Recently I am working on an internal OA system for a small company. One of their business requirement is for the users to upload attachments when creating a ticket. Azure Blob Storage is ideal for this scenario. However, the attachments are sized from a few KB to hundreds of MB, so it is necessary to show a progress bar when user upload the files. Let's see how can we easily do it with Azure.

The Design


The OA system is using ASP.NET Core as it's backend API. So basically I can come up with two designs for uploading files to Azure Blob Storage.

  1. Create an API that accept files from web front-end and then upload to Azure.
  2. Create an API return just SAS token for Azure Storage, web front-end then use this token to upload files directly to Azure without calling our own API.

Option 1 is too expensive because this require API server to allow large request body, and I have to write a lot of complicated code to enable reporting progress to client, not to mention all the error scenarios. 

Since Azure Storage already got JavaScript API that can report upload progress, I decided to go with option 2.

What need to be prepared in Azure


Container

Create a container for storing uploaded files. e.g. "attachments".

Connection String

Copy the connection string from the storage account for later use.

CORS

Allow CORS on Blob service for your domain. For demo, I just enable all origins and all methods. Please do not do this in production. To upload files, only HTTP PUT is needed.

Create API to return SAS Token


Install latest Azure.Storage.Blobs package.

<PackageReference Include="Azure.Storage.Blobs" Version="12.9.1" />

Add Azure Storage settings to appsettings.json

"AppSettings": {
  "AzureStorage": {
    "ConnectionString": "<connection string>",
    "ContainerName": "attachments",
    "TokenExpirationMinutes": 20
  }
}

Please adjust TokenExpirationMinutes as you need.

Create controller and action

[ApiController]
[Route("[controller]")]
public class AttachmentController : ControllerBase
{
    private readonly IConfiguration _configuration;

