Using Cloudflare With Kentico - Purging Cached Media Files

This month I've been writing some blog posts on why I decided to start using Cloudflare service for my website and utilising its API to allow me to purge cached files from the Cloudflare CDN on demand. Before reading further, I highly suggest perusing those posts just to put everything into context for my reasoning into using Cloudflare as well as the C# code that interacts with the API, which I will be referencing later on within this very post.

My intial Cloudflare integration evolves around serving media files more efficiently through a CDN and having the ability to refresh these files automatically as updates are made within the Kentico CMS. Cloudflare's CDN services can help cache your content across their large global network, moving static files closer to your visitor.

Based on the Page Rules I configured within the Cloudflare dashboard, I am caching all media library files served through the /getmedia/ URL path into the Cloudflare CDN. The same file will be served through the CDN until the set cache limit has expired. We need to implement functionality that will add some automation to the Kentico platform to purge the cache of a specific media library file when updated.

Add A Global Event

I created an event handler for the updating of Media library files as I wanted to get details of the file being updated by leveraging the MediaFileInfo class to access the Update.After event.

protected override void OnInit()
{
    base.OnInit();

    MediaFileInfo.TYPEINFO.Events.Update.After += Update_After;
}

private void Update_After(object sender, ObjectEventArgs e)
{
    MediaFileInfo fileInfo = e.Object as MediaFileInfo;

    GlobalEventFunctions.PurgeMediaCache(fileInfo);
}

PurgeMediaCache() Method

The event above calls a GlobalEventFunctions.PurgeMediaCache() method that will pass the information about the changed file ready for purging. The file URL parsed to the Cloudflare.PurgeSelectedFiles() method needs to be exact and take into consideration how your instance of Kentico is serving media files. If Permanent URL's are being used the /getmedia/ URL needs to be constructed consisting of:

  • Current domain
  • File GUID
  • File Name
  • File Extension

Otherwise, we can just use get the file path as normal to where the media file resides.

public class GlobalEventFunctions
{
    /// <summary>
    /// Purges a file from the Cloudflare cache.
    /// </summary>
    /// <param name="fileInfo"></param>
    public static void PurgeMediaCache(MediaFileInfo fileInfo)
    {
        bool permanentURLEnabled = SettingsKeyInfoProvider.GetBoolValue($"{SiteContext.CurrentSiteName}.CMSMediaUsePermanentURLs");
        string filePath = string.Empty;
            
        if (permanentURLEnabled)
            filePath = $"{GetCurrentDomain()}/getmedia/{fileInfo.FileGUID.ToString()}/{fileInfo.FileName}{fileInfo.FileExtension}";
        else
            filePath = $"{GetCurrentDomain()}/{fileInfo.FilePath}";

        try
        {
            // Get code from: https://www.surinderbhomra.com/Blog/Post/2018/11/11/Cloudflare-API-Purge-Files-By-URL-In-C
            CloudflareCacheHelper cloudflareHelper = new CloudflareCacheHelper();

            cloudflareHelper.PurgeSelectedFiles(new List<string> { filePath });
        }
        catch (Exception ex)
        {
            EventLogProvider.LogException("Cloudflare Purge File Cache", "CLOUDFLARE_PURGE", ex, SiteContext.CurrentSiteID, $"Purge File: {filePath}");
        }
    }

    /// <summary>
    /// Get domain from current http context.
    /// </summary>
    /// <returns></returns>
    private static string GetCurrentDomain()
    {
        return $"{HttpContext.Current.Request.Url.Scheme}{Uri.SchemeDelimiter}{HttpContext.Current.Request.Url.Host}{(!HttpContext.Current.Request.Url.IsDefaultPort ? $":{HttpContext.Current.Request.Url.Port}" : null)}";
    }
}

We need not consider any other scenarios, such as insert or deletion. If a file is inserted, there is nothing to purge as it's a new file that will be cached directly into in the CDN on first request and when it comes to deletion we can just wait for the cache to expire.

What's Next?

The integration I have detailed so far is just scratching the surface of what Cloudflare has to offer and will investigate further on pushing more content over to the CDN. One area, in particular, I am looking into is carrying out full page caching. You might be thinking why even bother as Kentico has pretty good caching mechanisms already in place?

