diff --git a/src/ImageProcessor.Web/Caching/Cacher.cs b/src/ImageProcessor.Web/Caching/Cacher.cs new file mode 100644 index 000000000..a4a6f67af --- /dev/null +++ b/src/ImageProcessor.Web/Caching/Cacher.cs @@ -0,0 +1,389 @@ + + +namespace ImageProcessor.Web.Caching +{ + #region Using + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using System.Text.RegularExpressions; + using System.Threading.Tasks; + using System.Web; + using System.Web.Hosting; + using ImageProcessor.Helpers.Extensions; + using ImageProcessor.Web.Config; + #endregion + + /// + /// The cache. + /// + internal sealed class Cache + { + #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); + + /// + /// The concurrent dictionary. + /// + private ConcurrentDictionary concurrentDictionary = + new ConcurrentDictionary(); + + #endregion + + #region Methods + + public void Test() + { + Task task = this.CreateDirectoriesAsync(); + if (task.Result) + { + + } + + } + + #region Internal + /// + /// Converts an absolute file path + /// + /// The absolute path to convert. + /// The from the current context. + /// The virtual path to the file. + internal 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."); + } + + /// + /// 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 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 = this.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; + } + + /// + /// Creates the cache directories for storing images. + /// + /// + /// The true if the cache directories are created successfully; otherwise, false. + /// + internal /*async*/ Task CreateDirectoriesAsync() + { + return this.CreateDirectoriesAsyncTasks().ToTask(); + } + + /// + /// Adds an image to the cache. + /// + /// + /// The cached path. + /// + /// + /// The last write time. + /// + /// + /// The task. + /// + internal Task /*async*/ AddImageToCacheAsync(string cachedPath, DateTime lastWriteTimeUtc) + { + return this.AddImageToCacheAsyncTask(cachedPath, lastWriteTimeUtc).ToTask(); + } + + /// + /// 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 /*async*/ Task IsUpdatedFileAsync(string imagePath, string cachedImagePath, bool isRemote) + { + return this.IsUpdatedFileAsyncTask(imagePath, cachedImagePath, isRemote).ToTask(); + } + + /// + /// 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 /*async*/ Task SetCachedLastWriteTimeAsync(string imagePath, string cachedImagePath, bool isRemote) + { + return this.SetCachedLastWriteTimeAsyncTask(imagePath, cachedImagePath, isRemote).ToTask(); + } + + #endregion + + #region Private + /// + /// The create directories async tasks. + /// + /// + /// The . + /// + private IEnumerable CreateDirectoriesAsyncTasks() + { + bool success = true; + + 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 + { + success = false; + } + + yield return TaskEx.FromResult(success); + } + + /// + /// Adds an image to the cache. + /// + /// + /// The cached path. + /// + /// + /// The last write time. + /// + /// + /// The . + /// + private IEnumerable AddImageToCacheAsyncTask(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); + + yield break; + } + + /// + /// 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. + /// + /// The . + /// + private IEnumerable IsUpdatedFileAsyncTask(string imagePath, string cachedImagePath, bool isRemote) + { + string key = Path.GetFileNameWithoutExtension(cachedImagePath); + CachedImage cachedImage; + bool isUpdated = false; + + 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)) + { + isUpdated = true; + } + } + } + else + { + // Nothing in the cache so we should return true. + isUpdated = 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)) + { + isUpdated = true; + } + } + } + } + else + { + // Nothing in the cache so we should return true. + isUpdated = true; + } + + yield return TaskEx.FromResult(isUpdated); + } + + /// + /// 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 . + /// + private IEnumerable SetCachedLastWriteTimeAsyncTask(string imagePath, string cachedImagePath, bool isRemote) + { + FileInfo cachedFileInfo = new FileInfo(cachedImagePath); + DateTime lastWriteTime = DateTime.MinValue.ToUniversalTime(); + + if (isRemote) + { + if (cachedFileInfo.Exists) + { + lastWriteTime = cachedFileInfo.LastWriteTimeUtc; + } + } + else + { + FileInfo imageFileInfo = new FileInfo(imagePath); + + if (imageFileInfo.Exists && cachedFileInfo.Exists) + { + DateTime dateTime = imageFileInfo.LastWriteTimeUtc; + cachedFileInfo.LastWriteTimeUtc = dateTime; + + lastWriteTime = dateTime; + } + } + + yield return TaskEx.FromResult(lastWriteTime); + } + + /// + /// 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 + } +} diff --git a/src/ImageProcessor.Web/Caching/Copy of DiskCache.cs b/src/ImageProcessor.Web/Caching/Copy of DiskCache.cs new file mode 100644 index 000000000..84fc204e3 --- /dev/null +++ b/src/ImageProcessor.Web/Caching/Copy of DiskCache.cs @@ -0,0 +1,346 @@ +// ----------------------------------------------------------------------- +// +// 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 + } +} diff --git a/src/ImageProcessor.Web/Helpers/TaskEx.cs b/src/ImageProcessor.Web/Helpers/TaskEx.cs new file mode 100644 index 000000000..6d85c45c1 --- /dev/null +++ b/src/ImageProcessor.Web/Helpers/TaskEx.cs @@ -0,0 +1,174 @@ +// License: CPOL at http://www.codeproject.com/info/cpol10.aspx + + +namespace System.Threading.Tasks +{ + using System.Collections.Generic; + + /// + /// Extensions related to the classes. + /// Supports implementing "async"-style methods in C#4 using iterators. + /// + /// + /// I would call this TaskExtensions, except that clients must name the class to use methods like . + /// Based on work from Await Tasks in C#4 using Iterators by Keith L Robertson. + /// + /// + public static class TaskEx + { + /// + /// Return a Completed with a specific value. + /// + /// + /// The result + /// + /// + /// The result Value. + /// + /// + /// The . + /// + public static Task FromResult(TResult resultValue) + { + var completionSource = new TaskCompletionSource(); + completionSource.SetResult(resultValue); + return completionSource.Task; + } + + /// + /// Transform an enumeration of into a single non-Result . + /// + /// + /// The tasks. + /// + /// + /// The . + /// + public static Task ToTask(this IEnumerable tasks) + { + return ToTask(tasks); + } + + /// + /// Transform an enumeration of into a single . + /// The final in must be a . + /// + /// + /// The task results + /// + /// + /// The tasks. + /// + /// + /// The . + /// + public static Task ToTask(this IEnumerable tasks) + { + var taskScheduler = + SynchronizationContext.Current == null + ? TaskScheduler.Default : TaskScheduler.FromCurrentSynchronizationContext(); + var taskEnumerator = tasks.GetEnumerator(); + var completionSource = new TaskCompletionSource(); + + // Clean up the enumerator when the task completes. + completionSource.Task.ContinueWith(t => taskEnumerator.Dispose(), taskScheduler); + + ToTaskDoOneStep(taskEnumerator, taskScheduler, completionSource, null); + return completionSource.Task; + } + + /// + /// If the previous task Canceled or Faulted, complete the master task with the same . + /// Obtain the next from the . + /// If none, complete the master task, possibly with the of the last task. + /// Otherwise, set up the task with a continuation to come do this again when it completes. + /// + private static void ToTaskDoOneStep( + IEnumerator taskEnumerator, TaskScheduler taskScheduler, + TaskCompletionSource completionSource, Task completedTask) + { + // Check status of previous nested task (if any), and stop if Canceled or Faulted. + TaskStatus status; + if (completedTask == null) + { + // This is the first task from the iterator; skip status check. + } + else if ((status = completedTask.Status) == TaskStatus.Canceled) + { + completionSource.SetCanceled(); + return; + } + else if (status == TaskStatus.Faulted) + { + completionSource.SetException(completedTask.Exception); + return; + } + + // Check for cancellation before looking for the next task. + // This causes a problem where the ultimate Task does not complete and fire any continuations; I don't know why. + // So cancellation from the Token must be handled within the iterator itself. + //if (cancellationToken.IsCancellationRequested) { + // completionSource.SetCanceled(); + // return; + //} + + // Find the next Task in the iterator; handle cancellation and other exceptions. + Boolean haveMore; + try + { + haveMore = taskEnumerator.MoveNext(); + + } + catch (OperationCanceledException cancExc) + { + //if (cancExc.CancellationToken == cancellationToken) completionSource.SetCanceled(); + //else completionSource.SetException(cancExc); + completionSource.SetCanceled(); + return; + } + catch (Exception exc) + { + completionSource.SetException(exc); + return; + } + + if (!haveMore) + { + // No more tasks; set the result from the last completed task (if any, unless no result is requested). + // We know it's not Canceled or Faulted because we checked at the start of this method. + if (typeof(TResult) == typeof(VoidResult)) + { // No result + completionSource.SetResult(default(TResult)); + } + else if (!(completedTask is Task)) + { // Wrong result + completionSource.SetException(new InvalidOperationException( + "Asynchronous iterator " + taskEnumerator + + " requires a final result task of type " + typeof(Task).FullName + + (completedTask == null ? ", but none was provided." : + "; the actual task type was " + completedTask.GetType().FullName))); + } + else + { + completionSource.SetResult(((Task)completedTask).Result); + } + } + else + { + // When the nested task completes, continue by performing this function again. + // Note: This is NOT a recursive call; the current method activation will complete + // almost immediately and independently of the lambda continuation. + taskEnumerator.Current.ContinueWith( + nextTask => ToTaskDoOneStep(taskEnumerator, taskScheduler, completionSource, nextTask), + taskScheduler); + } + } + + /// + /// Internal marker type for using to implement . + /// + private abstract class VoidResult + { + } + } +} diff --git a/src/ImageProcessor.Web/ImageProcessor.Web.csproj b/src/ImageProcessor.Web/ImageProcessor.Web.csproj index 0ab6f04c4..42983cf84 100644 --- a/src/ImageProcessor.Web/ImageProcessor.Web.csproj +++ b/src/ImageProcessor.Web/ImageProcessor.Web.csproj @@ -87,7 +87,9 @@ + +