Skip to content

Instantly share code, notes, and snippets.

@q42jaap
Last active May 8, 2018 20:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save q42jaap/377d1a2b060c017b244a6a5e250585c4 to your computer and use it in GitHub Desktop.
Save q42jaap/377d1a2b060c017b244a6a5e250585c4 to your computer and use it in GitHub Desktop.
CustomFileVersionProvider
using System;
public class CdnConfiguration
{
/// <summary>
/// Base url for the CDN, e.g. https://my-cdn.azureedge.net
/// </summary>
public string BaseUrl { get; set; }
/// <summary>
/// The time a static file should be cached. This assumes the version hash query string param matches with the current hash of the file.
/// </summary>
public TimeSpan CacheTime { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// The time a static file should be cached when the version hash query string param doesn't match the current hash of the file.
/// </summary>
public TimeSpan MismatchCacheTime { get; set; } = TimeSpan.FromMinutes(1);
}
using System;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
public class CdnUrlService
{
private readonly CdnConfiguration cdnConfiguration;
private readonly ILogger<CdnUrlService> logger;
private readonly CustomFileVersionProvider fileVersionProvider;
public CdnUrlService(IOptions<CdnConfiguration> cdnConfiguration,
ILogger<CdnUrlService> logger,
IHostingEnvironment hostingEnvironment,
IMemoryCache memoryCache)
{
this.cdnConfiguration = cdnConfiguration.Value;
this.logger = logger;
fileVersionProvider = new CustomFileVersionProvider(
hostingEnvironment.WebRootFileProvider,
memoryCache);
}
public TimeSpan CacheTime => cdnConfiguration.CacheTime;
public TimeSpan MismatchCacheTime => cdnConfiguration.MismatchCacheTime;
/// <summary>
/// Add CDN prefix to url and adds a hash calcuted on the files binary. This has the advantage that only if the file changes, the
/// version changes so the cache is cleared only when needed.
/// </summary>
/// <param name="path">Path to file</param>
/// <param name="addFileVersion">Boolean whether or not to add a hash for cachebusting (Default: true)</param>
/// <returns></returns>
public string PrefixCdnBaseUrl(string path, bool addFileVersion = true)
{
//Add file version to url for cache busting
if (addFileVersion)
{
path = fileVersionProvider.AddFileVersionToPath(path);
}
var returnUrl = "/" + path.TrimStart(new[] {'/'});
if (!string.IsNullOrEmpty(cdnConfiguration.BaseUrl))
{
returnUrl = new Uri(new Uri(cdnConfiguration.BaseUrl), path).AbsoluteUri;
}
return returnUrl;
}
/// <summary>
/// Checks the passed versionHash with the file on disk. If it's the same, true is returned, false otherwise.
/// </summary>
/// <param name="path">The relative path</param>
/// <param name="versionHash">The version that was reported</param>
/// <returns>True if the versionHash matches.</returns>
public bool VerifyVersionHash(string path, string versionHash)
{
var currentVersionHash = fileVersionProvider.GetVersionHash(path);
var result = versionHash == currentVersionHash;
if (!result)
{
logger.LogWarning($"Given version hash \"{versionHash}\" for {path} doesn't match current hash \"{currentVersionHash}\".");
}
return result;
}
using System;
using Microsoft.AspNetCore.Antiforgery.Internal;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.FileProviders;
/// <summary>
/// Heavily inspired by https://github.com/aspnet/Mvc/blob/5e019bd707497b0d28a7387c7ed66447a1907d59/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/FileVersionProvider.cs
/// We needed GetHashForFile to be public with caching, so we needed to code our own.
/// See also https://github.com/aspnet/Mvc/issues/7735
/// </summary>
public class CustomFileVersionProvider
{
public const string VersionKey = "v";
private static readonly char[] QueryStringAndFragmentTokens = { '?', '#' };
private readonly IFileProvider fileProvider;
private readonly IMemoryCache cache;
/// <summary>
/// Creates a new instance of <see cref="CustomFileVersionProvider"/>.
/// </summary>
/// <param name="fileProvider">The file provider to get and watch files.</param>
/// <param name="cache"><see cref="IMemoryCache"/> where versioned urls of files are cached.</param>
public CustomFileVersionProvider(
IFileProvider fileProvider,
IMemoryCache cache)
{
this.fileProvider = fileProvider ?? throw new ArgumentNullException(nameof(fileProvider));
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
}
/// <summary>
/// Adds version query parameter to the specified file path.
/// </summary>
/// <param name="path">The path of the file to which version should be added.</param>
/// <returns>Path containing the version query string.</returns>
/// <remarks>
/// The query string is appended with the key "v".
/// </remarks>
public string AddFileVersionToPath(string path)
{
var resolvedPath = NormalizePath(path ?? throw new ArgumentNullException(nameof(path)));
if (IsPathAbsolute(resolvedPath))
{
// Don't append version if the path is absolute.
return path;
}
var hash = GetHashForPath(resolvedPath);
return hash != null ? QueryHelpers.AddQueryString(path, VersionKey, hash) : path;
}
/// <summary>
/// Gets the versionHash for the file
/// </summary>
/// <param name="path">Relative pathto the file</param>
/// <returns>The versionHash for the path, or null if the file was not found</returns>
public string GetVersionHash(string path)
{
var resolvedPath = NormalizePath(path ?? throw new ArgumentNullException(nameof(path)));
return IsPathAbsolute(resolvedPath) ? null : GetHashForPath(resolvedPath);
}
private string GetHashForPath(string resolvedPath)
{
return cache.GetOrCreate($"fileVersionHash|{resolvedPath}", entry =>
{
entry.AddExpirationToken(fileProvider.Watch(resolvedPath));
var fileInfo = fileProvider.GetFileInfo(resolvedPath);
return fileInfo.Exists ? CalcHashForFile(fileInfo) : null;
});
}
private static string CalcHashForFile(IFileInfo fileInfo)
{
using (var sha256 = CryptographyAlgorithms.CreateSHA256())
{
using (var readStream = fileInfo.CreateReadStream())
{
var hash = sha256.ComputeHash(readStream);
return WebEncoders.Base64UrlEncode(hash);
}
}
}
private static bool IsPathAbsolute(string resolvedPath)
{
return Uri.TryCreate(resolvedPath, UriKind.Absolute, out var uri) && !uri.IsFile;
}
private static string NormalizePath(string path)
{
var resolvedPath = path;
var queryStringOrFragmentStartIndex = path.IndexOfAny(QueryStringAndFragmentTokens);
if (queryStringOrFragmentStartIndex != -1)
{
resolvedPath = path.Substring(0, queryStringOrFragmentStartIndex);
}
return resolvedPath;
}
}
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
using System;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
public static class StaticFileCacheDuration
{
/// <summary>
/// Gets the cache duration for a static file based on the given "v" querystring parameter.
/// If the parameter matches the file version hash, the cache duration is <see cref="CdnConfiguration.CacheTime"/> (which has a default).
/// If the parameter doesn't match, a shorter <see cref="CdnConfiguration.MismatchCacheTime"/> is returned.
///
/// During a deploy, especially in multiple zones, there is a short amount of time HTML is served with a new version hash, but there
/// are still servers running that have the old resource on disk. The CDN doesn't know about these differences and could forward a request
/// with the new version hash to a server that still has the old version. Limiting the cache time prevents the browser (or proxies)
/// to store the old version for too long. This also means we don't need to purge the cdn cache after a deploy.
///
/// This method should be used in the StaticFileOptions.OnPrepareResponse callback.
/// </summary>
/// <example>
/// app.UseStaticFiles(new StaticFileOptions
/// {
/// OnPrepareResponse = context =>
/// {
/// var headers = context.Context.Response.GetTypedHeaders();
/// headers.CacheControl = new CacheControlHeaderValue
/// {
/// MaxAge = StaticFileCacheDuration.GetCacheDuration(context.Context),
/// Public = true
/// };
/// }
/// });
/// </example>
/// <param name="context">The <see cref="HttpContext"/> for the current request</param>
/// <returns>The time the resource indicated in the Request should be cached.</returns>
public static TimeSpan GetCacheDuration(HttpContext context)
{
var cdnUrlService = context.RequestServices.GetService<CdnUrlService>();
var requestVersionHash = context.Request.Query[CustomFileVersionProvider.VersionKey].FirstOrDefault();
if (!string.IsNullOrEmpty(requestVersionHash))
{
if (!cdnUrlService.VerifyVersionHash(context.Request.Path, requestVersionHash))
{
// we don't disable caching entirely, but set it to a short period so stale content won't end up in the cache for a long time.
return cdnUrlService.MismatchCacheTime;
}
}
return cdnUrlService.CacheTime;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment