using System; using System.Collections.Generic; using System.IO; using System.Linq; using Microsoft.Extensions.Logging; using Umbraco.Core.Exceptions; using Umbraco.Core.Composing; using System.Threading; using Umbraco.Core.Hosting; namespace Umbraco.Core.IO { public interface IPhysicalFileSystem : IFileSystem {} public class PhysicalFileSystem : IPhysicalFileSystem { private readonly IIOHelper _ioHelper; private readonly ILogger _logger; // the rooted, filesystem path, using directory separator chars, NOT ending with a separator // eg "c:" or "c:\path\to\site" or "\\server\path" private readonly string _rootPath; // _rootPath, but with separators replaced by forward-slashes // eg "c:" or "c:/path/to/site" or "//server/path" // (is used in GetRelativePath) private readonly string _rootPathFwd; // the relative URL, using URL separator chars, NOT ending with a separator // eg "" or "/Views" or "/Media" or "//Media" in case of a virtual path private readonly string _rootUrl; public PhysicalFileSystem(IIOHelper ioHelper,IHostingEnvironment hostingEnvironment, ILogger logger, string rootPath, string rootUrl) { _ioHelper = ioHelper ?? throw new ArgumentNullException(nameof(ioHelper)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); if (rootPath == null) throw new ArgumentNullException(nameof(rootPath)); if (string.IsNullOrEmpty(rootPath)) throw new ArgumentException("Value can't be empty.", nameof(rootPath)); if (rootUrl == null) throw new ArgumentNullException(nameof(rootUrl)); if (string.IsNullOrEmpty(rootUrl)) throw new ArgumentException("Value can't be empty.", nameof(rootUrl)); if (rootPath.StartsWith("~/")) throw new ArgumentException("Value can't be a virtual path and start with '~/'.", nameof(rootPath)); // rootPath should be... rooted, as in, it's a root path! if (Path.IsPathRooted(rootPath) == false) { // but the test suite App.config cannot really "root" anything so we have to do it here var localRoot = hostingEnvironment.MapPathContentRoot("~"); rootPath = Path.Combine(localRoot, rootPath); } // clean up root path rootPath = Path.GetFullPath(rootPath); _rootPath = EnsureDirectorySeparatorChar(rootPath).TrimEnd(Path.DirectorySeparatorChar); _rootPathFwd = EnsureUrlSeparatorChar(_rootPath); _rootUrl = EnsureUrlSeparatorChar(rootUrl).TrimEnd('/'); } /// /// Gets directories in a directory. /// /// The filesystem-relative path to the directory. /// The filesystem-relative path to the directories in the directory. /// Filesystem-relative paths use forward-slashes as directory separators. public IEnumerable GetDirectories(string path) { var fullPath = GetFullPath(path); try { if (Directory.Exists(fullPath)) return Directory.EnumerateDirectories(fullPath).Select(GetRelativePath); } catch (UnauthorizedAccessException ex) { _logger.LogError(ex, "Not authorized to get directories for '{Path}'", fullPath); } catch (DirectoryNotFoundException ex) { _logger.LogError(ex, "Directory not found for '{Path}'", fullPath); } return Enumerable.Empty(); } /// /// Deletes a directory. /// /// The filesystem-relative path of the directory. public void DeleteDirectory(string path) { DeleteDirectory(path, false); } /// /// Deletes a directory. /// /// The filesystem-relative path of the directory. /// A value indicating whether to recursively delete sub-directories. public void DeleteDirectory(string path, bool recursive) { var fullPath = GetFullPath(path); if (Directory.Exists(fullPath) == false) return; try { WithRetry(() => Directory.Delete(fullPath, recursive)); } catch (DirectoryNotFoundException ex) { _logger.LogError(ex, "Directory not found for '{Path}'", fullPath); } } /// /// Gets a value indicating whether a directory exists. /// /// The filesystem-relative path of the directory. /// A value indicating whether a directory exists. public bool DirectoryExists(string path) { var fullPath = GetFullPath(path); return Directory.Exists(fullPath); } /// /// Saves a file. /// /// The filesystem-relative path of the file. /// A stream containing the file data. /// Overrides the existing file, if any. public void AddFile(string path, Stream stream) { AddFile(path, stream, true); } /// /// Saves a file. /// /// The filesystem-relative path of the file. /// A stream containing the file data. /// A value indicating whether to override the existing file, if any. /// If a file exists and is false, an exception is thrown. public void AddFile(string path, Stream stream, bool overrideExisting) { var fullPath = GetFullPath(path); var exists = File.Exists(fullPath); if (exists && overrideExisting == false) throw new InvalidOperationException(string.Format("A file at path '{0}' already exists", path)); var directory = Path.GetDirectoryName(fullPath); if (directory == null) throw new InvalidOperationException("Could not get directory."); Directory.CreateDirectory(directory); // ensure it exists if (stream.CanSeek) // TODO: what if we cannot? stream.Seek(0, 0); using (var destination = (Stream) File.Create(fullPath)) stream.CopyTo(destination); } /// /// Gets files in a directory. /// /// The filesystem-relative path of the directory. /// The filesystem-relative path to the files in the directory. /// Filesystem-relative paths use forward-slashes as directory separators. public IEnumerable GetFiles(string path) { return GetFiles(path, "*.*"); } /// /// Gets files in a directory. /// /// The filesystem-relative path of the directory. /// A filter. /// The filesystem-relative path to the matching files in the directory. /// Filesystem-relative paths use forward-slashes as directory separators. public IEnumerable GetFiles(string path, string filter) { var fullPath = GetFullPath(path); try { if (Directory.Exists(fullPath)) return Directory.EnumerateFiles(fullPath, filter).Select(GetRelativePath); } catch (UnauthorizedAccessException ex) { _logger.LogError(ex, "Not authorized to get directories for '{Path}'", fullPath); } catch (DirectoryNotFoundException ex) { _logger.LogError(ex, "Directory not found for '{FullPath}'", fullPath); } return Enumerable.Empty(); } /// /// Opens a file. /// /// The filesystem-relative path to the file. /// public Stream OpenFile(string path) { var fullPath = GetFullPath(path); return File.OpenRead(fullPath); } /// /// Deletes a file. /// /// The filesystem-relative path to the file. public void DeleteFile(string path) { var fullPath = GetFullPath(path); if (File.Exists(fullPath) == false) return; try { WithRetry(() => File.Delete(fullPath)); } catch (FileNotFoundException ex) { _logger.LogError(ex.InnerException, "DeleteFile failed with FileNotFoundException for '{Path}'", fullPath); } } /// /// Gets a value indicating whether a file exists. /// /// The filesystem-relative path to the file. /// A value indicating whether the file exists. public bool FileExists(string path) { var fullpath = GetFullPath(path); return File.Exists(fullpath); } /// /// Gets the filesystem-relative path of a full path or of an URL. /// /// The full path or URL. /// The path, relative to this filesystem's root. /// /// The relative path is relative to this filesystem's root, not starting with any /// directory separator. All separators are forward-slashes. /// public string GetRelativePath(string fullPathOrUrl) { // test URL var path = fullPathOrUrl.Replace('\\', '/'); // ensure URL separator char // if it starts with the root URL, strip it and trim the starting slash to make it relative // eg "/Media/1234/img.jpg" => "1234/img.jpg" if (_ioHelper.PathStartsWith(path, _rootUrl, '/')) return path.Substring(_rootUrl.Length).TrimStart('/'); // if it starts with the root path, strip it and trim the starting slash to make it relative // eg "c:/websites/test/root/Media/1234/img.jpg" => "1234/img.jpg" if (_ioHelper.PathStartsWith(path, _rootPathFwd, '/')) return path.Substring(_rootPathFwd.Length).TrimStart('/'); // unchanged - what else? return path; } /// /// Gets the full path. /// /// The full or filesystem-relative path. /// The full path. /// /// On the physical filesystem, the full path is the rooted (ie non-relative), safe (ie within this /// filesystem's root) path. All separators are Path.DirectorySeparatorChar. /// public string GetFullPath(string path) { // normalize var opath = path; path = EnsureDirectorySeparatorChar(path); // FIXME: this part should go! // not sure what we are doing here - so if input starts with a (back) slash, // we assume it's not a FS relative path and we try to convert it... but it // really makes little sense? if (path.StartsWith(Path.DirectorySeparatorChar.ToString())) path = GetRelativePath(path); // if not already rooted, combine with the root path if (_ioHelper.PathStartsWith(path, _rootPath, Path.DirectorySeparatorChar) == false) path = Path.Combine(_rootPath, path); // sanitize - GetFullPath will take care of any relative // segments in path, eg '../../foo.tmp' - it may throw a SecurityException // if the combined path reaches illegal parts of the filesystem path = Path.GetFullPath(path); // at that point, path is within legal parts of the filesystem, ie we have // permissions to reach that path, but it may nevertheless be outside of // our root path, due to relative segments, so better check if (_ioHelper.PathStartsWith(path, _rootPath, Path.DirectorySeparatorChar)) { // this says that 4.7.2 supports long paths - but Windows does not // https://docs.microsoft.com/en-us/dotnet/api/system.io.pathtoolongexception?view=netframework-4.7.2 if (path.Length > 260) throw new PathTooLongException($"Path {path} is too long."); return path; } // nothing prevents us to reach the file, security-wise, yet it is outside // this filesystem's root - throw throw new UnauthorizedAccessException($"File original: [{opath}] full: [{path}] is outside this filesystem's root."); } /// /// Gets the URL. /// /// The filesystem-relative path. /// The URL. /// All separators are forward-slashes. public string GetUrl(string path) { path = EnsureUrlSeparatorChar(path).Trim('/'); return _rootUrl + "/" + path; } /// /// Gets the last-modified date of a directory or file. /// /// The filesystem-relative path to the directory or the file. /// The last modified date of the directory or the file. public DateTimeOffset GetLastModified(string path) { var fullpath = GetFullPath(path); return DirectoryExists(fullpath) ? new DirectoryInfo(fullpath).LastWriteTimeUtc : new FileInfo(fullpath).LastWriteTimeUtc; } /// /// Gets the created date of a directory or file. /// /// The filesystem-relative path to the directory or the file. /// The created date of the directory or the file. public DateTimeOffset GetCreated(string path) { var fullpath = GetFullPath(path); return DirectoryExists(fullpath) ? Directory.GetCreationTimeUtc(fullpath) : File.GetCreationTimeUtc(fullpath); } /// /// Gets the size of a file. /// /// The filesystem-relative path to the file. /// The file of the size, in bytes. /// If the file does not exist, returns -1. public long GetSize(string path) { var fullPath = GetFullPath(path); var file = new FileInfo(fullPath); return file.Exists ? file.Length : -1; } public bool CanAddPhysical => true; public void AddFile(string path, string physicalPath, bool overrideIfExists = true, bool copy = false) { var fullPath = GetFullPath(path); if (File.Exists(fullPath)) { if (overrideIfExists == false) throw new InvalidOperationException($"A file at path '{path}' already exists"); WithRetry(() => File.Delete(fullPath)); } var directory = Path.GetDirectoryName(fullPath); if (directory == null) throw new InvalidOperationException("Could not get directory."); Directory.CreateDirectory(directory); // ensure it exists if (copy) WithRetry(() => File.Copy(physicalPath, fullPath)); else WithRetry(() => File.Move(physicalPath, fullPath)); } #region Helper Methods protected virtual void EnsureDirectory(string path) { path = GetFullPath(path); Directory.CreateDirectory(path); } protected string EnsureTrailingSeparator(string path) { return path.EnsureEndsWith(Path.DirectorySeparatorChar); } protected string EnsureDirectorySeparatorChar(string path) { path = path.Replace('/', Path.DirectorySeparatorChar); path = path.Replace('\\', Path.DirectorySeparatorChar); return path; } protected string EnsureUrlSeparatorChar(string path) { path = path.Replace('\\', '/'); return path; } protected void WithRetry(Action action) { // 10 times 100ms is 1s const int count = 10; const int pausems = 100; for (var i = 0;; i++) { try { action(); break; // done } catch (IOException e) { // if it's not *exactly* IOException then it could be // some inherited exception such as FileNotFoundException, // and then we don't want to retry if (e.GetType() != typeof(IOException)) throw; // if we have tried enough, throw, else swallow // the exception and retry after a pause if (i == count) throw; } Thread.Sleep(pausems); } } #endregion } }