Well Cloudflare has a really neat feature called "Always Online", where a cached version of a page is served if on the off chance it happens to go down or requires a reboot to install key security updates. But implementing this feature requires strict Page Rules to be setup within the Cloudflare dashboard to ensure the general workings of Kentico are not effected.

Cloudflare API - Purge Files By URL In C#

Earlier this week I wrote about the reasons to why I decided to use Cloudflare for my website. I've been working on utilising Cloudflare's API to purge the cache on demand for when files need to be updated within the CDN. To do this, I decided to write a method that will primarily use one API endpoint - /purge_cache. This endpoint allows a maximum of 30 URL's at one time to be purged, which is flexible enough to fit the majority of day-to-day use cases.

To communicate with the API, we need to provide three pieces of information:

  1. Account Email Address
  2. Zone ID
  3. API Key

The last two pieces of information can be found within the dashboard of your Cloudflare account.

Code - CloudflareCacheHelper Class

The CloudflareCacheHelper class consists of a single method PurgeSelectedFiles() and the following class objects used for serializing and deserializing our responses from API requests:

  • CloudflareFileInfo
  • CloudflareZone
  • CloudflareResultInfo
  • CloudflareResponse

Not all the properties within each of the class objects are being used at the moment based on the requests I am making. But the CloudflareCacheHelper class will be updated with more methods as I delve further into Cloudflare's functionality.

public class CloudflareCacheHelper
{
    public string _userEmail;
    public string _apiKey;
    public string _zoneId;

    private readonly string ApiEndpoint = "https://api.cloudflare.com/client/v4";

    /// <summary>
    /// By default the Cloudflare API values will be taken from the Web.Config.
    /// </summary>
    public CloudflareCacheHelper()
    {
        _apiKey = ConfigurationManager.AppSettings["Cloudflare.ApiKey"];
        _userEmail = ConfigurationManager.AppSettings["Cloudflare.UserEmail"];
        _zoneId = ConfigurationManager.AppSettings["Cloudflare.ZoneId"];
    }

    /// <summary>
    /// Set the Cloudflare API values explicitly.
    /// </summary>
    /// <param name="userEmail"></param>
    /// <param name="apiKey"></param>
    /// <param name="zoneId"></param>
    public CloudflareCacheHelper(string userEmail, string apiKey, string zoneId)
    {
        _userEmail = userEmail;
        _apiKey = apiKey;
        _zoneId = zoneId;
    }
        
    /// <summary>
    /// A collection of file paths (max of 30) will be accepted for purging cache.
    /// </summary>
    /// <param name="filePaths"></param>
    /// <returns>Boolean value on success or failure.</returns>
    public bool PurgeSelectedFiles(List<string> filePaths)
    {
        CloudflareResponse purgeResponse = null;

        if (filePaths?.Count > 0)
        {
            try
            {
                HttpWebRequest purgeRequest = WebRequest.CreateHttp($"{ApiEndpoint}/zones/{_zoneId}/purge_cache");
                purgeRequest.Method = "POST";
                purgeRequest.ContentType = "application/json";
                purgeRequest.Headers.Add("X-Auth-Email", _userEmail);
                purgeRequest.Headers.Add("X-Auth-Key", _apiKey);

                #region Create list of Files for Submission In The Structure The Response Requires

                CloudflareFileInfo fileInfo = new CloudflareFileInfo
                {
                    Files = filePaths
                };

                byte[] data = Encoding.ASCII.GetBytes(JsonConvert.SerializeObject(fileInfo));

                purgeRequest.ContentLength = data.Length;

                using (Stream fileStream = purgeRequest.GetRequestStream())
                {
                    fileStream.Write(data, 0, data.Length);
                    fileStream.Flush();
                }

                #endregion

                using (WebResponse response = purgeRequest.GetResponse())
                {
                    using (StreamReader purgeStream = new StreamReader(response.GetResponseStream()))
                    {
                        string responseJson = purgeStream.ReadToEnd();

                        if (!string.IsNullOrEmpty(responseJson))
                            purgeResponse = JsonConvert.DeserializeObject<CloudflareResponse>(responseJson);
                    }
                }
            }
            catch (Exception ex)
            {
                throw ex;
            }

            return purgeResponse.Success;
        }

        return false;
    }

