Post

Simple .NET Web API service for Azure Blob Storage

Simple .NET Web API service for Azure Blob Storage

This blog runs on Jekyll, a lightweight static website builder where content is written in markdown and the whole app is repackaged from scratch at build time. This includes assets, like any images or other media used in site content. The obvious medium-long-term problem with this is as the total amount of content increases, the longer it will take to rebuild the whole app every time.

The problem

It all seems a bit inefficient that all this I/O needs to be done just to correct a spelling mistake in a single file, so my idea was to shift all the media files onto Azure Blob Storage and reference them in the markdown. Pretty standard use case for blob storage. Jekyll hints toward this by including options to set up a media CDN out-of-the-box.

The problem arises when it comes to how blob storage is addressed. I’d rather keep the blob.core.windows.net domain hidden from view - this should be easily solved by adding a custom domain (how about media.hornbyjw.tech) to the blob service on Azure, right…?

Wrong! Custom domains on the Azure blob service are only available by default on HTTP, so functionally useless. The recommended alternative is Azure Front Door CDN, which costs a wedge and is tremendous overkill for what I plan to use it for.

So I figured I’ll just build it myself as a web API.

https://github.com/old-m/Hornbyjw.Media

Architecture

The media delivery server is a .NET web API which is deployed as an Azure App Service which sits in front of a private Storage Account and has a single endpoint. Requests hit /media and whatever follows is treated as the blob storage virtual path.

Architecture

Code

Code-wise in .NET 8 all we need is a bit of setup in Program.cs, and a controller. You could decouple all this and move logic to a services class, but I’m not bothering with that right now.

In Program.cs we need to set up DI for the blob service client and pass in the URIs for the Storage Account. Also adding the Key Vault here though it doesn’t get any use right now.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Get keys
var kvUrl = builder.Configuration["KeyVaultUrl"];
var storageUrl = builder.Configuration["StorageUrl"];

// Add Azure service clients
builder.Services.AddAzureClients(async clientBuilder =>
{
    // Register clients for each service
    clientBuilder.AddSecretClient(new Uri(kvUrl));
    clientBuilder.AddBlobServiceClient(new Uri(storageUrl));

    // Set a credential for all clients to use by default
    DefaultAzureCredential credential = new();
    clientBuilder.UseCredential(credential);

});

The config keys for the KV and Storage Account are kept as environment variables on the app service to save retaining them in source.

Setting up MediaController.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[ApiController]
[Route("media")]
public class MediaController : ControllerBase
{
    private readonly ILogger<MediaController> _logger;
    private readonly BlobServiceClient _blobServiceClient;
    private readonly IConfiguration _configuration;

    public MediaController(ILogger<MediaController> logger, BlobServiceClient blobServiceClient, IConfiguration configuration)
    {
        _logger = logger;
        _blobServiceClient = blobServiceClient;
        _configuration = configuration;
    }

    /// <summary>
    /// Get a blob item stored at a requested virtual path
    /// </summary>
    /// <param name="assetPath">The URL path including extension</param>
    /// <returns>A stream of the requested blob</returns>
    [Route("{**assetPath}")]
    [HttpGet]
    public async Task<IActionResult> Get(string assetPath)
    {
        var mimeType = CheckValidExtensionAndReturnMimeType(Path.GetExtension(assetPath));

        if (!string.IsNullOrEmpty(mimeType))
        {
            var container = _blobServiceClient.GetBlobContainerClient(_configuration.GetValue<string>("ContainerName"));

            // Download
            var blobClient = container.GetBlockBlobClient(assetPath);
            if (await blobClient.ExistsAsync())
            {
                var blobStream = await blobClient.OpenReadAsync().ConfigureAwait(false);
                return new FileStreamResult(blobStream, mimeType);
            }
        }
        return BadRequest("Requested a bad path.");
    }

The point of interest here is the [Route("{**assetPath}")]. The controller is listening on /media, and the ** in the route definition specifies that the endpoint will be hit for any path that follows /media, whether it is /image.jpg or /assets/images/image.jpg - and whatever that path happens to be is written to assetPath.

What follows is checking whether a file exists in the blob container at a path that matches that requested of the web API, and if so, simply serves it.

There is also CheckValidExtensionAndReturnMimeType(), which doubles up as a way to validate requests and get the mime type to return in the response at the same time.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
        /// <summary>
        /// Performs a few semantic tasks.
        /// 1. Checks that you've actually requested a file (with an extension)
        /// 2. Limits the types of file that one can request
        /// 3. While we're iterating through file extensions, return the mime type
        /// </summary>
        /// <param name="extension">The file extension to validate and return a mime type for</param>
        /// <returns>The mime type for your extension, if it exists and is valid, otherwise an empty string</returns>
        private string CheckValidExtensionAndReturnMimeType(string extension)
        {
            if (!string.IsNullOrEmpty(extension))
            {
                extension = extension.ToLower();

                switch (extension)
                {
                    case ".jpg":
                        return "image/jpeg";
                    case ".png":
                        return "image/png";
                    case ".gif":
                        return "image/gif";
                    case ".jpeg":
                        return "image/jpeg";
                    case ".webp":
                        return "image/webp";
                    case ".pdf":
                        return "application/pdf";
                    case ".ico":
                        return "image/x-icon";
                    case ".avif":
                        return "image/avif";
                    case ".webm":
                        return "video/webm";
                }
            }

            return string.Empty;
        }
    }
}

Right now it only accepts a few file types as I don’t expect to need anything else. You don’t really need to do this at all, but as far as I can tell, .NET can’t decide whether it wants to be properly helpful with mime type mapping so I’ve hard coded it.

Azure configuration

This deploys as an Azure App Service, and requires the use of the built-in Environment Variables to look up the blob account URL. It also needs to authenticate with the Storage Account (unless you’re making it public, which sort of defeats the point).

To authenticate, the app will need a system-assigned managed identity set up with permissions to read from the Storage Account.

Setting up Managed Identity on an App Service

Copy the Object ID, go to the Storage Account > Access Control > Role Assignments, then Add > Add Role Assignment. Choose Storage Blob Data reader, then when selecting members to add, paste the Object ID. That’s about it.

Setting up RBAC

Sometimes RBAC can be a bit funny and take a while to sort itself out, and even more sometimes just not seem to work at all. In that case, there are SAS tokens and Access Keys…but that is a story for another time.

Future enhancements

  • Some sort of caching.
  • General performance improvements where I can make them.
If you have any thoughts, send me an email