2018-06-29 19:52:40 +02:00
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using Umbraco.Core.Exceptions ;
using Umbraco.Core.Composing ;
using System.Threading ;
using Umbraco.Core.Logging ;
namespace Umbraco.Core.IO
{
public class PhysicalFileSystem : IFileSystem
{
2019-11-19 07:52:40 +01:00
private readonly IIOHelper _ioHelper ;
private readonly ILogger _logger ;
2018-06-29 19:52:40 +02:00
// 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 "/<vpath>/Media" in case of a virtual path
private readonly string _rootUrl ;
// virtualRoot should be "~/path/to/root" eg "~/Views"
// the "~/" is mandatory.
2019-11-19 07:52:40 +01:00
public PhysicalFileSystem ( IIOHelper ioHelper , ILogger logger , string virtualRoot )
2018-06-29 19:52:40 +02:00
{
2019-11-19 07:52:40 +01:00
_ioHelper = ioHelper ? ? throw new ArgumentNullException ( nameof ( ioHelper ) ) ;
_logger = logger ? ? throw new ArgumentNullException ( nameof ( logger ) ) ;
2018-06-29 19:52:40 +02:00
if ( virtualRoot = = null ) throw new ArgumentNullException ( "virtualRoot" ) ;
if ( virtualRoot . StartsWith ( "~/" ) = = false )
throw new ArgumentException ( "The virtualRoot argument must be a virtual path and start with '~/'" ) ;
2019-11-19 07:52:40 +01:00
_rootPath = EnsureDirectorySeparatorChar ( _ioHelper . MapPath ( virtualRoot ) ) . TrimEnd ( Path . DirectorySeparatorChar ) ;
2018-06-29 19:52:40 +02:00
_rootPathFwd = EnsureUrlSeparatorChar ( _rootPath ) ;
2019-11-19 07:52:40 +01:00
_rootUrl = EnsureUrlSeparatorChar ( _ioHelper . ResolveUrl ( virtualRoot ) ) . TrimEnd ( '/' ) ;
2018-06-29 19:52:40 +02:00
}
2019-11-19 07:52:40 +01:00
public PhysicalFileSystem ( IIOHelper ioHelper , ILogger logger , string rootPath , string rootUrl )
2018-06-29 19:52:40 +02:00
{
2019-11-19 07:52:40 +01:00
_ioHelper = ioHelper ? ? throw new ArgumentNullException ( nameof ( ioHelper ) ) ;
_logger = logger ? ? throw new ArgumentNullException ( nameof ( logger ) ) ;
2019-10-07 22:10:21 +02:00
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 ) ) ;
2018-06-29 19:52:40 +02:00
2019-11-19 07:52:40 +01:00
2018-06-29 19:52:40 +02:00
// 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
2020-04-03 15:36:57 +11:00
// TODO: This will map to the web content root (www) not the web app root, is that what we want? Else we need to use IHostingEnvironment.ApplicationPhysicalPath
2020-03-31 17:27:51 +11:00
var localRoot = _ioHelper . MapPath ( "~" ) ;
2018-06-29 19:52:40 +02:00
rootPath = Path . Combine ( localRoot , rootPath ) ;
}
_rootPath = EnsureDirectorySeparatorChar ( rootPath ) . TrimEnd ( Path . DirectorySeparatorChar ) ;
_rootPathFwd = EnsureUrlSeparatorChar ( _rootPath ) ;
_rootUrl = EnsureUrlSeparatorChar ( rootUrl ) . TrimEnd ( '/' ) ;
}
/// <summary>
/// Gets directories in a directory.
/// </summary>
/// <param name="path">The filesystem-relative path to the directory.</param>
/// <returns>The filesystem-relative path to the directories in the directory.</returns>
/// <remarks>Filesystem-relative paths use forward-slashes as directory separators.</remarks>
public IEnumerable < string > GetDirectories ( string path )
{
var fullPath = GetFullPath ( path ) ;
try
{
if ( Directory . Exists ( fullPath ) )
return Directory . EnumerateDirectories ( fullPath ) . Select ( GetRelativePath ) ;
}
catch ( UnauthorizedAccessException ex )
{
2019-11-19 07:52:40 +01:00
_logger . Error < PhysicalFileSystem > ( ex , "Not authorized to get directories for '{Path}'" , fullPath ) ;
2018-06-29 19:52:40 +02:00
}
catch ( DirectoryNotFoundException ex )
{
2019-11-19 07:52:40 +01:00
_logger . Error < PhysicalFileSystem > ( ex , "Directory not found for '{Path}'" , fullPath ) ;
2018-06-29 19:52:40 +02:00
}
return Enumerable . Empty < string > ( ) ;
}
/// <summary>
/// Deletes a directory.
/// </summary>
/// <param name="path">The filesystem-relative path of the directory.</param>
public void DeleteDirectory ( string path )
{
DeleteDirectory ( path , false ) ;
}
/// <summary>
/// Deletes a directory.
/// </summary>
/// <param name="path">The filesystem-relative path of the directory.</param>
/// <param name="recursive">A value indicating whether to recursively delete sub-directories.</param>
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 )
{
2019-11-19 07:52:40 +01:00
_logger . Error < PhysicalFileSystem > ( ex , "Directory not found for '{Path}'" , fullPath ) ;
2018-06-29 19:52:40 +02:00
}
}
/// <summary>
/// Gets a value indicating whether a directory exists.
/// </summary>
/// <param name="path">The filesystem-relative path of the directory.</param>
/// <returns>A value indicating whether a directory exists.</returns>
public bool DirectoryExists ( string path )
{
var fullPath = GetFullPath ( path ) ;
return Directory . Exists ( fullPath ) ;
}
/// <summary>
/// Saves a file.
/// </summary>
/// <param name="path">The filesystem-relative path of the file.</param>
/// <param name="stream">A stream containing the file data.</param>
/// <remarks>Overrides the existing file, if any.</remarks>
public void AddFile ( string path , Stream stream )
{
AddFile ( path , stream , true ) ;
}
/// <summary>
/// Saves a file.
/// </summary>
/// <param name="path">The filesystem-relative path of the file.</param>
/// <param name="stream">A stream containing the file data.</param>
/// <param name="overrideExisting">A value indicating whether to override the existing file, if any.</param>
/// <remarks>If a file exists and <paramref name="overrideExisting"/> is false, an exception is thrown.</remarks>
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
2019-01-26 09:42:14 -05:00
if ( stream . CanSeek ) // TODO: what if we cannot?
2018-06-29 19:52:40 +02:00
stream . Seek ( 0 , 0 ) ;
using ( var destination = ( Stream ) File . Create ( fullPath ) )
stream . CopyTo ( destination ) ;
}
/// <summary>
/// Gets files in a directory.
/// </summary>
/// <param name="path">The filesystem-relative path of the directory.</param>
/// <returns>The filesystem-relative path to the files in the directory.</returns>
/// <remarks>Filesystem-relative paths use forward-slashes as directory separators.</remarks>
public IEnumerable < string > GetFiles ( string path )
{
return GetFiles ( path , "*.*" ) ;
}
/// <summary>
/// Gets files in a directory.
/// </summary>
/// <param name="path">The filesystem-relative path of the directory.</param>
/// <param name="filter">A filter.</param>
/// <returns>The filesystem-relative path to the matching files in the directory.</returns>
/// <remarks>Filesystem-relative paths use forward-slashes as directory separators.</remarks>
public IEnumerable < string > GetFiles ( string path , string filter )
{
var fullPath = GetFullPath ( path ) ;
try
{
if ( Directory . Exists ( fullPath ) )
return Directory . EnumerateFiles ( fullPath , filter ) . Select ( GetRelativePath ) ;
}
catch ( UnauthorizedAccessException ex )
{
2019-11-19 07:52:40 +01:00
_logger . Error < PhysicalFileSystem > ( ex , "Not authorized to get directories for '{Path}'" , fullPath ) ;
2018-06-29 19:52:40 +02:00
}
catch ( DirectoryNotFoundException ex )
{
2019-11-19 07:52:40 +01:00
_logger . Error < PhysicalFileSystem > ( ex , "Directory not found for '{FullPath}'" , fullPath ) ;
2018-06-29 19:52:40 +02:00
}
return Enumerable . Empty < string > ( ) ;
}
/// <summary>
/// Opens a file.
/// </summary>
/// <param name="path">The filesystem-relative path to the file.</param>
/// <returns></returns>
public Stream OpenFile ( string path )
{
var fullPath = GetFullPath ( path ) ;
return File . OpenRead ( fullPath ) ;
}
/// <summary>
/// Deletes a file.
/// </summary>
/// <param name="path">The filesystem-relative path to the file.</param>
public void DeleteFile ( string path )
{
var fullPath = GetFullPath ( path ) ;
if ( File . Exists ( fullPath ) = = false )
return ;
try
{
WithRetry ( ( ) = > File . Delete ( fullPath ) ) ;
}
catch ( FileNotFoundException ex )
{
2019-11-19 07:52:40 +01:00
_logger . Error < PhysicalFileSystem > ( ex . InnerException , "DeleteFile failed with FileNotFoundException for '{Path}'" , fullPath ) ;
2018-06-29 19:52:40 +02:00
}
}
/// <summary>
/// Gets a value indicating whether a file exists.
/// </summary>
/// <param name="path">The filesystem-relative path to the file.</param>
/// <returns>A value indicating whether the file exists.</returns>
public bool FileExists ( string path )
{
var fullpath = GetFullPath ( path ) ;
return File . Exists ( fullpath ) ;
}
/// <summary>
/// Gets the filesystem-relative path of a full path or of an url.
/// </summary>
/// <param name="fullPathOrUrl">The full path or url.</param>
/// <returns>The path, relative to this filesystem's root.</returns>
/// <remarks>
/// <para>The relative path is relative to this filesystem's root, not starting with any
/// directory separator. All separators are forward-slashes.</para>
/// </remarks>
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"
2019-11-19 07:52:40 +01:00
if ( _ioHelper . PathStartsWith ( path , _rootUrl , '/' ) )
2018-06-29 19:52:40 +02:00
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"
2019-11-19 07:52:40 +01:00
if ( _ioHelper . PathStartsWith ( path , _rootPathFwd , '/' ) )
2018-06-29 19:52:40 +02:00
return path . Substring ( _rootPathFwd . Length ) . TrimStart ( '/' ) ;
// unchanged - what else?
return path ;
}
/// <summary>
/// Gets the full path.
/// </summary>
/// <param name="path">The full or filesystem-relative path.</param>
/// <returns>The full path.</returns>
/// <remarks>
/// <para>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.</para>
/// </remarks>
public string GetFullPath ( string path )
{
// normalize
var opath = path ;
path = EnsureDirectorySeparatorChar ( path ) ;
2019-01-27 01:17:32 -05:00
// FIXME: this part should go!
2018-06-29 19:52:40 +02:00
// 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
2019-11-19 07:52:40 +01:00
if ( _ioHelper . PathStartsWith ( path , _rootPath , Path . DirectorySeparatorChar ) = = false )
2018-06-29 19:52:40 +02:00
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
2019-11-19 07:52:40 +01:00
if ( _ioHelper . PathStartsWith ( path , _rootPath , Path . DirectorySeparatorChar ) )
2019-02-20 17:03:57 +01:00
{
// 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." ) ;
2018-06-29 19:52:40 +02:00
return path ;
2019-02-20 17:03:57 +01:00
}
2018-06-29 19:52:40 +02:00
// nothing prevents us to reach the file, security-wise, yet it is outside
// this filesystem's root - throw
2019-10-06 11:32:37 +02:00
throw new UnauthorizedAccessException ( "File '" + opath + "' is outside this filesystem's root." ) ;
2018-06-29 19:52:40 +02:00
}
/// <summary>
/// Gets the url.
/// </summary>
/// <param name="path">The filesystem-relative path.</param>
/// <returns>The url.</returns>
/// <remarks>All separators are forward-slashes.</remarks>
public string GetUrl ( string path )
{
path = EnsureUrlSeparatorChar ( path ) . Trim ( '/' ) ;
return _rootUrl + "/" + path ;
}
/// <summary>
/// Gets the last-modified date of a directory or file.
/// </summary>
/// <param name="path">The filesystem-relative path to the directory or the file.</param>
/// <returns>The last modified date of the directory or the file.</returns>
public DateTimeOffset GetLastModified ( string path )
{
var fullpath = GetFullPath ( path ) ;
return DirectoryExists ( fullpath )
? new DirectoryInfo ( fullpath ) . LastWriteTimeUtc
: new FileInfo ( fullpath ) . LastWriteTimeUtc ;
}
/// <summary>
/// Gets the created date of a directory or file.
/// </summary>
/// <param name="path">The filesystem-relative path to the directory or the file.</param>
/// <returns>The created date of the directory or the file.</returns>
public DateTimeOffset GetCreated ( string path )
{
var fullpath = GetFullPath ( path ) ;
return DirectoryExists ( fullpath )
? Directory . GetCreationTimeUtc ( fullpath )
: File . GetCreationTimeUtc ( fullpath ) ;
}
/// <summary>
/// Gets the size of a file.
/// </summary>
/// <param name="path">The filesystem-relative path to the file.</param>
/// <returns>The file of the size, in bytes.</returns>
/// <remarks>If the file does not exist, returns -1.</remarks>
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
}
}