Browse Source

More refactoring.

Former-commit-id: ee57be6d460b0e9bbf4af2b6a205b398ce7ce92d
pull/17/head
James South 13 years ago
parent
commit
a0eeec81a7
  1. 236
      src/ImageProcessor.Web/Caching/Cache.cs
  2. 64
      src/ImageProcessor.Web/Helpers/AsyncIoExtensions.cs
  3. 347
      src/ImageProcessor.Web/Helpers/Copy of RemoteFile.cs
  4. 86
      src/ImageProcessor.Web/Helpers/RemoteFile.cs
  5. 342
      src/ImageProcessor.Web/HttpModules/Copy of ImageProcessingModule.cs
  6. 175
      src/ImageProcessor.Web/HttpModules/ImageProcessingModule.cs
  7. 3
      src/ImageProcessor.Web/ImageProcessor.Web.csproj

236
src/ImageProcessor.Web/Caching/Cacher.cs → src/ImageProcessor.Web/Caching/Cache.cs

@ -1,4 +1,9 @@

// -----------------------------------------------------------------------
// <copyright file="Cache.cs" company="James South">
// Copyright (c) James South.
// Dual licensed under the MIT or GPL Version 2 licenses.
// </copyright>
// -----------------------------------------------------------------------
namespace ImageProcessor.Web.Caching
{
@ -8,6 +13,7 @@ namespace ImageProcessor.Web.Caching
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
@ -22,7 +28,6 @@ namespace ImageProcessor.Web.Caching
internal sealed class Cache
{
#region Fields
/// <summary>
/// The maximum number of days to cache files on the system for.
/// </summary>
@ -71,23 +76,43 @@ namespace ImageProcessor.Web.Caching
/// <summary>
/// The concurrent dictionary.
/// </summary>
private ConcurrentDictionary<string, CachedImage> concurrentDictionary =
private static ConcurrentDictionary<string, CachedImage> concurrentDictionary =
new ConcurrentDictionary<string, CachedImage>();
#endregion
#region Methods
/// <summary>
/// The request path for the image.
/// </summary>
private string requestPath;
public void Test()
{
Task<bool> task = this.CreateDirectoriesAsync();
if (task.Result)
{
/// <summary>
/// The image name
/// </summary>
private string imageName;
}
/// <summary>
/// Whether the request is for a remote image.
/// </summary>
private bool isRemote;
#endregion
#region Constructors
public Cache(string requestPath, string fullPath, string imageName, bool isRemote)
{
this.requestPath = requestPath;
this.imageName = imageName;
this.isRemote = isRemote;
this.CachedPath = this.GetCachePath(fullPath, imageName);
}
#endregion
#region Properties
/// <summary>
/// Gets the cached path.
/// </summary>
internal string CachedPath { get; private set; }
#endregion
#region Methods
#region Internal
/// <summary>
/// Converts an absolute file path
@ -110,48 +135,15 @@ namespace ImageProcessor.Web.Caching
"We can only map an absolute back to a relative path if the application path is available.");
}
/// <summary>
/// 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
/// </summary>
/// <param name="imagePath">The original image path.</param>
/// <param name="imageName">The original image name.</param>
/// <returns>The full cached path for the image.</returns>
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;
}
/// <summary>
/// Creates the cache directories for storing images.
/// </summary>
/// <returns>
/// The true if the cache directories are created successfully; otherwise, false.
/// </returns>
internal /*async*/ Task<bool> CreateDirectoriesAsync()
internal static /*async*/ Task<bool> CreateDirectoriesAsync()
{
return this.CreateDirectoriesAsyncTasks().ToTask<bool>();
return CreateDirectoriesAsyncTasks().ToTask<bool>();
}
/// <summary>
@ -166,43 +158,39 @@ namespace ImageProcessor.Web.Caching
/// <returns>
/// The task.
/// </returns>
internal Task /*async*/ AddImageToCacheAsync(string cachedPath, DateTime lastWriteTimeUtc)
internal Task /*async*/ AddImageToCacheAsync(DateTime lastWriteTimeUtc)
{
return this.AddImageToCacheAsyncTask(cachedPath, lastWriteTimeUtc).ToTask();
return this.AddImageToCacheAsyncTask(lastWriteTimeUtc).ToTask();
}
/// <summary>
/// Returns a value indicating whether the original file has been updated.
/// Returns a value indicating whether the original file is new or has been updated.
/// </summary>
/// <param name="imagePath">The original image path.</param>
/// <param name="cachedImagePath">The cached image path.</param>
/// <param name="isRemote">Whether the file is a remote request.</param>
/// <returns>
/// True if the the original file has been updated; otherwise, false.
/// True if the the original file is new or has been updated; otherwise, false.
/// </returns>
internal /*async*/ Task<bool> IsUpdatedFileAsync(string imagePath, string cachedImagePath, bool isRemote)
internal /*async*/ Task<bool> isNewOrUpdatedFileAsync()
{
return this.IsUpdatedFileAsyncTask(imagePath, cachedImagePath, isRemote).ToTask<bool>();
return this.isNewOrUpdatedFileAsyncTask().ToTask<bool>();
}
/// <summary>
/// Sets the LastWriteTime of the cached file to match the original file.
/// </summary>
/// <param name="imagePath">
/// The original image path.
/// </param>
/// <param name="cachedImagePath">
/// The cached image path.
/// </param>
/// <param name="isRemote">Whether the file is remote.</param>
/// <returns>
/// The <see cref="System.DateTime"/> set to the last write time of the file.
/// </returns>
internal /*async*/ Task<DateTime> SetCachedLastWriteTimeAsync(string imagePath, string cachedImagePath, bool isRemote)
internal /*async*/ Task<DateTime> SetCachedLastWriteTimeAsync()
{
return this.SetCachedLastWriteTimeAsyncTask(imagePath, cachedImagePath, isRemote).ToTask<DateTime>();
return this.SetCachedLastWriteTimeAsyncTask().ToTask<DateTime>();
}
/// <summary>
/// Purges any files from the file-system cache in the given folders.
/// </summary>
internal /*async*/ Task TrimCachedFoldersAsync()
{
return this.TrimCachedFoldersAsyncTask().ToTask();
}
#endregion
#region Private
@ -212,7 +200,7 @@ namespace ImageProcessor.Web.Caching
/// <returns>
/// The <see cref="IEnumerable{Task}"/>.
/// </returns>
private IEnumerable<Task> CreateDirectoriesAsyncTasks()
private static IEnumerable<Task> CreateDirectoriesAsyncTasks()
{
bool success = true;
@ -242,41 +230,35 @@ namespace ImageProcessor.Web.Caching
/// <summary>
/// Adds an image to the cache.
/// </summary>
/// <param name="cachedPath">
/// The cached path.
/// </param>
/// <param name="lastWriteTimeUtc">
/// The last write time.
/// </param>
/// <returns>
/// The <see cref="IEnumerable{Task}"/>.
/// </returns>
private IEnumerable<Task> AddImageToCacheAsyncTask(string cachedPath, DateTime lastWriteTimeUtc)
private IEnumerable<Task> AddImageToCacheAsyncTask(DateTime lastWriteTimeUtc)
{
string key = Path.GetFileNameWithoutExtension(cachedPath);
string key = Path.GetFileNameWithoutExtension(this.CachedPath);
DateTime expires = DateTime.UtcNow.AddDays(MaxFileCachedDuration).ToUniversalTime();
CachedImage cachedImage = new CachedImage(cachedPath, MaxFileCachedDuration, lastWriteTimeUtc, expires);
CachedImage cachedImage = new CachedImage(this.CachedPath, MaxFileCachedDuration, lastWriteTimeUtc, expires);
PersistantDictionary.Instance.Add(key, cachedImage);
yield break;
}
/// <summary>
/// Returns a value indicating whether the original file has been updated.
/// Returns a value indicating whether the original file is new or has been updated.
/// </summary>
/// <param name="imagePath">The original image path.</param>
/// <param name="cachedImagePath">The cached image path.</param>
/// <param name="isRemote">Whether the file is a remote request.</param>
/// <returns>
/// The <see cref="IEnumerable{Task}"/>.
/// </returns>
private IEnumerable<Task> IsUpdatedFileAsyncTask(string imagePath, string cachedImagePath, bool isRemote)
private IEnumerable<Task> isNewOrUpdatedFileAsyncTask()
{
string key = Path.GetFileNameWithoutExtension(cachedImagePath);
string key = Path.GetFileNameWithoutExtension(this.CachedPath);
CachedImage cachedImage;
bool isUpdated = false;
if (isRemote)
if (this.isRemote)
{
if (PersistantDictionary.Instance.TryGetValue(key, out cachedImage))
{
@ -301,7 +283,7 @@ namespace ImageProcessor.Web.Caching
// Test now for locally requested files.
if (PersistantDictionary.Instance.TryGetValue(key, out cachedImage))
{
FileInfo imageFileInfo = new FileInfo(imagePath);
FileInfo imageFileInfo = new FileInfo(this.requestPath);
if (imageFileInfo.Exists)
{
@ -340,9 +322,9 @@ namespace ImageProcessor.Web.Caching
/// <returns>
/// The <see cref="IEnumerable{Task}"/>.
/// </returns>
private IEnumerable<Task> SetCachedLastWriteTimeAsyncTask(string imagePath, string cachedImagePath, bool isRemote)
private IEnumerable<Task> SetCachedLastWriteTimeAsyncTask()
{
FileInfo cachedFileInfo = new FileInfo(cachedImagePath);
FileInfo cachedFileInfo = new FileInfo(this.CachedPath);
DateTime lastWriteTime = DateTime.MinValue.ToUniversalTime();
if (isRemote)
@ -354,7 +336,7 @@ namespace ImageProcessor.Web.Caching
}
else
{
FileInfo imageFileInfo = new FileInfo(imagePath);
FileInfo imageFileInfo = new FileInfo(this.requestPath);
if (imageFileInfo.Exists && cachedFileInfo.Exists)
{
@ -368,6 +350,92 @@ namespace ImageProcessor.Web.Caching
yield return TaskEx.FromResult(lastWriteTime);
}
/// <summary>
/// Purges any files from the file-system cache in the given folders.
/// </summary>
/// <returns>
/// The <see cref="IEnumerable{Task}"/>.
/// </returns>
private IEnumerable<Task> TrimCachedFoldersAsyncTask()
{
// 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<string, CachedImage> 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;
}
}
}
yield break;
}
/// <summary>
/// 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
/// </summary>
/// <param name="fullPath">The original image path.</param>
/// <param name="imageName">The original image name.</param>
/// <returns>The full cached path for the image.</returns>
private string GetCachePath(string fullPath, 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(fullPath);
string fallbackExtension = imageName.Substring(imageName.LastIndexOf(".", StringComparison.Ordinal) + 1);
string encryptedName = fullPath.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;
}
/// <summary>
/// Returns the correct file extension for the given string input
/// </summary>

64
src/ImageProcessor.Web/Helpers/AsyncIoExtensions.cs

@ -0,0 +1,64 @@
// License: CPOL at http://www.codeproject.com/info/cpol10.aspx
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
namespace System.IO
{
public static class AsyncIoExtensions
{
public static Task<Stream> GetRequestStreamAsync(this WebRequest webRequest)
{
return Task.Factory.FromAsync(
webRequest.BeginGetRequestStream,
ar => webRequest.EndGetRequestStream(ar),
null);
}
public static Task<WebResponse> GetResponseAsync(this WebRequest webRequest)
{
return Task.Factory.FromAsync(
webRequest.BeginGetResponse,
ar => webRequest.EndGetResponse(ar),
null);
}
public static Task<int> ReadAsync(this Stream input, Byte[] buffer, int offset, int count)
{
return Task.Factory.FromAsync(
input.BeginRead,
(Func<IAsyncResult, int>)input.EndRead,
buffer, offset, count,
null);
}
public static Task WriteAsync(this Stream input, Byte[] buffer, int offset, int count)
{
return Task.Factory.FromAsync(
input.BeginWrite,
input.EndWrite,
buffer, offset, count,
null);
}
public static /*async*/ Task CopyToAsync(this Stream input, Stream output, CancellationToken cancellationToken = default(CancellationToken))
{
return CopyToAsyncTasks(input, output, cancellationToken).ToTask();
}
private static IEnumerable<Task> CopyToAsyncTasks(Stream input, Stream output, CancellationToken cancellationToken)
{
byte[] buffer = new byte[0x1000]; // 4 KiB
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var readTask = input.ReadAsync(buffer, 0, buffer.Length);
yield return readTask;
if (readTask.Result == 0) break;
cancellationToken.ThrowIfCancellationRequested();
yield return output.WriteAsync(buffer, 0, readTask.Result);
}
}
}
}

347
src/ImageProcessor.Web/Helpers/Copy of RemoteFile.cs

@ -0,0 +1,347 @@
// -----------------------------------------------------------------------
// <copyright file="RemoteFile.cs" company="James South">
// Copyright (c) James South.
// Dual licensed under the MIT or GPL Version 2 licenses.
// </copyright>
// -----------------------------------------------------------------------
namespace ImageProcessor.Web.Helpers
{
#region Using
using System;
using System.Diagnostics.Contracts;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Security;
using System.Text;
using ImageProcessor.Web.Config;
#endregion
/// <summary>
/// Encapsulates methods used to download files from a website address.
/// </summary>
/// <remarks>
/// <para>
/// The purpose of this class is so there's one core way of downloading remote files with url[s] that are from
/// outside users. There's various areas in application where an attacker could supply an external url to the server
/// and tie up resources.
/// </para>
/// For example, the ImageProcessingModule accepts off-server addresses as a path. An attacker could, for instance, pass the url
/// to a file that's a few gigs in size, causing the server to get out-of-memory exceptions or some other errors. An attacker
/// could also use this same method to use one application instance to hammer another site by, again, passing an off-server
/// address of the victims site to the ImageProcessingModule.
/// This class will not throw an exception if the Uri supplied points to a resource local to the running application instance.
/// <para>
/// There shouldn't be any security issues there, as the internal WebRequest instance is still calling it remotely.
/// Any local files that shouldn't be accessed by this won't be allowed by the remote call.
/// </para>
/// Adapted from <see cref="http://blogengine.codeplex.com">BlogEngine.Net</see>
/// </remarks>
internal sealed class RemoteFile
{
#region Fields
/// <summary>
/// The white-list of url[s] from which to download remote files.
/// </summary>
private static readonly Uri[] RemoteFileWhiteList = ImageProcessorConfig.Instance.RemoteFileWhiteList;
/// <summary>
/// The length of time, in milliseconds, that a remote file download attempt can last before timing out.
/// </summary>
private static readonly int TimeoutMilliseconds = ImageProcessorConfig.Instance.Timeout;
/// <summary>
/// The maximum size, in bytes, that a remote file download attempt can download.
/// </summary>
private static readonly int MaxBytes = ImageProcessorConfig.Instance.MaxBytes;
/// <summary>
/// Whether to allow remote downloads.
/// </summary>
private static readonly bool AllowRemoteDownloads = ImageProcessorConfig.Instance.AllowRemoteDownloads;
/// <summary>
/// Whether this RemoteFile instance is ignoring remote download rules set in the current application
/// instance.
/// </summary>
private readonly bool ignoreRemoteDownloadSettings;
/// <summary>
/// The <see cref="T:System.Uri">Uri</see> of the remote file being downloaded.
/// </summary>
private readonly Uri url;
/// <summary>
/// The maximum allowable download size in bytes.
/// </summary>
private readonly int maxDownloadSize;
/// <summary>
/// The length of time, in milliseconds, that a remote file download attempt can last before timing out.
/// </summary>
private int timeoutLength;
/// <summary>
/// The <see cref="T:System.Net.WebResponse">WebResponse</see> object used internally for this RemoteFile instance.
/// </summary>
private WebRequest webRequest;
#endregion
#region Constructors
/// <summary>
/// Initializes a new instance of the <see cref="T:ImageProcessor.Web.Helpers.RemoteFile">RemoteFile</see> class.
/// </summary>
/// <param name="filePath">The url of the file to be downloaded.</param>
/// <param name="ignoreRemoteDownloadSettings">
/// If set to <see langword="true"/>, then RemoteFile should ignore the current the applications instance's remote download settings; otherwise,<see langword="false"/>.
/// </param>
internal RemoteFile(Uri filePath, bool ignoreRemoteDownloadSettings)
{
Contract.Requires(filePath != null);
this.url = filePath;
this.ignoreRemoteDownloadSettings = ignoreRemoteDownloadSettings;
this.timeoutLength = TimeoutMilliseconds;
this.maxDownloadSize = MaxBytes;
}
#endregion
#region Properties
/// <summary>
/// Gets a value indicating whether this RemoteFile instance is ignoring remote download rules set in the
/// current application instance.
/// <remarks>
/// This should only be set to true if the supplied url is a verified resource. Use at your own risk.
/// </remarks>
/// </summary>
/// <value>
/// <see langword="true"/> if this RemoteFile instance is ignoring remote download rules set in the current
/// application instance; otherwise, <see langword="false"/>.
/// </value>
public bool IgnoreRemoteDownloadSettings
{
get
{
return this.ignoreRemoteDownloadSettings;
}
}
/// <summary>
/// Gets the Uri of the remote file being downloaded.
/// </summary>
public Uri Uri
{
get
{
return this.url;
}
}
/// <summary>
/// Gets or sets the length of time, in milliseconds, that a remote file download attempt can
/// last before timing out.
/// <remarks>
/// <para>
/// This value can only be set if the instance is supposed to ignore the remote download settings set
/// in the current application instance.
/// </para>
/// <para>
/// Set this value to 0 if there should be no timeout.
/// </para>
/// </remarks>
/// </summary>
public int TimeoutLength
{
get
{
return this.IgnoreRemoteDownloadSettings ? this.timeoutLength : TimeoutMilliseconds;
}
set
{
if (!this.IgnoreRemoteDownloadSettings)
{
throw new SecurityException("Timeout length can not be adjusted on remote files that are abiding by remote download rules");
}
if (value < 0)
{
throw new ArgumentOutOfRangeException("TimeoutLength");
}
this.timeoutLength = value;
}
}
/// <summary>
/// Gets or sets the maximum download size, in bytes, that a remote file download attempt can be.
/// <remarks>
/// <para>
/// This value can only be set if the instance is supposed to ignore the remote download settings set
/// in the current application instance.
/// </para>
/// <para>
/// Set this value to 0 if there should be no timeout.
/// </para>
/// </remarks>
/// </summary>
public int MaxDownloadSize
{
get
{
return this.IgnoreRemoteDownloadSettings ? this.maxDownloadSize : MaxBytes;
}
set
{
if (!this.IgnoreRemoteDownloadSettings)
{
throw new SecurityException("Max Download Size can not be adjusted on remote files that are abiding by remote download rules");
}
if (value < 0)
{
throw new ArgumentOutOfRangeException("MaxDownloadSize");
}
this.timeoutLength = value;
}
}
#endregion
#region Methods
#region Public
/// <summary>
/// Returns the <see cref="T:System.Net.WebResponse">WebResponse</see> used to download this file.
/// <remarks>
/// <para>
/// This method is meant for outside users who need specific access to the WebResponse this class
/// generates. They're responsible for disposing of it.
/// </para>
/// </remarks>
/// </summary>
/// <returns>The <see cref="T:System.Net.WebResponse">WebResponse</see> used to download this file.</returns>
public WebResponse GetWebResponse()
{
WebResponse response = this.GetWebRequest().GetResponse();
long contentLength = response.ContentLength;
// WebResponse.ContentLength doesn't always know the value, it returns -1 in this case.
if (contentLength == -1)
{
// Response headers may still have the Content-Length inside of it.
string headerContentLength = response.Headers["Content-Length"];
if (!string.IsNullOrWhiteSpace(headerContentLength))
{
contentLength = long.Parse(headerContentLength, CultureInfo.InvariantCulture);
}
}
// We don't need to check the url here since any external urls are available only from the web.config.
if ((this.MaxDownloadSize > 0) && (contentLength > this.MaxDownloadSize))
{
response.Close();
throw new SecurityException("An attempt to download a remote file has been halted because the file is larger than allowed.");
}
return response;
}
/// <summary>
/// Returns the remote file as a String.
/// <remarks>
/// This returns the resulting stream as a string as passed through a StreamReader.
/// </remarks>
/// </summary>
/// <returns>The remote file as a String.</returns>
public string GetFileAsString()
{
using (WebResponse response = this.GetWebResponse())
{
Stream responseStream = response.GetResponseStream();
if (responseStream != null)
{
// Pipe the stream to a stream reader with the required encoding format.
using (StreamReader reader = new StreamReader(responseStream, Encoding.UTF8))
{
return reader.ReadToEnd();
}
}
return string.Empty;
}
}
#endregion
#region Private
/// <summary>
/// Performs a check to see whether the application is able to download remote files.
/// </summary>
private void CheckCanDownload()
{
if (!this.IgnoreRemoteDownloadSettings && !AllowRemoteDownloads)
{
throw new SecurityException("application is not configured to allow remote file downloads.");
}
}
/// <summary>
/// Creates the WebRequest object used internally for this RemoteFile instance.
/// </summary>
/// <returns>
/// <para>
/// The WebRequest should not be passed outside of this instance, as it will allow tampering. Anyone
/// that needs more fine control over the downloading process should probably be using the WebRequest
/// class on its own.
/// </para>
/// </returns>
private WebRequest GetWebRequest()
{
// Check downloads are allowed.
this.CheckCanDownload();
// Check the url is from a whitelisted location.
this.CheckSafeUrlLocation();
if (this.webRequest == null)
{
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(this.Uri);
request.Headers["Accept-Encoding"] = "gzip";
request.Headers["Accept-Language"] = "en-us";
request.Credentials = CredentialCache.DefaultNetworkCredentials;
request.AutomaticDecompression = DecompressionMethods.GZip;
if (this.TimeoutLength > 0)
{
request.Timeout = this.TimeoutLength;
}
this.webRequest = request;
}
return this.webRequest;
}
/// <summary>
/// Returns a value indicating whether the current url is in a list of safe download locations.
/// </summary>
private void CheckSafeUrlLocation()
{
bool validUrl = RemoteFileWhiteList.Any(item => item.Host.ToUpperInvariant().Equals(this.url.Host.ToUpperInvariant()));
if (!validUrl)
{
throw new SecurityException("application is not configured to allow remote file downloads from this domain.");
}
}
#endregion
#endregion
}
}

86
src/ImageProcessor.Web/Helpers/RemoteFile.cs

@ -17,6 +17,8 @@ namespace ImageProcessor.Web.Helpers
using System.Security;
using System.Text;
using ImageProcessor.Web.Config;
using System.Threading.Tasks;
using System.Collections.Generic;
#endregion
/// <summary>
@ -99,7 +101,10 @@ namespace ImageProcessor.Web.Helpers
/// </param>
internal RemoteFile(Uri filePath, bool ignoreRemoteDownloadSettings)
{
Contract.Requires(filePath != null);
if (filePath == null)
{
throw new ArgumentNullException("filePath");
}
this.url = filePath;
this.ignoreRemoteDownloadSettings = ignoreRemoteDownloadSettings;
@ -212,7 +217,7 @@ namespace ImageProcessor.Web.Helpers
#endregion
#region Methods
#region Public
#region Internal
/// <summary>
/// Returns the <see cref="T:System.Net.WebResponse">WebResponse</see> used to download this file.
/// <remarks>
@ -223,32 +228,12 @@ namespace ImageProcessor.Web.Helpers
/// </remarks>
/// </summary>
/// <returns>The <see cref="T:System.Net.WebResponse">WebResponse</see> used to download this file.</returns>
public WebResponse GetWebResponse()
/// <returns>
/// The <see cref="IEnumerable{Task}"/>.
/// </returns>
internal /*async*/ Task<WebResponse> GetWebResponseAsync()
{
WebResponse response = this.GetWebRequest().GetResponse();
long contentLength = response.ContentLength;
// WebResponse.ContentLength doesn't always know the value, it returns -1 in this case.
if (contentLength == -1)
{
// Response headers may still have the Content-Length inside of it.
string headerContentLength = response.Headers["Content-Length"];
if (!string.IsNullOrWhiteSpace(headerContentLength))
{
contentLength = long.Parse(headerContentLength, CultureInfo.InvariantCulture);
}
}
// We don't need to check the url here since any external urls are available only from the web.config.
if ((this.MaxDownloadSize > 0) && (contentLength > this.MaxDownloadSize))
{
response.Close();
throw new SecurityException("An attempt to download a remote file has been halted because the file is larger than allowed.");
}
return response;
return this.GetWebResponseAsyncTask().ToTask<WebResponse>();
}
/// <summary>
@ -260,7 +245,9 @@ namespace ImageProcessor.Web.Helpers
/// <returns>The remote file as a String.</returns>
public string GetFileAsString()
{
using (WebResponse response = this.GetWebResponse())
Task<WebResponse> responseTask = this.GetWebResponseAsync();
using (WebResponse response = responseTask.Result)
{
Stream responseStream = response.GetResponseStream();
@ -279,6 +266,49 @@ namespace ImageProcessor.Web.Helpers
#endregion
#region Private
/// <summary>
/// Returns the <see cref="T:System.Net.WebResponse">WebResponse</see> used to download this file.
/// <remarks>
/// <para>
/// This method is meant for outside users who need specific access to the WebResponse this class
/// generates. They're responsible for disposing of it.
/// </para>
/// </remarks>
/// </summary>
/// <returns>
/// The <see cref="IEnumerable{Task}"/>.
/// </returns>
private IEnumerable<Task> GetWebResponseAsyncTask()
{
Task<WebResponse> responseTask = this.GetWebRequest().GetResponseAsync();
yield return responseTask;
WebResponse response = responseTask.Result;
long contentLength = response.ContentLength;
// WebResponse.ContentLength doesn't always know the value, it returns -1 in this case.
if (contentLength == -1)
{
// Response headers may still have the Content-Length inside of it.
string headerContentLength = response.Headers["Content-Length"];
if (!string.IsNullOrWhiteSpace(headerContentLength))
{
contentLength = long.Parse(headerContentLength, CultureInfo.InvariantCulture);
}
}
// We don't need to check the url here since any external urls are available only from the web.config.
if ((this.MaxDownloadSize > 0) && (contentLength > this.MaxDownloadSize))
{
response.Close();
throw new SecurityException("An attempt to download a remote file has been halted because the file is larger than allowed.");
}
yield return TaskEx.FromResult(response);
}
/// <summary>
/// Performs a check to see whether the application is able to download remote files.
/// </summary>

342
src/ImageProcessor.Web/HttpModules/Copy of ImageProcessingModule.cs

@ -0,0 +1,342 @@
// -----------------------------------------------------------------------
// <copyright file="ImageProcessingModule.cs" company="James South">
// Copyright (c) James South.
// Dual licensed under the MIT or GPL Version 2 licenses.
// </copyright>
// -----------------------------------------------------------------------
namespace ImageProcessor.Web.HttpModules
{
#region Using
using System;
using System.IO;
using System.Net;
using System.Reflection;
using System.Web;
using System.Web.Hosting;
using ImageProcessor.Helpers.Extensions;
using ImageProcessor.Imaging;
using ImageProcessor.Web.Caching;
using ImageProcessor.Web.Config;
using ImageProcessor.Web.Helpers;
#endregion
/// <summary>
/// Processes any image requests within the web application.
/// </summary>
public class ImageProcessingModule : IHttpModule
{
#region Fields
/// <summary>
/// The key for storing the response type of the current image.
/// </summary>
private const string CachedResponseTypeKey = "CACHED_IMAGE_RESPONSE_TYPE";
/// <summary>
/// The value to prefix any remote image requests with to ensure they get captured.
/// </summary>
private static readonly string RemotePrefix = ImageProcessorConfig.Instance.RemotePrefix;
/// <summary>
/// The object to lock against.
/// </summary>
private static readonly object SyncRoot = new object();
/// <summary>
/// The assembly version.
/// </summary>
private static readonly string AssemblyVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString();
/// <summary>
/// A value indicating whether the application has started.
/// </summary>
private static bool hasModuleInitialized;
#endregion
/// <summary>
/// The delegate void representing the ProcessImage method.
/// </summary>
/// <param name="context">
/// the <see cref="T:System.Web.HttpContext">HttpContext</see> object that provides
/// references to the intrinsic server objects
/// </param>
private delegate void ProcessImageDelegate(HttpContext context);
#region IHttpModule Members
/// <summary>
/// Initializes a module and prepares it to handle requests.
/// </summary>
/// <param name="context">
/// An <see cref="T:System.Web.HttpApplication"/> that provides
/// access to the methods, properties, and events common to all
/// application objects within an ASP.NET application
/// </param>
public void Init(HttpApplication context)
{
if (!hasModuleInitialized)
{
lock (SyncRoot)
{
if (!hasModuleInitialized)
{
DiskCache.CreateCacheDirectories();
hasModuleInitialized = true;
}
}
}
context.AddOnBeginRequestAsync(this.OnBeginAsync, this.OnEndAsync);
context.PreSendRequestHeaders += this.ContextPreSendRequestHeaders;
}
/// <summary>
/// Disposes of the resources (other than memory) used by the module that implements <see cref="T:System.Web.IHttpModule"/>.
/// </summary>
public void Dispose()
{
// Nothing to dispose.
}
#endregion
/// <summary>
/// The <see cref="T:System.Web.BeginEventHandler"/> that starts asynchronous processing
/// of the <see cref="System.Web.HttpApplication.BeginRequest"/>.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="e">
/// An <see cref="T:System.EventArgs">EventArgs</see> that contains
/// the event data.
/// </param>
/// <param name="callBack">
/// The delegate to call when the asynchronous method call is complete.
/// If the callback is null, the delegate is not called.
/// </param>
/// <param name="state">
/// Any additional data needed to process the request.
/// </param>
/// <returns>
/// The status of the asynchronous operation.
/// </returns>
private IAsyncResult OnBeginAsync(object sender, EventArgs e, AsyncCallback callBack, object state)
{
HttpContext context = ((HttpApplication)sender).Context;
ProcessImageDelegate processImage = this.ProcessImage;
return processImage.BeginInvoke(context, callBack, state);
}
/// <summary>
/// The method that handles asynchronous events such as application events.
/// </summary>
/// <param name="result">
/// The <see cref="T:System.IAsyncResult"/> that is the result of the
/// <see cref="T:System.Web.BeginEventHandler"/> operation.
/// </param>
private void OnEndAsync(IAsyncResult result)
{
// Ensure our ProcessImage has completed in the background.
while (!result.IsCompleted)
{
System.Threading.Thread.Sleep(1);
}
}
/// <summary>
/// Occurs just before ASP.NET send HttpHeaders to the client.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="e">An <see cref="T:System.EventArgs">EventArgs</see> that contains the event data.</param>
private void ContextPreSendRequestHeaders(object sender, EventArgs e)
{
HttpContext context = ((HttpApplication)sender).Context;
object responseTypeObject = context.Items[CachedResponseTypeKey];
if (responseTypeObject != null)
{
string responseType = (string)responseTypeObject;
// Set the headers
this.SetHeaders(context, responseType);
context.Items[CachedResponseTypeKey] = null;
}
}
#region Private
/// <summary>
/// Processes the image.
/// </summary>
/// <param name="context">
/// the <see cref="T:System.Web.HttpContext">HttpContext</see> object that provides
/// references to the intrinsic server objects
/// </param>
private void ProcessImage(HttpContext context)
{
// Is this a remote file.
bool isRemote = context.Request.Path.Equals(RemotePrefix, StringComparison.OrdinalIgnoreCase);
string path = string.Empty;
string queryString = string.Empty;
if (isRemote)
{
// We need to split the querystring to get the actual values we want.
string urlDecode = HttpUtility.UrlDecode(context.Request.QueryString.ToString());
if (urlDecode != null)
{
string[] paths = urlDecode.Split('?');
path = paths[0];
if (paths.Length > 1)
{
queryString = paths[1];
}
}
}
else
{
path = HostingEnvironment.MapPath(context.Request.Path);
queryString = HttpUtility.UrlDecode(context.Request.QueryString.ToString());
}
// Only process requests that pass our sanitizing filter.
if (ImageUtils.IsValidImageExtension(path) && !string.IsNullOrWhiteSpace(queryString))
{
if (this.FileExists(path, isRemote))
{
string fullPath = string.Format("{0}?{1}", path, queryString);
string imageName = Path.GetFileName(path);
string cachedPath = DiskCache.GetCachePath(fullPath, imageName);
bool isUpdated = DiskCache.IsUpdatedFile(path, cachedPath, isRemote);
// Only process if the file has been updated.
if (isUpdated)
{
// Process the image.
using (ImageFactory imageFactory = new ImageFactory())
{
if (isRemote)
{
Uri uri = new Uri(path);
RemoteFile remoteFile = new RemoteFile(uri, false);
using (MemoryStream memoryStream = new MemoryStream())
{
using (Stream responseStream = remoteFile.GetWebResponse().GetResponseStream())
{
if (responseStream != null)
{
//lock (SyncRoot)
//{
// Trim the cache.
DiskCache.TrimCachedFolders();
responseStream.CopyTo(memoryStream);
imageFactory.Load(memoryStream)
.AddQueryString(queryString)
.Format(ImageUtils.GetImageFormat(imageName))
.AutoProcess().Save(cachedPath);
// Ensure that the LastWriteTime property of the source and cached file match.
DateTime dateTime = DiskCache.SetCachedLastWriteTime(path, cachedPath, true);
// Add to the cache.
DiskCache.AddImageToCache(cachedPath, dateTime);
//}
}
}
}
}
else
{
//lock (SyncRoot)
//{
// Trim the cache.
DiskCache.TrimCachedFolders();
imageFactory.Load(fullPath).AutoProcess().Save(cachedPath);
// Ensure that the LastWriteTime property of the source and cached file match.
DateTime dateTime = DiskCache.SetCachedLastWriteTime(path, cachedPath, false);
// Add to the cache.
DiskCache.AddImageToCache(cachedPath, dateTime);
//}
}
}
}
context.Items[CachedResponseTypeKey] = ImageUtils.GetResponseType(imageName).ToDescription();
// The cached file is valid so just rewrite the path.
context.RewritePath(DiskCache.GetVirtualPath(cachedPath, context.Request), false);
}
}
}
/// <summary>
/// returns a value indicating whether a file exists.
/// </summary>
/// <param name="path">The path to the file to check.</param>
/// <param name="isRemote">Whether the file is remote.</param>
/// <returns>True if the file exists, otherwise false.</returns>
/// <remarks>If the file is remote the method will always return true.</remarks>
private bool FileExists(string path, bool isRemote)
{
if (isRemote)
{
return true;
}
FileInfo fileInfo = new FileInfo(path);
return fileInfo.Exists;
}
/// <summary>
/// This will make the browser and server keep the output
/// in its cache and thereby improve performance.
/// See http://en.wikipedia.org/wiki/HTTP_ETag
/// </summary>
/// <param name="context">
/// the <see cref="T:System.Web.HttpContext">HttpContext</see> object that provides
/// references to the intrinsic server objects
/// </param>
/// <param name="responseType">The HTTP MIME type to to send.</param>
private void SetHeaders(HttpContext context, string responseType)
{
HttpResponse response = context.Response;
response.ContentType = responseType;
response.AddHeader("Image-Served-By", "ImageProcessor/" + AssemblyVersion);
HttpCachePolicy cache = response.Cache;
cache.VaryByHeaders["Accept-Encoding"] = true;
int maxDays = DiskCache.MaxFileCachedDuration;
cache.SetExpires(DateTime.Now.ToUniversalTime().AddDays(maxDays));
cache.SetMaxAge(new TimeSpan(maxDays, 0, 0, 0));
cache.SetRevalidation(HttpCacheRevalidation.AllCaches);
string incomingEtag = context.Request.Headers["If-None-Match"];
cache.SetCacheability(HttpCacheability.Public);
if (incomingEtag == null)
{
return;
}
response.Clear();
response.StatusCode = (int)HttpStatusCode.NotModified;
response.SuppressContent = true;
}
#endregion
}
}

175
src/ImageProcessor.Web/HttpModules/ImageProcessingModule.cs

@ -19,6 +19,8 @@ namespace ImageProcessor.Web.HttpModules
using ImageProcessor.Web.Caching;
using ImageProcessor.Web.Config;
using ImageProcessor.Web.Helpers;
using System.Threading.Tasks;
using System.Collections.Generic;
#endregion
/// <summary>
@ -53,15 +55,6 @@ namespace ImageProcessor.Web.HttpModules
private static bool hasModuleInitialized;
#endregion
/// <summary>
/// The delegate void representing the ProcessImage method.
/// </summary>
/// <param name="context">
/// the <see cref="T:System.Web.HttpContext">HttpContext</see> object that provides
/// references to the intrinsic server objects
/// </param>
private delegate void ProcessImageDelegate(HttpContext context);
#region IHttpModule Members
/// <summary>
/// Initializes a module and prepares it to handle requests.
@ -79,13 +72,14 @@ namespace ImageProcessor.Web.HttpModules
{
if (!hasModuleInitialized)
{
DiskCache.CreateCacheDirectories();
Cache.CreateDirectoriesAsync();
//DiskCache.CreateCacheDirectories();
hasModuleInitialized = true;
}
}
}
context.AddOnBeginRequestAsync(this.OnBeginAsync, this.OnEndAsync);
context.BeginRequest += this.ContextBeginRequest;
context.PreSendRequestHeaders += this.ContextPreSendRequestHeaders;
}
@ -99,47 +93,14 @@ namespace ImageProcessor.Web.HttpModules
#endregion
/// <summary>
/// The <see cref="T:System.Web.BeginEventHandler"/> that starts asynchronous processing
/// of the <see cref="System.Web.HttpApplication.BeginRequest"/>.
/// Occurs as the first event in the HTTP pipeline chain of execution when ASP.NET responds to a request.
/// </summary>
/// <param name="sender">The source of the event.</param>
/// <param name="e">
/// An <see cref="T:System.EventArgs">EventArgs</see> that contains
/// the event data.
/// </param>
/// <param name="callBack">
/// The delegate to call when the asynchronous method call is complete.
/// If the callback is null, the delegate is not called.
/// </param>
/// <param name="state">
/// Any additional data needed to process the request.
/// </param>
/// <returns>
/// The status of the asynchronous operation.
/// </returns>
private IAsyncResult OnBeginAsync(object sender, EventArgs e, AsyncCallback callBack, object state)
/// <param name="e">An <see cref="T:System.EventArgs">EventArgs</see> that contains the event data.</param>
private void ContextBeginRequest(object sender, EventArgs e)
{
HttpContext context = ((HttpApplication)sender).Context;
ProcessImageDelegate processImage = this.ProcessImage;
return processImage.BeginInvoke(context, callBack, state);
}
/// <summary>
/// The method that handles asynchronous events such as application events.
/// </summary>
/// <param name="result">
/// The <see cref="T:System.IAsyncResult"/> that is the result of the
/// <see cref="T:System.Web.BeginEventHandler"/> operation.
/// </param>
private void OnEndAsync(IAsyncResult result)
{
// Ensure our ProcessImage has completed in the background.
while (!result.IsCompleted)
{
System.Threading.Thread.Sleep(1);
}
this.ProcessImage(context);
}
/// <summary>
@ -173,10 +134,37 @@ namespace ImageProcessor.Web.HttpModules
/// references to the intrinsic server objects
/// </param>
private void ProcessImage(HttpContext context)
{
this.ProcessImageAsync(context);
}
/// <summary>
/// Processes the image.
/// </summary>
/// <param name="context">
/// the <see cref="T:System.Web.HttpContext">HttpContext</see> object that provides
/// references to the intrinsic server objects
/// </param>
private /*async*/ Task ProcessImageAsync(HttpContext context)
{
return this.ProcessImageAsyncTask(context).ToTask();
}
/// <summary>
/// Processes the image.
/// </summary>
/// <param name="context">
/// the <see cref="T:System.Web.HttpContext">HttpContext</see> object that provides
/// references to the intrinsic server objects
/// </param>
/// <returns>
/// The <see cref="IEnumerable{Task}"/>.
/// </returns>
private IEnumerable<Task> ProcessImageAsyncTask(HttpContext context)
{
// Is this a remote file.
bool isRemote = context.Request.Path.Equals(RemotePrefix, StringComparison.OrdinalIgnoreCase);
string path = string.Empty;
string requestPath = string.Empty;
string queryString = string.Empty;
if (isRemote)
@ -188,7 +176,7 @@ namespace ImageProcessor.Web.HttpModules
{
string[] paths = urlDecode.Split('?');
path = paths[0];
requestPath = paths[0];
if (paths.Length > 1)
{
@ -198,74 +186,96 @@ namespace ImageProcessor.Web.HttpModules
}
else
{
path = HostingEnvironment.MapPath(context.Request.Path);
requestPath = HostingEnvironment.MapPath(context.Request.Path);
queryString = HttpUtility.UrlDecode(context.Request.QueryString.ToString());
}
// Only process requests that pass our sanitizing filter.
if (ImageUtils.IsValidImageExtension(path) && !string.IsNullOrWhiteSpace(queryString))
if (ImageUtils.IsValidImageExtension(requestPath) && !string.IsNullOrWhiteSpace(queryString))
{
if (this.FileExists(path, isRemote))
if (this.FileExists(requestPath, isRemote))
{
string fullPath = string.Format("{0}?{1}", path, queryString);
string imageName = Path.GetFileName(path);
string cachedPath = DiskCache.GetCachePath(fullPath, imageName);
bool isUpdated = DiskCache.IsUpdatedFile(path, cachedPath, isRemote);
string fullPath = string.Format("{0}?{1}", requestPath, queryString);
string imageName = Path.GetFileName(requestPath);
// Create a new cache to help process and cache the request.
Cache cache = new Cache(requestPath, fullPath, imageName, isRemote);
// Is the file new or updated?
Task<bool> isUpdatedTask = cache.isNewOrUpdatedFileAsync();
yield return isUpdatedTask;
bool isNewOrUpdated = isUpdatedTask.Result;
// Only process if the file has been updated.
if (isUpdated)
if (isNewOrUpdated)
{
// Process the image.
using (ImageFactory imageFactory = new ImageFactory())
{
if (isRemote)
{
Uri uri = new Uri(path);
RemoteFile remoteFile = new RemoteFile(uri, false);
Uri uri = new Uri(requestPath);
HttpWebRequest webRequest = (HttpWebRequest)WebRequest.Create(uri);
Task<WebResponse> responseTask = webRequest.GetResponseAsync();
yield return responseTask;
//RemoteFile remoteFile = new RemoteFile(uri, false);
//Task<WebResponse> getWebResponseTask = remoteFile.GetWebResponseAsync();
//yield return getWebResponseTask;
using (MemoryStream memoryStream = new MemoryStream())
{
using (Stream responseStream = remoteFile.GetWebResponse().GetResponseStream())
using (WebResponse response = responseTask.Result)
{
if (responseStream != null)
using (Stream responseStream = response.GetResponseStream())
{
//lock (SyncRoot)
//{
if (responseStream != null)
{
// Trim the cache.
DiskCache.TrimCachedFolders();
Task trimCachedFoldersTask = cache.TrimCachedFoldersAsync();
yield return trimCachedFoldersTask;
responseStream.CopyTo(memoryStream);
imageFactory.Load(memoryStream)
.AddQueryString(queryString)
.Format(ImageUtils.GetImageFormat(imageName))
.AutoProcess().Save(cachedPath);
.AutoProcess().Save(cache.CachedPath);
// Ensure that the LastWriteTime property of the source and cached file match.
DateTime dateTime = DiskCache.SetCachedLastWriteTime(path, cachedPath, true);
Task<DateTime> setCachedLastWriteTimeTask = cache.SetCachedLastWriteTimeAsync();
yield return setCachedLastWriteTimeTask;
DateTime dateTime = setCachedLastWriteTimeTask.Result;
// Add to the cache.
DiskCache.AddImageToCache(cachedPath, dateTime);
//}
Task addImageToCacheTask = cache.AddImageToCacheAsync(dateTime);
yield return addImageToCacheTask;
}
}
}
}
}
else
{
//lock (SyncRoot)
//{
// Trim the cache.
DiskCache.TrimCachedFolders();
// Trim the cache.
Task trimCachedFoldersTask = cache.TrimCachedFoldersAsync();
yield return trimCachedFoldersTask;
imageFactory.Load(fullPath).AutoProcess().Save(cache.CachedPath);
imageFactory.Load(fullPath).AutoProcess().Save(cachedPath);
// Ensure that the LastWriteTime property of the source and cached file match.
Task<DateTime> setCachedLastWriteTimeTask = cache.SetCachedLastWriteTimeAsync();
yield return setCachedLastWriteTimeTask;
DateTime dateTime = setCachedLastWriteTimeTask.Result;
// Ensure that the LastWriteTime property of the source and cached file match.
DateTime dateTime = DiskCache.SetCachedLastWriteTime(path, cachedPath, false);
// Add to the cache.
Task addImageToCacheTask = cache.AddImageToCacheAsync(dateTime);
yield return addImageToCacheTask;
// Add to the cache.
DiskCache.AddImageToCache(cachedPath, dateTime);
//}
}
}
}
@ -273,11 +283,16 @@ namespace ImageProcessor.Web.HttpModules
context.Items[CachedResponseTypeKey] = ImageUtils.GetResponseType(imageName).ToDescription();
// The cached file is valid so just rewrite the path.
context.RewritePath(DiskCache.GetVirtualPath(cachedPath, context.Request), false);
context.RewritePath(cache.GetVirtualPath(cache.CachedPath, context.Request), false);
yield break;
}
}
yield break;
}
/// <summary>
/// returns a value indicating whether a file exists.
/// </summary>

3
src/ImageProcessor.Web/ImageProcessor.Web.csproj

@ -87,8 +87,9 @@
</ItemGroup>
<ItemGroup>
<Compile Include="Caching\CachedImage.cs" />
<Compile Include="Caching\Cacher.cs" />
<Compile Include="Caching\Cache.cs" />
<Compile Include="Caching\DiskCache.cs" />
<Compile Include="Helpers\AsyncIoExtensions.cs" />
<Compile Include="Helpers\TaskEx.cs" />
<Compile Include="Helpers\LockedDictionary.cs" />
<Compile Include="Caching\PersistantDictionary.cs" />

Loading…
Cancel
Save