    #region Cloudflare Class Objects

    public class CloudflareFileInfo
    {
        [JsonProperty("files")]
        public List<string> Files { get; set; }
    }

    public class CloudflareZone
    {
        [JsonProperty("id")]
        public string Id { get; set; }

        [JsonProperty("type")]
        public string Type { get; set; }

        [JsonProperty("name")]
        public string Name { get; set; }

        [JsonProperty("content")]
        public string Content { get; set; }

        [JsonProperty("proxiable")]
        public bool Proxiable { get; set; }

        [JsonProperty("proxied")]
        public bool Proxied { get; set; }

        [JsonProperty("ttl")]
        public int Ttl { get; set; }

        [JsonProperty("priority")]
        public int Priority { get; set; }

        [JsonProperty("locked")]
        public bool Locked { get; set; }

        [JsonProperty("zone_id")]
        public string ZoneId { get; set; }

        [JsonProperty("zone_name")]
        public string ZoneName { get; set; }

        [JsonProperty("modified_on")]
        public DateTime ModifiedOn { get; set; }

        [JsonProperty("created_on")]
        public DateTime CreatedOn { get; set; }
    }

    public class CloudflareResultInfo
    {
        [JsonProperty("page")]
        public int Page { get; set; }

        [JsonProperty("per_page")]
        public int PerPage { get; set; }

        [JsonProperty("count")]
        public int Count { get; set; }

        [JsonProperty("total_count")]
        public int TotalCount { get; set; }
    }

    public class CloudflareResponse
    {
        [JsonProperty("result")]
        public CloudflareZone Result { get; set; }

        [JsonProperty("success")]
        public bool Success { get; set; }

        [JsonProperty("errors")]
        public IList<object> Errors { get; set; }

        [JsonProperty("messages")]
        public IList<object> Messages { get; set; }

        [JsonProperty("result_info")]
        public CloudflareResultInfo ResultInfo { get; set; }
    }

    #endregion
}

Example - Purging Cache of Two Files

A string collection of URL's can be passed into the method to allow for the cache of a batch of files to be purged in a single request. If all goes well, the success response should be true.

CloudflareCacheHelper cloudflareCache = new CloudflareCacheHelper();

bool isSuccess = cloudflareCache.PurgeSelectedFiles(new List<string> {
                                    "https://www.surinderbhomra.com/getmedia/7907d934-805f-4bd3-86e7-a6b2027b4ba6/CloudflareResponseMISS.png",
                                    "https://www.surinderbhomra.com/getmedia/89679ffc-ca2f-4c47-8d41-34a6efdf7bb8/CloudflareResponseHIT.png"
                                });

Rate Limits

The Cloudflare API sets a maximum of 1,200 requests in a five minute period. Cache-Tag purging has a lower rate limit of up to 2,000 purge API calls in every 24 hour period. You may purge up to 30 tags in one API call.

My Reasons for Using Cloudflare With Kentico

A couple day ago my website got absolutely hammered by a wave of constant SQL injection attacks by the same IP over a time period of a couple hours.

I only managed to notice this whilst perusing the Event Log within the Kentico Administration interface. I don't normally check my own error logs as regularly as I should do, but since my site has recently gone through a bit of a revamp (which I'm still yet to post about), I wanted to ensure I haven't broken anything.

To be honest, I am flattered that someone would think this site is worth the time and energy in trying to hack my site. Trust me, it ain't worth it.

Even though Kentico has handled these attacks well, as a precaution I wanted to implement an additional layer of security before any further untoward activity reaches to my site. Being on a shared hosting platform my options are limited and my hands are tied to put in an infrastructure that doesn't cost the world.

Enter Cloudflare

I have always had some form of awareness of the Cloudflare content delivery network, just never put the service into practice. At one point I was looking into utilising Cloudflare to manage all my site media files over their CDN. But Cloudflare isn't just a CDN, it's able to offer much more:

  • Analytics - monitor traffic as well as caching ratio and more!
  • Firewall - manage access by IP, country, or query rules.
  • Rate limiting - protect your site or API from malicious traffic by blocking client IP addresses that hit a URL pattern and exceed a threshold you define.
  • Page rules - to allow caching to be triggered by a number of rules targeting specific areas of a site.

The great thing is that these options are part of the free plan... even though there are restrictions to the number of settings you are able to put in place. The security options alone was enough of a reason to try out Cloudflare. But for my needs, the free plan seemed to tick all the boxes. I am just scratching the surface to what Cloudflare has to offer and have already got a few of the features working alongside Kentico.

How I'm Using Cloudflare With Kentico

As security was a main concern for me, one of the first things I did was to add in some rules through the firewall and block suspicious traffic. Naturally the next was to take advantage of the CDN capabilities. I wouldn't recommend full site caching unless you have some pretty strict page rules in place as this has a chance to cause issues with the Kentico Admin interface. By default, Cloudflare doesn't cache HTML pages, which is a good thing as it gives us a plain canvas to target the areas we want to cache.

Being on the free plan, I only had three page rules at my disposal and made the decision to cache the following parts of my site.

Area Regex Rule Settings
Media Library Files (using Permanent URL's) *surinderbhomra.com/
getmedia/*

Cache Level: Caches Everything
Edge Cache TTL: A month
Browser Cache TTL: 16 days

Site CSS, JavaScript and Images *surinderbhomra.com/
resources/*
Cache Level: Caches Everything
Edge Cache TTL: A month
Browser Cache TTL: 16 days
ScriptResource.axd/WebResource.axd *surinderbhomra.com/*.axd Cache Level: Caches Everything
Edge Cache TTL: 7 days
Browser Cache TTL: 7 days

Two cache types are used:

  1. Edge Cache TTL - is the setting that controls how long CloudFlare's edge servers will cache a resource before requesting a fresh copy from your server. 
  2. Browser Cache TTL - is the time that CloudFlare instructs a visitor's browser to cache a resource. Until this time expires, the browser will load the resource from its local cache and speeding up the request.

I am caching my assets for a considerable amount of time, which begs the question how will changes to files purge the cache in Cloudflare? Luckily, Cloudflare has quite a nice API, where I have the ability to purge everything or individual files (maximum of 30 in one request).

Purge Media Library File Cache

I am in the middle of testing a GlobalEvent handler that will carry out the following steps when files are inserted or updated:

  • Get path of the file.
  • Check if site is using Permanent URL's. As the URL to the file will be constructured differently if enabled.
  • Convert the relative path to an absolute path.
  • Pass the absolute file path to Cloudflare using the following Purge Files by URL API endpoint.

Once I have carried out some further tests, I will be posting this code in a follow-up post.

Purge Site File Cache

Now attempting to purge the cache for site files such as JavaScript, CSS and images are a little more tricky as I need to keep an eye on the files changed. The easiest thing to do is I could write some code that will iterate through all the files in the /resources folder and purge everything from the CDN. Not the most elegant solution. Still, need to ponder on the correct implementation. If anyone has got any better suggestions, let me know.

How Do I Know Cloudflare Is Caching My Site Files?

It does take a little time in Cloudflare to cache all files on a site. It all depends on pages being viewed. A page needs to be loaded in order to submit all its contents to cache. On first load, the response header CF-Cache-Status will return a "MISS", which means the content has not been served from Cloudflare.

Cloudflare Response - MISS

However, when you go back to the page and re-check the page headers, the CF-Cache-Status should return "HIT". If this is not the case, check your Page Rules within the Cloudflare dashboard.

Cloudflare Response - HIT

Is Cloudflare Worth It?

Quick answer - Yes!

Setup is very straight-forward. All that is required is to carry out a change to your domain and point your DNS to the DNS Cloudflare assigns to you. There is no downtime in doing this. As a result, overall site performance has improved and page speed test faired much better.

To give you a better insight for transparency, here are some statistics straight from my Cloudflare portal over a 24 hour period:

Requests Through Cloudflare
(Understandably, the number of cached items served through Cloudflare is low due to only caching specific areas)

Cloudflare Performance
(My basic shared hosting should now be performing better as less requests are being served via the origin server)

Cloudflare Detected Threats
(Blocked threats - the reason why I decided to give Cloudflare a try in the first place)

I still have to carry out a lot more research in using Cloudflare to its full potential and will use my website as a test bed to see what I can achieve. My end goal is to make my website quicker and more secure!