    public AttachmentController(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    [HttpGet("token")]
    [ProducesResponseType(StatusCodes.Status409Conflict)]
    [ProducesResponseType(typeof(AzureStorageSASResult), StatusCodes.Status200OK)]
    public IActionResult SASToken()
    {
        var azureStorageConfig = _configuration.GetSection("AppSettings:AzureStorage").Get<AzureStorageConfig>();
        BlobContainerClient container = new(azureStorageConfig.ConnectionString, azureStorageConfig.ContainerName);

        if (!container.CanGenerateSasUri) return Conflict("The container can't generate SAS URI");

        var sasBuilder = new BlobSasBuilder
        {
            BlobContainerName = container.Name,
            Resource = "c",
            ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(azureStorageConfig.TokenExpirationMinutes)
        };

        sasBuilder.SetPermissions(BlobContainerSasPermissions.All);

        var sasUri = container.GenerateSasUri(sasBuilder);

        var result = new AzureStorageSASResult
        {
            AccountName = container.AccountName,
            AccountUrl = $"{container.Uri.Scheme}://{container.Uri.Host}",
            ContainerName = container.Name,
            ContainerUri = container.Uri,
            SASUri = sasUri,
            SASToken = sasUri.Query.TrimStart('?'),
            SASPermission = sasBuilder.Permissions,
            SASExpire = sasBuilder.ExpiresOn
        };

        return Ok(result);
    }
}

public class AzureStorageConfig
{
    public string ConnectionString { get; set; }
    public string ContainerName { get; set; }
    public int TokenExpirationMinutes { get; set; }
}

public class AzureStorageSASResult
{
    public string AccountName { get; set; }
    public string AccountUrl { get; set; }
    public Uri ContainerUri { get; set; }
    public string ContainerName { get; set; }
    public Uri SASUri { get; set; }
    public string SASToken { get; set; }
    public string SASPermission { get; set; }
    public DateTimeOffset SASExpire { get; set; }
}

Notice the SAS Permissions are set to All for demo here, in production, please set to only the permissions you need for security reasons.

sasBuilder.SetPermissions(BlobContainerSasPermissions.All);

A correct API response will look like this

{
  "accountName": "cinderellastorage",
  "accountUrl": "https://cinderellastorage.blob.core.windows.net",
  "containerUri": "https://cinderellastorage.blob.core.windows.net/attachments",
  "containerName": "attachments",
  "sasUri": "https://cinderellastorage.blob.core.windows.net/attachments?sv=2020-08-04&se=2021-07-07T05%3A37%3A07Z&sr=c&sp=racwdxlt&sig=MxCEEtT%2FGVbjksDzgzVgvRqucQfjKd4JOp6zsId0h5c%3D",
  "sasToken": "sv=2020-08-04&se=2021-07-07T05%3A37%3A07Z&sr=c&sp=racwdxlt&sig=MxCEEtT%2FGVbjksDzgzVgvRqucQfjKd4JOp6zsId0h5c%3D",
  "sasPermission": "racwdxlt",
  "sasExpire": "2021-07-07T05:37:07.6443444+00:00"
}

Web Front-End


I am not a pro front-end developer. So I used code from this post with a little modification. The azure-storage.blob.min.js library can be downloaded from https://aka.ms/downloadazurestoragejs 

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <title>Upload</title>
    <script src="azure-storage.blob.min.js"></script>
    <script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-3.6.0.min.js"></script>
    <script type="text/javascript">
        $(document).on('change', ':file', function () {
            var input = $(this)
            var label = $('#BrowseInput').val(input.val().replace(/\\/g, '/').replace(/.*\//, ''));
        });
    </script>
    <script type="text/javascript">
        function displayProcess(process) {
            document.getElementById("uploadProgressBar").style.width = process + '%';
            document.getElementById("uploadProgressBar").innerHTML = process + '%';
        }

        function uploadBlob() {
            displayProcess(0);
            document.getElementById("uploadProgressBarContainer").classList.remove('hidden');

            // TODO: Call API to get URL, SAS Token, Container name
            var blobUri = 'https://cinderellastorage.blob.core.windows.net';
            var containerName = 'attachments';
            var sasToken = 'sv=2020-08-04&se=2021-07-07T05%3A40%3A03Z&sr=c&sp=racwdxlt&sig=NKDKAdQESM03GxCfFs2FLHHYLWJzqRYy4LKdyxTcVx8%3D';

            var blobService = AzureStorage.Blob.createBlobServiceWithSas(blobUri, sasToken);

            var file = $('#FileInput').get(0).files[0];

            var customBlockSize = file.size > 1024 * 1024 * 32 ? 1024 * 1024 * 4 : 1024 * 512;
            blobService.singleBlobPutThresholdInBytes = customBlockSize;

            var finishedOrError = false;
            var speedSummary = blobService.createBlockBlobFromBrowserFile(containerName, file.name, file, { blockSize: customBlockSize }, function (error, result, response) {
                finishedOrError = true;
                if (error) {
                    alert('Error');
                } else {
                    displayProcess(100);
                }
            });

            function refreshProgress() {
                setTimeout(function () {
                    if (!finishedOrError) {
                        var process = speedSummary.getCompletePercent();
                        displayProcess(process);
                        refreshProgress();
                    }
                }, 200);
            }

            refreshProgress();
        }
    </script>
</head>

<body>
    <div class="modal-dialog">
        <div class="modal-content">
            <form asp-controller="Home" asp-action="UploadSmallFile" enctype="multipart/form-data" id="BlobUploadForm"
                method="post" class="form-label-left" role="form">
                <div class="modal-body">
                    <div class="form-group">
                        <div class="input-group">
                            <label class="input-group-btn">
                                <span class="btn btn-primary">
                                    Browse… <input type="file" style="display: none;" name="file" id="FileInput">
                                </span>
                            </label>
                            <input type="text" class="form-control" readonly="" id="BrowseInput">
                        </div>
                    </div>
                    <div class="form-group">
                        <div class="input-group">
                            <button type="button" value="Upload to Blob" class="btn btn-success" id="UploadBlob"
                                onclick="uploadBlob()">Upload to Blob</button>
                        </div>
                    </div>
                    <div class="form-group hidden" id="uploadProgressBarContainer">
                        Uploading...
                        <div class="progress">
                            <div class="progress-bar" role="progressbar" id="uploadProgressBar" aria-valuenow="60"
                                aria-valuemin="0" aria-valuemax="100" style="width: 0%;">
                                0%
                            </div>
                        </div>
                    </div>
                </div>
            </form>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
        crossorigin="anonymous"></script>
</body>
</html>

The basic idea in this code is to break large files to trunks and upload them piece by piece. Azure storage SDK will handle the upload and report the progress.

Finally, I can verify the file has been uploaded to Azure Storage.