// ----------------------------------------------------------------------- // // Copyright (c) James South. // Dual licensed under the MIT or GPL Version 2 licenses. // // ----------------------------------------------------------------------- namespace ImageProcessor.Web.Caching { #region Using using System; using System.Collections.Generic; 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; #endregion /// /// Encapsulates methods to handle disk caching of images. /// 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|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); #endregion #region Methods /// /// The create cache paths. /// /// /// The true if the cache directories are created successfully; otherwise, false. /// internal static bool CreateCacheDirectories() { try { Parallel.ForEach( ValidSubDirectoryChars.ToCharArray(), (extension, loop) => { string path = Path.Combine(AbsoluteCachePath, extension.ToString(CultureInfo.InvariantCulture)); DirectoryInfo directoryInfo = new DirectoryInfo(path); if (!directoryInfo.Exists) { directoryInfo.Create(); } }); } catch { return false; } return true; } /// /// Gets the full transformed cached path for the image. /// The file names are stored as MD5 encrypted versions of the full request path. /// This should make them unique enough to /// /// The original image path. /// The original image name. /// The full cached path for the image. internal static string GetCachePath(string imagePath, string imageName) { 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 = ParseExtension(imagePath); string fallbackExtension = imageName.Substring(imageName.LastIndexOf(".", StringComparison.Ordinal) + 1); string encryptedName = imagePath.ToMD5Fingerprint(); string subpath = encryptedName.Substring(0, 1); string cachedFileName = string.Format( "{0}.{1}", encryptedName, !string.IsNullOrWhiteSpace(parsedExtension) ? parsedExtension : fallbackExtension); cachedPath = Path.Combine(AbsoluteCachePath, subpath, cachedFileName); } return cachedPath; } /// /// Adds an image to the cache. /// /// /// The cached path. /// /// /// The last write time. /// internal static void AddImageToCache(string cachedPath, DateTime lastWriteTimeUtc) { string key = Path.GetFileNameWithoutExtension(cachedPath); DateTime expires = DateTime.UtcNow.AddDays(MaxFileCachedDuration).ToUniversalTime(); CachedImage cachedImage = new CachedImage(cachedPath, MaxFileCachedDuration, lastWriteTimeUtc, expires); PersistantDictionary.Instance.Add(key, cachedImage); } /// /// Converts an absolute file path /// /// The absolute path to convert. /// The from the current context. /// The virtual path to the file. internal static string GetVirtualPath(string absolutePath, HttpRequest request) { string applicationPath = request.PhysicalApplicationPath; string virtualDir = request.ApplicationPath; virtualDir = virtualDir == "/" ? virtualDir : (virtualDir + "/"); if (applicationPath != null) { return absolutePath.Replace(applicationPath, virtualDir).Replace(@"\", "/"); } throw new InvalidOperationException("We can only map an absolute back to a relative path if the application path is available."); } /// /// Returns a value indicating whether the original file has been updated. /// /// The original image path. /// The cached image path. /// Whether the file is a remote request. /// /// True if the the original file has been updated; otherwise, false. /// internal static bool IsUpdatedFile(string imagePath, string cachedImagePath, bool isRemote) { string key = Path.GetFileNameWithoutExtension(cachedImagePath); CachedImage cachedImage; if (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 (PersistantDictionary.Instance.TryRemove(key, out cachedImage)) { // We can jump out here. return true; } } return false; } // Nothing in the cache so we should return true. return true; } // Test now for locally requested files. if (PersistantDictionary.Instance.TryGetValue(key, out cachedImage)) { FileInfo imageFileInfo = new FileInfo(imagePath); 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 (PersistantDictionary.Instance.TryRemove(key, out cachedImage)) { return true; } } } } else { // Nothing in the cache so we should return true. return true; } return false; } /// /// Sets the LastWriteTime of the cached file to match the original file. /// /// /// The original image path. /// /// /// The cached image path. /// /// Whether the file is remote. /// /// The set to the last write time of the file. /// internal static DateTime SetCachedLastWriteTime(string imagePath, string cachedImagePath, bool isRemote) { FileInfo cachedFileInfo = new FileInfo(cachedImagePath); if (isRemote) { if (cachedFileInfo.Exists) { return cachedFileInfo.LastWriteTimeUtc; } } FileInfo imageFileInfo = new FileInfo(imagePath); if (imageFileInfo.Exists && cachedFileInfo.Exists) { DateTime dateTime = imageFileInfo.LastWriteTimeUtc; cachedFileInfo.LastWriteTimeUtc = dateTime; return dateTime; } return DateTime.MinValue.ToUniversalTime(); } /// /// Purges any files from the file-system cache in the given folders. /// internal static 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); CachedImage cachedImage; if (PersistantDictionary.Instance.TryRemove(key, out cachedImage)) { fileInfo.Delete(); groupCount -= 1; } } catch (Exception) { // Do Nothing, skip to the next. // TODO: Should we handle this? continue; } } } } /// /// 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 static string ParseExtension(string input) { Match match = FormatRegex.Match(input); return match.Success ? match.Value : string.Empty; } #endregion } }