From 622948bf14e2ccc4dc9f8ed8c5d5496e0959a4af Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Mon, 22 Apr 2019 20:12:22 +0200 Subject: [PATCH] Refactor virtual path management of StaticFilesModule (fixes #272 et al.) (#275) * Avoid creating a temporary Dictionary. * Remove excess parenthesis. * Refactor virtual path management of StaticFilesModule (fixes #272 et al.) * Determinism: - always evaluate virtual path in reverse ordinal order; - normalize URL paths to simplify code and ensure consistent mapping. * Separation of concerns: - have methods that deal with mapping URL paths to local paths, and other methods that deal with the existence of local paths. * Caching: - keep track of _how_ and _why_ paths were mapped; - discard cached mapped paths whose generating data (virtual path, default extension, default documkent name) has changed. * Support for dynamic file systems: - allow the user to deactivate path caching in situations where the contents of served directories may change over time. * Compatibility: - keep existing constructors, methods, and properties of StaticFilesModule; - keep existing exception semantics (e.g. throw InvalidOperationException instead of ArgumentException for invalid paths passed to RegisterVirtualPath). * Behavior changes: - scenarios where files could be added to a served directory after being requested (resulting in error 404) are no longer supported: said files will continue to give error 404, unless path caching is disabled. - scenarios where files are continuously added and/or deleted, which previously resulted in spurious error 404s (#272), can now be supported by disabling path caching. * Modify test that would not throw on machines where drive E: actually exists. --- src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs | 95 +++++ .../Core/PathMappingResult.cs | 38 ++ .../Core/ReverseOrdinalStringComparer.cs | 19 + .../Core/VirtualPath.cs | 42 ++ .../Core/VirtualPathManager.cs | 365 ++++++++++++++++++ .../Core/VirtualPaths.cs | 198 ---------- .../Modules/StaticFilesModule.cs | 123 ++++-- .../StaticFilesModuleTest.cs | 2 +- 8 files changed, 644 insertions(+), 238 deletions(-) create mode 100644 src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs create mode 100644 src/Unosquare.Labs.EmbedIO/Core/PathMappingResult.cs create mode 100644 src/Unosquare.Labs.EmbedIO/Core/ReverseOrdinalStringComparer.cs create mode 100644 src/Unosquare.Labs.EmbedIO/Core/VirtualPath.cs create mode 100644 src/Unosquare.Labs.EmbedIO/Core/VirtualPathManager.cs delete mode 100644 src/Unosquare.Labs.EmbedIO/Core/VirtualPaths.cs diff --git a/src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs b/src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs new file mode 100644 index 000000000..a605fb015 --- /dev/null +++ b/src/Unosquare.Labs.EmbedIO/Core/PathHelper.cs @@ -0,0 +1,95 @@ +namespace Unosquare.Labs.EmbedIO.Core +{ + using System; + using System.IO; + using System.Text.RegularExpressions; + + internal static class PathHelper + { + private static readonly Regex _multipleSlashRegex = new Regex("//+", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + static readonly char[] _invalidLocalPathChars = GetInvalidLocalPathChars(); + + public static bool IsValidUrlPath(string urlPath) => !string.IsNullOrEmpty(urlPath) && urlPath[0] == '/'; + + // urlPath must be a valid URL path + // (not null, not empty, starting with a slash.) + public static string NormalizeUrlPath(string urlPath, bool isBasePath) + { + // Replace each run of multiple slashes with a single slash + urlPath = _multipleSlashRegex.Replace(urlPath, "/"); + + // The root path needs no further checking. + var length = urlPath.Length; + if (length == 1) + return urlPath; + + // Base URL paths must end with a slash; + // non-base URL paths must NOT end with a slash. + // The final slash is irrelevant for the URL itself + // (it has to map the same way with or without it) + // but makes comparing and mapping URls a lot simpler. + var finalPosition = length - 1; + var endsWithSlash = urlPath[finalPosition] == '/'; + return isBasePath + ? (endsWithSlash ? urlPath : urlPath + "/") + : (endsWithSlash ? urlPath.Substring(0, finalPosition) : urlPath); + } + + public static string EnsureValidUrlPath(string urlPath, bool isBasePath) + { + if (urlPath == null) + { + throw new InvalidOperationException("URL path is null,"); + } + + if (urlPath.Length == 0) + { + throw new InvalidOperationException("URL path is empty."); + } + + if (urlPath[0] != '/') + { + throw new InvalidOperationException($"URL path \"{urlPath}\"does not start with a slash."); + } + + return NormalizeUrlPath(urlPath, isBasePath); + } + + public static string EnsureValidLocalPath(string localPath) + { + if (localPath == null) + { + throw new InvalidOperationException("Local path is null."); + } + + if (localPath.Length == 0) + { + throw new InvalidOperationException("Local path is empty."); + } + + if (string.IsNullOrWhiteSpace(localPath)) + { + throw new InvalidOperationException("Local path contains only white space."); + } + + if (localPath.IndexOfAny(_invalidLocalPathChars) >= 0) + { + throw new InvalidOperationException($"Local path \"{localPath}\"contains one or more invalid characters."); + } + + return localPath; + } + + private static char[] GetInvalidLocalPathChars() + { + var systemChars = Path.GetInvalidPathChars(); + var p = systemChars.Length; + var result = new char[p + 2]; + Array.Copy(systemChars, result, p); + result[p++] = '*'; + result[p] = '?'; + return result; + } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/PathMappingResult.cs b/src/Unosquare.Labs.EmbedIO/Core/PathMappingResult.cs new file mode 100644 index 000000000..62b6ebdc2 --- /dev/null +++ b/src/Unosquare.Labs.EmbedIO/Core/PathMappingResult.cs @@ -0,0 +1,38 @@ +namespace Unosquare.Labs.EmbedIO.Core +{ + using System; + + [Flags] + internal enum PathMappingResult + { + /// + /// The mask used to extract the mapping result. + /// + MappingMask = 0xF, + + /// + /// The path was not found. + /// + NotFound = 0, + + /// + /// The path was mapped to a file. + /// + IsFile = 0x1, + + /// + /// The path was mapped to a directory. + /// + IsDirectory = 0x2, + + /// + /// The default extension has been appended to the path. + /// + DefaultExtensionUsed = 0x1000, + + /// + /// The default document name has been appended to the path. + /// + DefaultDocumentUsed = 0x2000, + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/ReverseOrdinalStringComparer.cs b/src/Unosquare.Labs.EmbedIO/Core/ReverseOrdinalStringComparer.cs new file mode 100644 index 000000000..39ab8eccc --- /dev/null +++ b/src/Unosquare.Labs.EmbedIO/Core/ReverseOrdinalStringComparer.cs @@ -0,0 +1,19 @@ +namespace Unosquare.Labs.EmbedIO.Core +{ + using System; + using System.Collections.Generic; + + // Sorts strings in reverse order to obtain the evaluation order of virtual paths + internal sealed class ReverseOrdinalStringComparer : IComparer + { + private static readonly IComparer _directComparer = StringComparer.Ordinal; + + private ReverseOrdinalStringComparer() + { + } + + public static IComparer Instance { get; } = new ReverseOrdinalStringComparer(); + + public int Compare(string x, string y) => _directComparer.Compare(y, x); + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/VirtualPath.cs b/src/Unosquare.Labs.EmbedIO/Core/VirtualPath.cs new file mode 100644 index 000000000..bad12b0b9 --- /dev/null +++ b/src/Unosquare.Labs.EmbedIO/Core/VirtualPath.cs @@ -0,0 +1,42 @@ +namespace Unosquare.Labs.EmbedIO.Core +{ + using System; + using System.IO; + + internal sealed class VirtualPath + { + public VirtualPath(string baseUrlPath, string baseLocalPath) + { + BaseUrlPath = PathHelper.EnsureValidUrlPath(baseUrlPath, true); + try + { + BaseLocalPath = Path.GetFullPath(PathHelper.EnsureValidLocalPath(baseLocalPath)); + } +#pragma warning disable CA1031 + catch (Exception e) + { + throw new InvalidOperationException($"Cannot determine the full local path for \"{baseLocalPath}\".", e); + } +#pragma warning restore CA1031 + } + + public string BaseUrlPath { get; } + + public string BaseLocalPath { get; } + + internal bool CanMapUrlPath(string urlPath) => urlPath.StartsWith(BaseUrlPath, StringComparison.Ordinal); + + internal bool TryMapUrlPathLoLocalPath(string urlPath, out string localPath) + { + if (!CanMapUrlPath(urlPath)) + { + localPath = null; + return false; + } + + var relativeUrlPath = urlPath.Substring(BaseUrlPath.Length); + localPath = Path.Combine(BaseLocalPath, relativeUrlPath.Replace('/', Path.DirectorySeparatorChar)); + return true; + } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/VirtualPathManager.cs b/src/Unosquare.Labs.EmbedIO/Core/VirtualPathManager.cs new file mode 100644 index 000000000..ec49a43f9 --- /dev/null +++ b/src/Unosquare.Labs.EmbedIO/Core/VirtualPathManager.cs @@ -0,0 +1,365 @@ +namespace Unosquare.Labs.EmbedIO.Core +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.IO; + using System.Linq; + using System.Threading; + + internal sealed class VirtualPathManager : IDisposable + { + public const string DefaultDocumentName = "index.html"; + + private const string RootUrlPath = "/"; + + private readonly SortedDictionary _virtualPaths = new SortedDictionary(ReverseOrdinalStringComparer.Instance); + + private readonly VirtualPath _rootPath; + + private readonly ReaderWriterLockSlim _access = new ReaderWriterLockSlim(); + + private readonly ConcurrentDictionary _pathCache = new ConcurrentDictionary(); + + private string _defaultExtension; + + private string _defaultDocument = DefaultDocumentName; + + public VirtualPathManager(string rootLocalPath, bool canMapDirectories, bool cachePaths) + { + rootLocalPath = PathHelper.EnsureValidLocalPath(rootLocalPath); + _rootPath = new VirtualPath(RootUrlPath, rootLocalPath); + CanMapDirectories = canMapDirectories; + CachePaths = cachePaths; + } + + ~VirtualPathManager() + { + Dispose(false); + } + + public string RootLocalPath => _rootPath.BaseLocalPath; + + public bool CanMapDirectories { get; } + + public bool CachePaths { get; } + + public string DefaultExtension + { + get + { + _access.EnterReadLock(); + try + { + return _defaultExtension; + } + finally + { + _access.ExitReadLock(); + } + } + set + { + if (string.IsNullOrEmpty(value)) + { + value = null; + } + else if (value[0] != '.') + { + throw new InvalidOperationException("The default extension, if any, must start with a dot."); + } + + if (string.Equals(value, _defaultExtension, StringComparison.Ordinal)) + return; + + _access.EnterWriteLock(); + try + { + _defaultExtension = value; + + // Discard cache entries for which the previous default extension was used. + // If / when requested again, the new default extension will be used. + var keys = _pathCache + .Where(p => (p.Value.MappingResult & PathMappingResult.DefaultExtensionUsed) != 0) + .Select(p => p.Key) + .ToArray(); + foreach (var key in keys) + { + _pathCache.TryRemove(key, out _); + } + } + finally + { + _access.ExitWriteLock(); + } + } + } + + public string DefaultDocument + { + get + { + _access.EnterReadLock(); + try + { + return _defaultDocument; + } + finally + { + _access.ExitReadLock(); + } + } + set + { + if (string.IsNullOrEmpty(value)) + { + value = null; + } + + if (string.Equals(value, _defaultDocument, StringComparison.Ordinal)) + return; + + _access.EnterWriteLock(); + try + { + _defaultDocument = value; + + // Discard cache entries for which the previous default document was used. + // If / when requested again, the new default document will be used. + var keys = _pathCache + .Where(p => (p.Value.MappingResult & PathMappingResult.DefaultDocumentUsed) != 0) + .Select(p => p.Key) + .ToArray(); + foreach (var key in keys) + { + _pathCache.TryRemove(key, out _); + } + } + finally + { + _access.ExitWriteLock(); + } + } + } + + public ReadOnlyDictionary VirtualPaths + { + get + { + IDictionary dictionary; + + _access.EnterReadLock(); + try + { + dictionary = _virtualPaths.Values.ToDictionary(p => p.BaseUrlPath, p => p.BaseLocalPath); + } + finally + { + _access.ExitReadLock(); + } + + return new ReadOnlyDictionary(dictionary); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public void RegisterVirtualPath(string virtualPath, string physicalPath) + { + virtualPath = PathHelper.EnsureValidUrlPath(virtualPath, true); + + if (virtualPath == RootUrlPath) + throw new InvalidOperationException($"The virtual path {RootUrlPath} is invalid."); + + physicalPath = PathHelper.EnsureValidLocalPath(physicalPath); + + _access.EnterWriteLock(); + try + { + if (_virtualPaths.ContainsKey(virtualPath)) + throw new InvalidOperationException($"The virtual path {virtualPath} already exists."); + + var vp = new VirtualPath(virtualPath, physicalPath); + _virtualPaths.Add(virtualPath, vp); + + // Remove URL paths that could be mapped by the new virtual path, + // but were mapped by either a shorter virtual path, or the root path, + // from the mapped paths cache. + // If / when requested again, those paths can now be mapped by the newly-added virtual path. + var keys = _pathCache + .Where(p => vp.CanMapUrlPath(p.Key) && p.Value.BaseUrlPath.Length < virtualPath.Length) + .Select(p => p.Key) + .ToArray(); + foreach (var key in keys) + { + _pathCache.TryRemove(key, out _); + } + } + finally + { + _access.ExitWriteLock(); + } + } + + public void UnregisterVirtualPath(string virtualPath) + { + virtualPath = PathHelper.EnsureValidUrlPath(virtualPath, true); + + _access.EnterWriteLock(); + try + { + if (!_virtualPaths.ContainsKey(virtualPath)) + throw new InvalidOperationException($"The virtual path {virtualPath} does not exist."); + + _virtualPaths.Remove(virtualPath); + + // Remove paths mapped by this virtual path + // from the mapped paths cache. + // If / when requested again, those paths will be mapped + // by either a shorter virtual path, or the root path. + var keys = _pathCache + .Where(p => string.Equals(virtualPath, p.Value.BaseUrlPath, StringComparison.Ordinal)) + .Select(p => p.Key) + .ToArray(); + foreach (var key in keys) + { + _pathCache.TryRemove(key, out _); + } + } + finally + { + _access.ExitWriteLock(); + } + } + + public PathMappingResult MapUrlPath(string urlPath, out string localPath) + { + urlPath = PathHelper.NormalizeUrlPath(urlPath, false); + var result = CachePaths ? _pathCache.GetOrAdd(urlPath, MapUrlPathCore) : MapUrlPathCore(urlPath); + localPath = result.LocalPath; + return result.MappingResult; + } + + private void Dispose(bool disposing) + { + if (disposing) + { + _access.Dispose(); + } + + _pathCache.Clear(); + } + + private PathCacheItem MapUrlPathCore(string urlPath) + { + _access.EnterReadLock(); + try + { + var localPath = MapUrlPathToLocalPath(urlPath, out var baseUrlPath); + var validationResult = ValidateLocalPath(ref localPath); + return new PathCacheItem(baseUrlPath, localPath, validationResult); + } + finally + { + _access.ExitReadLock(); + } + } + + private string MapUrlPathToLocalPath(string urlPath, out string baseUrlPath) + { + // Assuming that urlPath is not null, not empty, and starts with a slash, + // a length lower than 2 can only mean that the path is "/". + // Bail out early, because we need at least a length of 2 + // for the optimizations below to work. + if (urlPath.Length < 2) + { + baseUrlPath = RootUrlPath; + return _rootPath.BaseLocalPath; + } + + string localPath; + + // First try to use each virtual path in reverse ordinal order + // (so e.g. "/media/images" is evaluated before "/media".) + // As long as we keep checks simple, we can try to optimize the loop a little. + // The second character of a URL path is the first character following the initial slash; + // by checking just that, we can avoid some useless calls to TryMapUrlPathLoLocalPath. + var secondChar = urlPath[1]; + foreach (var virtualPath in _virtualPaths.Values) + { + var baseSecondChar = virtualPath.BaseUrlPath[1]; + if (baseSecondChar == secondChar) + { + // If the second character is the same, try mapping. + if (virtualPath.TryMapUrlPathLoLocalPath(urlPath, out localPath)) + { + baseUrlPath = virtualPath.BaseUrlPath; + return localPath; + } + } + else if (baseSecondChar < secondChar) + { + // If we have reached a base URL path with a second character + // with a lower value than ours, we can safely bail out of the loop. + break; + } + } + + // If no virtual path can map our URL path, use the root path. + // This will always succeed. + _rootPath.TryMapUrlPathLoLocalPath(urlPath, out localPath); + baseUrlPath = RootUrlPath; + return localPath; + } + + private PathMappingResult ValidateLocalPath(ref string localPath) + { + if (File.Exists(localPath)) + return PathMappingResult.IsFile; + + if (Directory.Exists(localPath)) + { + if (CanMapDirectories) + return PathMappingResult.IsDirectory; + + if (_defaultDocument != null) + { + localPath = Path.Combine(localPath, _defaultDocument); + if (File.Exists(localPath)) + return PathMappingResult.IsFile | PathMappingResult.DefaultDocumentUsed; + } + } + + if (_defaultExtension != null) + { + localPath += _defaultExtension; + if (File.Exists(localPath)) + return PathMappingResult.IsFile | PathMappingResult.DefaultExtensionUsed; + } + + localPath = null; + return PathMappingResult.NotFound; + } + + private struct PathCacheItem + { + public readonly string BaseUrlPath; + + public readonly string LocalPath; + + public readonly PathMappingResult MappingResult; + + public PathCacheItem(string baseUrlPath, string localPath, PathMappingResult mappingResult) + { + BaseUrlPath = baseUrlPath; + LocalPath = localPath; + MappingResult = mappingResult; + } + } + } +} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Core/VirtualPaths.cs b/src/Unosquare.Labs.EmbedIO/Core/VirtualPaths.cs deleted file mode 100644 index 29d73f42d..000000000 --- a/src/Unosquare.Labs.EmbedIO/Core/VirtualPaths.cs +++ /dev/null @@ -1,198 +0,0 @@ -namespace Unosquare.Labs.EmbedIO.Core -{ - using System; - using System.Collections.Concurrent; - using System.Collections.Generic; - using System.Collections.ObjectModel; - using System.IO; - using System.Linq; - - internal class VirtualPaths : Dictionary - { - private readonly ConcurrentDictionary _validPaths = new ConcurrentDictionary(); - - private readonly ConcurrentDictionary _mappedPaths = new ConcurrentDictionary(); - - public VirtualPaths(string fileSystemPath, bool useDirectoryBrowser) - { - FileSystemPath = fileSystemPath; - UseDirectoryBrowser = useDirectoryBrowser; - } - - public ReadOnlyDictionary Collection => new ReadOnlyDictionary(this); - - public string DefaultDocument { get; set; } - - public string DefaultExtension { get; set; } - - public string FileSystemPath { get; } - - public bool UseDirectoryBrowser { get; } - - internal VirtualPathStatus ExistsLocalPath(string urlPath, ref string localPath) - { - if (_validPaths.TryGetValue(localPath, out var tempPath)) - { - localPath = tempPath; - return UseDirectoryBrowser && Directory.Exists(localPath) - ? VirtualPathStatus.Directory - : VirtualPathStatus.File; - } - - // Check if the requested local path is part of the root File System Path - // or of any of the virtualized paths (this latter test fixes issue #263) - var testLocalPath = localPath; - - if (!IsPartOfPath(localPath, FileSystemPath) && !Values.Any(vfsPath => IsPartOfPath(testLocalPath, vfsPath))) - { - return VirtualPathStatus.Forbidden; - } - - var originalPath = localPath; - var result = ExistsPath(urlPath, ref localPath); - - if (result != VirtualPathStatus.Invalid) - _validPaths.TryAdd(originalPath, localPath); - - return result; - } - - internal void RegisterVirtualPath(string virtualPath, string physicalPath) - { - if (string.IsNullOrWhiteSpace(virtualPath) || virtualPath == "/" || virtualPath[0] != '/') - throw new InvalidOperationException($"The virtual path {virtualPath} is invalid"); - - if (ContainsKey(virtualPath)) - throw new InvalidOperationException($"The virtual path {virtualPath} already exists"); - - if (!Directory.Exists(physicalPath)) - throw new InvalidOperationException($"The physical path {physicalPath} doesn't exist"); - - physicalPath = Path.GetFullPath(physicalPath); - Add(virtualPath, physicalPath); - } - - internal void UnregisterVirtualPath(string virtualPath) - { - if (ContainsKey(virtualPath) == false) - throw new InvalidOperationException($"The virtual path {virtualPath} doesn't exists"); - - Remove(virtualPath); - } - - internal string GetUrlPath(string requestPath, ref string baseLocalPath) - { - if (_mappedPaths.TryGetValue(requestPath, out var urlPath)) - { - return urlPath; - } - - urlPath = requestPath.Replace('/', Path.DirectorySeparatorChar); - - if (this.Any(x => requestPath.StartsWith(x.Key))) - { - var additionalPath = this.FirstOrDefault(x => requestPath.StartsWith(x.Key)); - baseLocalPath = additionalPath.Value; - urlPath = urlPath.Replace(additionalPath.Key.Replace('/', Path.DirectorySeparatorChar), string.Empty); - - if (string.IsNullOrWhiteSpace(urlPath)) - { - urlPath = Path.DirectorySeparatorChar.ToString(); - } - } - - // adjust the path to see if we've got a default document - if (!UseDirectoryBrowser && urlPath.Last() == Path.DirectorySeparatorChar) - { - urlPath = urlPath + DefaultDocument; - } - - urlPath = urlPath.TrimStart(Path.DirectorySeparatorChar); - - _mappedPaths.TryAdd(requestPath, urlPath); - - return urlPath; - } - - private static bool IsPartOfPath(string targetPath, string basePath) - { - targetPath = Path.GetFullPath(targetPath).ToLowerInvariant().TrimEnd('/', '\\'); - basePath = Path.GetFullPath(basePath).ToLowerInvariant().TrimEnd('/', '\\'); - - return targetPath.StartsWith(basePath); - } - - private VirtualPathStatus ExistsPath(string urlPath, ref string localPath) - { - // check if the path is just a directory and return - if (UseDirectoryBrowser && Directory.Exists(localPath)) - { - return VirtualPathStatus.Directory; - } - - if (DefaultExtension?.StartsWith(".") == true && !File.Exists(localPath)) - { - localPath += DefaultExtension; - } - - if (File.Exists(localPath)) - { - return VirtualPathStatus.File; - } - - if (File.Exists(Path.Combine(localPath, DefaultDocument))) - { - localPath = Path.Combine(localPath, DefaultDocument); - } - else - { - // Try to fallback to root - var rootLocalPath = Path.Combine(FileSystemPath, urlPath); - - if (UseDirectoryBrowser && Directory.Exists(rootLocalPath)) - { - localPath = rootLocalPath; - return VirtualPathStatus.Directory; - } - - if (File.Exists(rootLocalPath)) - { - localPath = rootLocalPath; - } - else if (File.Exists(Path.Combine(rootLocalPath, DefaultDocument))) - { - localPath = Path.Combine(rootLocalPath, DefaultDocument); - } - else - { - return VirtualPathStatus.Invalid; - } - } - - return VirtualPathStatus.File; - } - - internal enum VirtualPathStatus - { - /// - /// The invalid - /// - Invalid, - - /// - /// The forbidden - /// - Forbidden, - - /// - /// The file - /// - File, - - /// - /// The directory - /// - Directory, - } - } -} \ No newline at end of file diff --git a/src/Unosquare.Labs.EmbedIO/Modules/StaticFilesModule.cs b/src/Unosquare.Labs.EmbedIO/Modules/StaticFilesModule.cs index 9c1ce1af8..9517dff64 100644 --- a/src/Unosquare.Labs.EmbedIO/Modules/StaticFilesModule.cs +++ b/src/Unosquare.Labs.EmbedIO/Modules/StaticFilesModule.cs @@ -17,13 +17,12 @@ /// /// Represents a simple module to server static files from the file system. /// - public class StaticFilesModule - : FileModuleBase + public class StaticFilesModule : FileModuleBase, IDisposable { /// /// Default document constant to "index.html". /// - public const string DefaultDocumentName = "index.html"; + public const string DefaultDocumentName = VirtualPathManager.DefaultDocumentName; /// /// Maximal length of entry in DirectoryBrowser. @@ -35,7 +34,7 @@ public class StaticFilesModule /// private const int SizeIndent = 20; - private readonly VirtualPaths _virtualPaths; + private readonly VirtualPathManager _virtualPathManager; private readonly ConcurrentDictionary> _fileHashCache = new ConcurrentDictionary>(); @@ -45,7 +44,7 @@ public class StaticFilesModule /// /// The paths. public StaticFilesModule(Dictionary paths) - : this(paths.First().Value, null, paths) + : this(paths.First().Value, null, paths, false, true) { } @@ -55,7 +54,35 @@ public StaticFilesModule(Dictionary paths) /// The file system path. /// if set to true [use directory browser]. public StaticFilesModule(string fileSystemPath, bool useDirectoryBrowser) - : this(fileSystemPath, null, null, useDirectoryBrowser) + : this(fileSystemPath, null, null, useDirectoryBrowser, true) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The file system path. + /// if set to true [use directory browser]. + /// if set to true, [cache mapped paths]. + public StaticFilesModule(string fileSystemPath, bool useDirectoryBrowser, bool cacheMappedPaths) + : this(fileSystemPath, null, null, useDirectoryBrowser, cacheMappedPaths) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The file system path. + /// The headers to set in every request. + /// The additional paths. + /// if set to true [use directory browser]. + /// Path ' + fileSystemPath + ' does not exist. + public StaticFilesModule( + string fileSystemPath, + Dictionary headers, + Dictionary additionalPaths, + bool useDirectoryBrowser) + : this(fileSystemPath, headers, additionalPaths, useDirectoryBrowser, true) { } @@ -66,17 +93,19 @@ public StaticFilesModule(string fileSystemPath, bool useDirectoryBrowser) /// The headers to set in every request. /// The additional paths. /// if set to true [use directory browser]. + /// if set to true, [cache mapped paths]. /// Path ' + fileSystemPath + ' does not exist. public StaticFilesModule( string fileSystemPath, Dictionary headers = null, Dictionary additionalPaths = null, - bool useDirectoryBrowser = false) + bool useDirectoryBrowser = false, + bool cacheMappedPaths = true) { if (!Directory.Exists(fileSystemPath)) throw new ArgumentException($"Path '{fileSystemPath}' does not exist."); - _virtualPaths = new VirtualPaths(Path.GetFullPath(fileSystemPath), useDirectoryBrowser); + _virtualPathManager = new VirtualPathManager(Path.GetFullPath(fileSystemPath), useDirectoryBrowser, cacheMappedPaths); DefaultDocument = DefaultDocumentName; UseGzip = true; @@ -88,14 +117,23 @@ public StaticFilesModule( #endif headers?.ForEach(DefaultHeaders.Add); - additionalPaths?.Where(path => path.Key != "/") - .ToDictionary(x => x.Key, x => x.Value) - .ForEach(RegisterVirtualPath); + additionalPaths?.ForEach((virtualPath, physicalPath) => { + if (virtualPath != "/") + RegisterVirtualPath(virtualPath, physicalPath); + }); AddHandler(ModuleMap.AnyPath, HttpVerbs.Head, (context, ct) => HandleGet(context, ct, false)); AddHandler(ModuleMap.AnyPath, HttpVerbs.Get, (context, ct) => HandleGet(context, ct)); } + /// + /// Finalizes an instance of the class. + /// + ~StaticFilesModule() + { + Dispose(false); + } + /// /// Gets or sets the maximum size of the ram cache file. The default value is 250kb. /// @@ -114,8 +152,8 @@ public StaticFilesModule( /// public string DefaultDocument { - get => _virtualPaths.DefaultDocument; - set => _virtualPaths.DefaultDocument = value; + get => _virtualPathManager.DefaultDocument; + set => _virtualPathManager.DefaultDocument = value; } /// @@ -128,8 +166,8 @@ public string DefaultDocument /// public string DefaultExtension { - get => _virtualPaths.DefaultExtension; - set => _virtualPaths.DefaultExtension = value; + get => _virtualPathManager.DefaultExtension; + set => _virtualPathManager.DefaultExtension = value; } /// @@ -138,7 +176,7 @@ public string DefaultExtension /// /// The file system path. /// - public string FileSystemPath => _virtualPaths.FileSystemPath; + public string FileSystemPath => _virtualPathManager.RootLocalPath; /// /// Gets or sets a value indicating whether or not to use the RAM Cache feature @@ -155,7 +193,7 @@ public string DefaultExtension /// /// The virtual paths. /// - public ReadOnlyDictionary VirtualPaths => _virtualPaths.Collection; + public ReadOnlyDictionary VirtualPaths => _virtualPathManager.VirtualPaths; /// public override string Name => nameof(StaticFilesModule); @@ -168,6 +206,15 @@ public string DefaultExtension /// private RamCache RamCache { get; } = new RamCache(); + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + /// /// Registers the virtual path. /// @@ -177,7 +224,7 @@ public string DefaultExtension /// Is thrown when a method call is invalid for the object's current state. /// public void RegisterVirtualPath(string virtualPath, string physicalPath) - => _virtualPaths.RegisterVirtualPath(virtualPath, physicalPath); + => _virtualPathManager.RegisterVirtualPath(virtualPath, physicalPath); /// /// Unregisters the virtual path. @@ -186,13 +233,24 @@ public void RegisterVirtualPath(string virtualPath, string physicalPath) /// /// Is thrown when a method call is invalid for the object's current state. /// - public void UnregisterVirtualPath(string virtualPath) => _virtualPaths.UnregisterVirtualPath(virtualPath); + public void UnregisterVirtualPath(string virtualPath) => _virtualPathManager.UnregisterVirtualPath(virtualPath); /// /// Clears the RAM cache. /// public void ClearRamCache() => RamCache.Clear(); + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (!disposing) return; + + _virtualPathManager.Dispose(); + } + private static Task HandleDirectory(IHttpContext context, string localPath, CancellationToken ct) { var entries = new[] { context.Request.RawUrl == "/" ? string.Empty : "../" } @@ -244,15 +302,12 @@ private static Task HandleDirectory(IHttpContext context, string localPath private Task HandleGet(IHttpContext context, CancellationToken ct, bool sendBuffer = true) { - switch (ValidatePath(context, out var requestFullLocalPath)) + switch (_virtualPathManager.MapUrlPath(context.RequestPathCaseSensitive(), out var localPath) & PathMappingResult.MappingMask) { - case Core.VirtualPaths.VirtualPathStatus.Forbidden: - context.Response.StatusCode = (int)System.Net.HttpStatusCode.Forbidden; - return Task.FromResult(true); - case Core.VirtualPaths.VirtualPathStatus.File: - return HandleFile(context, requestFullLocalPath, sendBuffer, ct); - case Core.VirtualPaths.VirtualPathStatus.Directory: - return HandleDirectory(context, requestFullLocalPath, ct); + case PathMappingResult.IsFile: + return HandleFile(context, localPath, sendBuffer, ct); + case PathMappingResult.IsDirectory: + return HandleDirectory(context, localPath, ct); default: return Task.FromResult(false); } @@ -315,9 +370,9 @@ await WriteFileAsync( // Connection error, nothing else to do var isListenerException = #if !NETSTANDARD1_3 - (ex is System.Net.HttpListenerException) || + ex is System.Net.HttpListenerException || #endif - (ex is Net.HttpListenerException); + ex is Net.HttpListenerException; if (!isListenerException) throw; @@ -365,16 +420,6 @@ private Stream GetFileStream(IHttpContext context, FileSystemInfo fileInfo, bool return buffer; } - private VirtualPaths.VirtualPathStatus ValidatePath(IHttpContext context, out string requestFullLocalPath) - { - var baseLocalPath = FileSystemPath; - var requestLocalPath = _virtualPaths.GetUrlPath(context.RequestPathCaseSensitive(), ref baseLocalPath); - - requestFullLocalPath = Path.Combine(baseLocalPath, requestLocalPath); - - return _virtualPaths.ExistsLocalPath(requestLocalPath, ref requestFullLocalPath); - } - private bool UpdateFileCache( IHttpResponse response, Stream buffer, diff --git a/test/Unosquare.Labs.EmbedIO.Tests/StaticFilesModuleTest.cs b/test/Unosquare.Labs.EmbedIO.Tests/StaticFilesModuleTest.cs index 6698acb73..c885c8309 100644 --- a/test/Unosquare.Labs.EmbedIO.Tests/StaticFilesModuleTest.cs +++ b/test/Unosquare.Labs.EmbedIO.Tests/StaticFilesModuleTest.cs @@ -272,7 +272,7 @@ public void RegisterInvalidPhysicalPath_ThrowsInvalidOperationException() Assert.Throws(() => { var instance = new StaticFilesModule(Directory.GetCurrentDirectory()); - instance.RegisterVirtualPath("/tmp", "e:"); + instance.RegisterVirtualPath("/tmp", @"e:*.dll"); }); } }