// -----------------------------------------------------------------------
//
// Copyright (c) James South.
// Licensed under the Apache License, Version 2.0.
//
// -----------------------------------------------------------------------
namespace ImageProcessor.Web.Caching
{
#region Using
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using System.Web.Hosting;
using ImageProcessor.Helpers.Extensions;
using ImageProcessor.Web.Config;
using ImageProcessor.Web.Helpers;
#endregion
///
/// The disk cache.
///
internal sealed class DiskCache
{
#region Fields
///
/// The maximum number of days to cache files on the system for.
///
internal static readonly int MaxFileCachedDuration = ImageProcessorConfig.Instance.MaxCacheDays;
///
/// The valid sub directory chars. This used in combination with the file limit per folder
/// allows the storage of 360,000 image files in the cache.
///
private const string ValidSubDirectoryChars = "abcdefghijklmnopqrstuvwxyz0123456789";
///
/// The maximum number of files allowed in the directory.
///
///
/// NTFS directories can handle up to 10,000 files in the directory before slowing down.
/// This will help us to ensure that don't go over that limit.
///
///
///
///
private const int MaxFilesCount = 10000;
///
/// The regular expression to search strings for file extensions.
///
private static readonly Regex FormatRegex = new Regex(
@"(jpeg|png|bmp|gif)", RegexOptions.RightToLeft | RegexOptions.Compiled);
///
/// The regular expression to search strings for valid subfolder names.
/// We're specifically not using a shorter regex as we need to be able to iterate through
/// each match group.
///
private static readonly Regex SubFolderRegex =
new Regex(
@"(\/([a-z]|[0-9])\/(a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p|q|r|s|t|u|v|w|x|y|z|0|1|2|3|4|5|6|7|8|9)\/)",
RegexOptions.Compiled);
///
/// The absolute path to virtual cache path on the server.
///
private static readonly string AbsoluteCachePath =
HostingEnvironment.MapPath(ImageProcessorConfig.Instance.VirtualCachePath);
///
/// The request for the image.
///
private readonly HttpRequest request;
///
/// The request path for the image.
///
private readonly string requestPath;
///
/// The full path for the image.
///
private readonly string fullPath;
///
/// The image name
///
private readonly string imageName;
///
/// Whether the request is for a remote image.
///
private readonly bool isRemote;
#endregion
#region Constructors
///
/// Initializes a new instance of the class.
///
///
/// The request for the image.
///
///
/// The request path for the image.
///
///
/// The full path for the image.
///
///
/// The image name.
///
///
/// Whether the request is for a remote image.
///
public DiskCache(HttpRequest request, string requestPath, string fullPath, string imageName, bool isRemote)
{
this.request = request;
this.requestPath = requestPath;
this.fullPath = fullPath;
this.imageName = imageName;
this.isRemote = isRemote;
this.CachedPath = this.GetCachePath();
}
#endregion
#region Properties
///
/// Gets the cached path.
///
internal string CachedPath { get; private set; }
#endregion
#region Methods
#region Internal
///
/// Creates the series of directories required to house our cached images.
/// The images are stored in paths that are based upon the MD5 of their full request path
/// taking the first and last characters of the hash to determine their location.
/// ~/cache/a/1/ab04g67p91.jpg
/// This allows us to store 36 folders within 36 folders giving us a total of 12,960,000 images.
///
///
/// True if the directories are successfully created; otherwise, false.
///
[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1650:ElementDocumentationMustBeSpelledCorrectly", Justification = "Reviewed. Suppression is OK here.")]
internal static bool CreateDirectories()
{
bool success = true;
try
{
// Split up our characters into an array to loop though.
char[] characters = ValidSubDirectoryChars.ToCharArray();
// Loop through and create the first level.
Parallel.ForEach(
characters,
(character, loop) =>
{
string firstSubPath = Path.Combine(AbsoluteCachePath, character.ToString(CultureInfo.InvariantCulture));
DirectoryInfo directoryInfo = new DirectoryInfo(firstSubPath);
if (!directoryInfo.Exists)
{
directoryInfo.Create();
// Loop through and create the second level.
Parallel.ForEach(
characters,
(subCharacter, subLoop) =>
{
string secondSubPath = Path.Combine(firstSubPath, subCharacter.ToString(CultureInfo.InvariantCulture));
DirectoryInfo subDirectoryInfo = new DirectoryInfo(secondSubPath);
if (!subDirectoryInfo.Exists)
{
subDirectoryInfo.Create();
}
});
}
});
}
catch
{
success = false;
}
return success;
}
///
/// Gets the virtual path to the cached processed image.
///
/// The virtual path to the cached processed image.
internal string GetVirtualCachedPath()
{
string applicationPath = this.request.PhysicalApplicationPath;
string virtualDir = this.request.ApplicationPath;
virtualDir = virtualDir == "/" ? virtualDir : (virtualDir + "/");
if (applicationPath != null)
{
return this.CachedPath.Replace(applicationPath, virtualDir).Replace(@"\", "/");
}
throw new InvalidOperationException(
"We can only map an absolute back to a relative path if the application path is available.");
}
///
/// Adds an image to the cache.
///
///
/// The last write time.
///
///
/// The .
///
internal async Task AddImageToCacheAsync(DateTime lastWriteTimeUtc)
{
string key = Path.GetFileNameWithoutExtension(this.CachedPath);
DateTime expires = DateTime.UtcNow.AddDays(MaxFileCachedDuration).ToUniversalTime();
CachedImage cachedImage = new CachedImage(this.CachedPath, MaxFileCachedDuration, lastWriteTimeUtc, expires);
await PersistantDictionary.Instance.AddAsync(key, cachedImage);
}
///
/// Returns a value indicating whether the original file is new or has been updated.
///
///
/// True if the the original file is new or has been updated; otherwise, false.
///
internal async Task IsNewOrUpdatedFileAsync()
{
string key = Path.GetFileNameWithoutExtension(this.CachedPath);
CachedImage cachedImage;
bool isUpdated = false;
if (this.isRemote)
{
if (PersistantDictionary.Instance.TryGetValue(key, out cachedImage))
{
// Can't check the last write time so check to see if the cached image is set to expire
// or if the max age is different.
if (cachedImage.ExpiresUtc < DateTime.UtcNow.AddDays(-MaxFileCachedDuration)
|| cachedImage.MaxAge != MaxFileCachedDuration)
{
if (await PersistantDictionary.Instance.TryRemoveAsync(key))
{
isUpdated = true;
}
}
}
else
{
// Nothing in the cache so we should return true.
isUpdated = true;
}
}
else
{
// Test now for locally requested files.
if (PersistantDictionary.Instance.TryGetValue(key, out cachedImage))
{
FileInfo imageFileInfo = new FileInfo(this.requestPath);
if (imageFileInfo.Exists)
{
// Check to see if the last write time is different of whether the
// cached image is set to expire or if the max age is different.
if (imageFileInfo.LastWriteTimeUtc != cachedImage.LastWriteTimeUtc
|| cachedImage.ExpiresUtc < DateTime.UtcNow.AddDays(-MaxFileCachedDuration)
|| cachedImage.MaxAge != MaxFileCachedDuration)
{
if (await PersistantDictionary.Instance.TryRemoveAsync(key))
{
isUpdated = true;
}
}
}
}
else
{
// Nothing in the cache so we should return true.
isUpdated = true;
}
}
return isUpdated;
}
///
/// Sets the LastWriteTime of the cached file to match the original file.
///
///
/// The set to the last write time of the file.
///
internal async Task SetCachedLastWriteTimeAsync()
{
// Create Action delegate for IsNewOrUpdatedFile.
return await TaskHelpers.Run(() => this.SetCachedLastWriteTime());
}
///
/// Purges any files from the file-system cache in the given folders.
///
///
/// The .
///
internal async Task TrimCachedFoldersAsync()
{
// Create Action delegate for TrimCachedFolders.
await TaskHelpers.Run(this.TrimCachedFolders);
}
#endregion
#region Private
///
/// Sets the LastWriteTime of the cached file to match the original file.
///
///
/// The of the original and cached file.
///
private DateTime SetCachedLastWriteTime()
{
FileInfo cachedFileInfo = new FileInfo(this.CachedPath);
DateTime lastWriteTime = DateTime.MinValue.ToUniversalTime();
if (this.isRemote)
{
if (cachedFileInfo.Exists)
{
lastWriteTime = cachedFileInfo.LastWriteTimeUtc;
}
}
else
{
FileInfo imageFileInfo = new FileInfo(this.requestPath);
if (imageFileInfo.Exists && cachedFileInfo.Exists)
{
DateTime dateTime = imageFileInfo.LastWriteTimeUtc;
cachedFileInfo.LastWriteTimeUtc = dateTime;
lastWriteTime = dateTime;
}
}
return lastWriteTime;
}
///
/// Purges any files from the file-system cache in the given folders.
///
private async void TrimCachedFolders()
{
// Group each cache folder and clear any expired items or any that exeed
// the maximum allowable count.
var groups = PersistantDictionary.Instance.ToList()
.GroupBy(x => SubFolderRegex.Match(x.Value.Path).Value)
.Where(g => g.Count() > MaxFilesCount);
foreach (var group in groups)
{
int groupCount = group.Count();
foreach (KeyValuePair pair in group.OrderBy(x => x.Value.ExpiresUtc))
{
// If the group count is equal to the max count minus 1 then we know we
// are counting down from a full directory not simply clearing out
// expired items.
if (groupCount == MaxFilesCount - 1)
{
break;
}
try
{
// Remove from the cache and delete each CachedImage.
FileInfo fileInfo = new FileInfo(pair.Value.Path);
string key = Path.GetFileNameWithoutExtension(fileInfo.Name);
if (await PersistantDictionary.Instance.TryRemoveAsync(key))
{
fileInfo.Delete();
groupCount -= 1;
}
}
// ReSharper disable EmptyGeneralCatchClause
catch
// ReSharper restore EmptyGeneralCatchClause
{
// Do nothing; skip to the next file.
}
}
}
}
///
/// Gets the full transformed cached path for the image.
/// The images are stored in paths that are based upon the MD5 of their full request path
/// taking the first and last characters of the hash to determine their location.
/// ~/cache/a/1/ab04g67p91.jpg
/// This allows us to store 36 folders within 36 folders giving us a total of 12,960,000 images.
///
/// The full cached path for the image.
[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1650:ElementDocumentationMustBeSpelledCorrectly", Justification = "Reviewed. Suppression is OK here.")]
private string GetCachePath()
{
string cachedPath = string.Empty;
if (AbsoluteCachePath != null)
{
// Use an md5 hash of the full path including the querystring to create the image name.
// That name can also be used as a key for the cached image and we should be able to use
// The first character of that hash as a subfolder.
string parsedExtension = this.ParseExtension(this.fullPath);
string fallbackExtension = this.imageName.Substring(this.imageName.LastIndexOf(".", StringComparison.Ordinal) + 1);
string encryptedName = this.fullPath.ToMD5Fingerprint();
string firstSubpath = encryptedName.Substring(0, 1);
string secondSubpath = encryptedName.Substring(31, 1);
string cachedFileName = string.Format(
"{0}.{1}",
encryptedName,
!string.IsNullOrWhiteSpace(parsedExtension) ? parsedExtension : fallbackExtension);
cachedPath = Path.Combine(AbsoluteCachePath, firstSubpath, secondSubpath, cachedFileName);
}
return cachedPath;
}
///
/// Returns the correct file extension for the given string input
///
///
/// The string to parse.
///
///
/// The correct file extension for the given string input if it can find one; otherwise an empty string.
///
private string ParseExtension(string input)
{
Match match = FormatRegex.Match(input);
return match.Success ? match.Value : string.Empty;
}
#endregion
#endregion
}
}