From 82998362132d130a1f218fc7eab839fcddc53cd0 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Thu, 26 Aug 2021 17:05:31 +0200 Subject: [PATCH 1/7] Move provider-independent code to Umbraco.StorageProviders project --- Umbraco.StorageProviders.sln | 14 ++++++ .../AzureBlobCdnMediaUrlProviderExtensions.cs | 47 +++++++++++++++++++ .../Umbraco.StorageProviders.AzureBlob.csproj | 4 +- .../CdnMediaUrlProvider.cs | 2 +- .../CdnMediaUrlProviderOptions.cs | 2 +- .../CdnMediaUrlProviderExtensions.cs | 20 ++------ .../Properties/AssemblyInfo.cs | 3 ++ .../Umbraco.StorageProviders.csproj | 14 ++++++ src/Umbraco.StorageProviders/version.json | 7 +++ 9 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobCdnMediaUrlProviderExtensions.cs rename src/{Umbraco.StorageProviders.AzureBlob => Umbraco.StorageProviders}/CdnMediaUrlProvider.cs (98%) rename src/{Umbraco.StorageProviders.AzureBlob => Umbraco.StorageProviders}/CdnMediaUrlProviderOptions.cs (94%) rename src/{Umbraco.StorageProviders.AzureBlob => Umbraco.StorageProviders}/DependencyInjection/CdnMediaUrlProviderExtensions.cs (82%) create mode 100644 src/Umbraco.StorageProviders/Properties/AssemblyInfo.cs create mode 100644 src/Umbraco.StorageProviders/Umbraco.StorageProviders.csproj create mode 100644 src/Umbraco.StorageProviders/version.json diff --git a/Umbraco.StorageProviders.sln b/Umbraco.StorageProviders.sln index db5fc0b..ddd4e39 100644 --- a/Umbraco.StorageProviders.sln +++ b/Umbraco.StorageProviders.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Umbraco.StorageProviders", "src\Umbraco.StorageProviders\Umbraco.StorageProviders.csproj", "{5EC38982-2C9A-4D8D-AAE2-743A690FCD71}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -37,6 +39,18 @@ Global {99A3FCBE-FDC6-4580-BDB8-7D219C6D98C3}.Release|x64.Build.0 = Release|Any CPU {99A3FCBE-FDC6-4580-BDB8-7D219C6D98C3}.Release|x86.ActiveCfg = Release|Any CPU {99A3FCBE-FDC6-4580-BDB8-7D219C6D98C3}.Release|x86.Build.0 = Release|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Debug|x64.ActiveCfg = Debug|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Debug|x64.Build.0 = Debug|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Debug|x86.ActiveCfg = Debug|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Debug|x86.Build.0 = Debug|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Release|Any CPU.Build.0 = Release|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Release|x64.ActiveCfg = Release|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Release|x64.Build.0 = Release|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Release|x86.ActiveCfg = Release|Any CPU + {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobCdnMediaUrlProviderExtensions.cs b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobCdnMediaUrlProviderExtensions.cs new file mode 100644 index 0000000..bb4e930 --- /dev/null +++ b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobCdnMediaUrlProviderExtensions.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Umbraco.StorageProviders; +using Umbraco.StorageProviders.AzureBlob.IO; + +// ReSharper disable once CheckNamespace +// uses same namespace as Umbraco Core for easier discoverability +namespace Umbraco.Cms.Core.DependencyInjection +{ + /// + /// Extension methods to help registering a CDN media URL provider. + /// + public static class AzureBlobCdnMediaUrlProviderExtensions + { + /// + /// Registers and configures the . + /// + /// The . + /// + /// The . + /// + /// builder + public static IUmbracoBuilder AddAzureBlobCdnMediaUrlProvider(this IUmbracoBuilder builder) + { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + + builder.AddCdnMediaUrlProvider(); + + builder.Services.AddOptions() + .BindConfiguration("Umbraco:Storage:AzureBlob:Media:Cdn") + .Configure>( + (options, factory) => + { + var mediaOptions = factory.Create(AzureBlobFileSystemOptions.MediaFileSystemName); + if (!string.IsNullOrEmpty(mediaOptions.ContainerName)) + { + options.Url = new Uri(options.Url, mediaOptions.ContainerName); + } + } + ) + .ValidateDataAnnotations(); + + return builder; + } + } +} diff --git a/src/Umbraco.StorageProviders.AzureBlob/Umbraco.StorageProviders.AzureBlob.csproj b/src/Umbraco.StorageProviders.AzureBlob/Umbraco.StorageProviders.AzureBlob.csproj index 01b3664..89e9421 100644 --- a/src/Umbraco.StorageProviders.AzureBlob/Umbraco.StorageProviders.AzureBlob.csproj +++ b/src/Umbraco.StorageProviders.AzureBlob/Umbraco.StorageProviders.AzureBlob.csproj @@ -11,6 +11,8 @@ - + + + diff --git a/src/Umbraco.StorageProviders.AzureBlob/CdnMediaUrlProvider.cs b/src/Umbraco.StorageProviders/CdnMediaUrlProvider.cs similarity index 98% rename from src/Umbraco.StorageProviders.AzureBlob/CdnMediaUrlProvider.cs rename to src/Umbraco.StorageProviders/CdnMediaUrlProvider.cs index 5c7c55d..e11a770 100644 --- a/src/Umbraco.StorageProviders.AzureBlob/CdnMediaUrlProvider.cs +++ b/src/Umbraco.StorageProviders/CdnMediaUrlProvider.cs @@ -4,7 +4,7 @@ using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Routing; -namespace Umbraco.StorageProviders.AzureBlob +namespace Umbraco.StorageProviders { /// /// A that returns a CDN URL for a media item. diff --git a/src/Umbraco.StorageProviders.AzureBlob/CdnMediaUrlProviderOptions.cs b/src/Umbraco.StorageProviders/CdnMediaUrlProviderOptions.cs similarity index 94% rename from src/Umbraco.StorageProviders.AzureBlob/CdnMediaUrlProviderOptions.cs rename to src/Umbraco.StorageProviders/CdnMediaUrlProviderOptions.cs index cf4e596..fd9f7c1 100644 --- a/src/Umbraco.StorageProviders.AzureBlob/CdnMediaUrlProviderOptions.cs +++ b/src/Umbraco.StorageProviders/CdnMediaUrlProviderOptions.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; -namespace Umbraco.StorageProviders.AzureBlob +namespace Umbraco.StorageProviders { /// /// The CDN media URL provider options. diff --git a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/CdnMediaUrlProviderExtensions.cs b/src/Umbraco.StorageProviders/DependencyInjection/CdnMediaUrlProviderExtensions.cs similarity index 82% rename from src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/CdnMediaUrlProviderExtensions.cs rename to src/Umbraco.StorageProviders/DependencyInjection/CdnMediaUrlProviderExtensions.cs index 0d06038..fc9fd53 100644 --- a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/CdnMediaUrlProviderExtensions.cs +++ b/src/Umbraco.StorageProviders/DependencyInjection/CdnMediaUrlProviderExtensions.cs @@ -1,8 +1,6 @@ using System; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Umbraco.StorageProviders.AzureBlob; -using Umbraco.StorageProviders.AzureBlob.IO; +using Umbraco.StorageProviders; // ReSharper disable once CheckNamespace // uses same namespace as Umbraco Core for easier discoverability @@ -25,22 +23,12 @@ public static IUmbracoBuilder AddCdnMediaUrlProvider(this IUmbracoBuilder builde { if (builder == null) throw new ArgumentNullException(nameof(builder)); + builder.MediaUrlProviders().Insert(); + builder.Services.AddOptions() - .BindConfiguration("Umbraco:Storage:AzureBlob:Media:Cdn") - .Configure>( - (options, factory) => - { - var mediaOptions = factory.Create(AzureBlobFileSystemOptions.MediaFileSystemName); - if (!string.IsNullOrEmpty(mediaOptions.ContainerName)) - { - options.Url = new Uri(options.Url, mediaOptions.ContainerName); - } - } - ) + .BindConfiguration("Umbraco:Storage:Media:Cdn") .ValidateDataAnnotations(); - builder.MediaUrlProviders().Insert(); - return builder; } diff --git a/src/Umbraco.StorageProviders/Properties/AssemblyInfo.cs b/src/Umbraco.StorageProviders/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..55acad6 --- /dev/null +++ b/src/Umbraco.StorageProviders/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System; + +[assembly:CLSCompliant(false)] diff --git a/src/Umbraco.StorageProviders/Umbraco.StorageProviders.csproj b/src/Umbraco.StorageProviders/Umbraco.StorageProviders.csproj new file mode 100644 index 0000000..ef2cc7c --- /dev/null +++ b/src/Umbraco.StorageProviders/Umbraco.StorageProviders.csproj @@ -0,0 +1,14 @@ + + + net5.0 + enable + AllEnabledByDefault + true + + + + + + + + diff --git a/src/Umbraco.StorageProviders/version.json b/src/Umbraco.StorageProviders/version.json new file mode 100644 index 0000000..4330f5c --- /dev/null +++ b/src/Umbraco.StorageProviders/version.json @@ -0,0 +1,7 @@ +{ + "version": "0.1", + "nugetPackageVersion": { + "semVer": 2 + }, + "pathFilters": ["."] +} From c163db72a2ef951a8384f7c212728c35021cfecf Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Thu, 26 Aug 2021 17:09:44 +0200 Subject: [PATCH 2/7] Add FileSystemFileProvider to expose an Umbraco IFileSystems as an ASP.NET Core IFileProvider --- .../IO/FileSystemDirectoryContents.cs | 60 +++++++++++++++++++ .../IO/FileSystemDirectoryInfo.cs | 49 +++++++++++++++ .../IO/FileSystemFileInfo.cs | 49 +++++++++++++++ .../IO/FileSystemFileProvider.cs | 58 ++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 src/Umbraco.StorageProviders/IO/FileSystemDirectoryContents.cs create mode 100644 src/Umbraco.StorageProviders/IO/FileSystemDirectoryInfo.cs create mode 100644 src/Umbraco.StorageProviders/IO/FileSystemFileInfo.cs create mode 100644 src/Umbraco.StorageProviders/IO/FileSystemFileProvider.cs diff --git a/src/Umbraco.StorageProviders/IO/FileSystemDirectoryContents.cs b/src/Umbraco.StorageProviders/IO/FileSystemDirectoryContents.cs new file mode 100644 index 0000000..3bf7eec --- /dev/null +++ b/src/Umbraco.StorageProviders/IO/FileSystemDirectoryContents.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.FileProviders; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.StorageProviders.IO +{ + /// + /// Represents the directory contents in an . + /// + /// + public class FileSystemDirectoryContents : IDirectoryContents + { + private readonly IFileSystem _fileSystem; + private readonly string _subpath; + private IEnumerable _entries = null!; + + /// + public bool Exists => true; + + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The subpath. + /// + /// fileSystem + /// or + /// subpath + /// + public FileSystemDirectoryContents(IFileSystem fileSystem, string subpath) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _subpath = subpath ?? throw new ArgumentNullException(nameof(subpath)); + } + + /// + public IEnumerator GetEnumerator() + { + EnsureInitialized(); + return _entries.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + EnsureInitialized(); + return _entries.GetEnumerator(); + } + + private void EnsureInitialized() + { + _entries = _fileSystem.GetDirectories(_subpath).Select(d => new FileSystemDirectoryInfo(_fileSystem, d)) + .Union(_fileSystem.GetFiles(_subpath).Select(f => new FileSystemFileInfo(_fileSystem, f))) + .ToList(); + } + } +} diff --git a/src/Umbraco.StorageProviders/IO/FileSystemDirectoryInfo.cs b/src/Umbraco.StorageProviders/IO/FileSystemDirectoryInfo.cs new file mode 100644 index 0000000..6a5dc75 --- /dev/null +++ b/src/Umbraco.StorageProviders/IO/FileSystemDirectoryInfo.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using Microsoft.Extensions.FileProviders; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.StorageProviders.IO +{ + /// + /// Represents a directory in an . + /// + /// + public class FileSystemDirectoryInfo : IFileInfo + { + private readonly IFileSystem _fileSystem; + private readonly string _subpath; + + /// + public bool Exists => true; + + /// + public bool IsDirectory => true; + + /// + public DateTimeOffset LastModified => _fileSystem.GetLastModified(_subpath); + + /// + public long Length => -1; + + /// + public string Name => _fileSystem.GetRelativePath(_subpath); + + /// + public string PhysicalPath => null!; + + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The subpath. + public FileSystemDirectoryInfo(IFileSystem fileSystem, string subpath) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _subpath = subpath ?? throw new ArgumentNullException(nameof(subpath)); + } + + /// + public Stream CreateReadStream() => throw new InvalidOperationException("Cannot create a stream for a directory."); + } +} diff --git a/src/Umbraco.StorageProviders/IO/FileSystemFileInfo.cs b/src/Umbraco.StorageProviders/IO/FileSystemFileInfo.cs new file mode 100644 index 0000000..0e44fe4 --- /dev/null +++ b/src/Umbraco.StorageProviders/IO/FileSystemFileInfo.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using Microsoft.Extensions.FileProviders; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.StorageProviders.IO +{ + /// + /// Represents a file in an . + /// + /// + public class FileSystemFileInfo : IFileInfo + { + private readonly IFileSystem _fileSystem; + private readonly string _subpath; + + /// + public bool Exists => true; + + /// + public bool IsDirectory => false; + + /// + public DateTimeOffset LastModified => _fileSystem.GetLastModified(_subpath); + + /// + public long Length => _fileSystem.GetSize(_subpath); + + /// + public string Name => _fileSystem.GetRelativePath(_subpath); + + /// + public string PhysicalPath => null!; + + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The subpath. + public FileSystemFileInfo(IFileSystem fileSystem, string subpath) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _subpath = subpath ?? throw new ArgumentNullException(nameof(subpath)); + } + + /// + public Stream CreateReadStream() => _fileSystem.OpenFile(_subpath); + } +} diff --git a/src/Umbraco.StorageProviders/IO/FileSystemFileProvider.cs b/src/Umbraco.StorageProviders/IO/FileSystemFileProvider.cs new file mode 100644 index 0000000..9612d5c --- /dev/null +++ b/src/Umbraco.StorageProviders/IO/FileSystemFileProvider.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.StorageProviders.IO +{ + /// + /// Exposes an as an . + /// + /// + public class FileSystemFileProvider : IFileProvider + { + private readonly IFileSystem _fileSystem; + private readonly string? _pathPrefix; + + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The path prefix. + /// fileSystem + public FileSystemFileProvider(IFileSystem fileSystem, string? pathPrefix = null) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _pathPrefix = pathPrefix; + } + + /// + public IDirectoryContents GetDirectoryContents(string subpath) + { + var path = _pathPrefix + subpath; + + if (path == null || _fileSystem.DirectoryExists(path) == false) + { + return NotFoundDirectoryContents.Singleton; + } + + return new FileSystemDirectoryContents(_fileSystem, path); + } + + /// + public IFileInfo GetFileInfo(string subpath) + { + var path = _pathPrefix + subpath; + + if (path == null || _fileSystem.FileExists(path) == false) + { + return new NotFoundFileInfo(path); + } + + return new FileSystemFileInfo(_fileSystem, path); + } + + /// + public IChangeToken Watch(string filter) => NullChangeToken.Singleton; + } +} From 11143af55aadafb56d6835422b66b82f83e09746 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Thu, 26 Aug 2021 17:11:38 +0200 Subject: [PATCH 3/7] Replace custom middleware with UseStaticFiles --- .../AzureBlobFileSystemMiddleware.cs | 381 ------------------ .../AzureBlobMediaFileSystemExtensions.cs | 35 +- 2 files changed, 28 insertions(+), 388 deletions(-) delete mode 100644 src/Umbraco.StorageProviders.AzureBlob/AzureBlobFileSystemMiddleware.cs diff --git a/src/Umbraco.StorageProviders.AzureBlob/AzureBlobFileSystemMiddleware.cs b/src/Umbraco.StorageProviders.AzureBlob/AzureBlobFileSystemMiddleware.cs deleted file mode 100644 index 98969e5..0000000 --- a/src/Umbraco.StorageProviders.AzureBlob/AzureBlobFileSystemMiddleware.cs +++ /dev/null @@ -1,381 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using Azure; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; -using Microsoft.Net.Http.Headers; -using Umbraco.Cms.Core.Hosting; -using Umbraco.StorageProviders.AzureBlob.IO; - -namespace Umbraco.StorageProviders.AzureBlob -{ - /// - /// The Azure Blob file system middleware. - /// - /// - public class AzureBlobFileSystemMiddleware : IMiddleware - { - private readonly string _name; - private readonly IAzureBlobFileSystemProvider _fileSystemProvider; - private string _rootPath; - private readonly TimeSpan? _maxAge = TimeSpan.FromDays(7); - - /// - /// Creates a new instance of . - /// - /// The options. - /// The file system provider. - /// The hosting environment. - - public AzureBlobFileSystemMiddleware(IOptionsMonitor options, IAzureBlobFileSystemProvider fileSystemProvider, IHostingEnvironment hostingEnvironment) - : this(AzureBlobFileSystemOptions.MediaFileSystemName, options, fileSystemProvider, hostingEnvironment) - { } - - /// - /// Initializes a new instance of the class. - /// - /// The name. - /// The options. - /// The file system provider. - /// The hosting environment. - /// options - /// or - /// hostingEnvironment - /// or - /// name - /// or - /// fileSystemProvider - protected AzureBlobFileSystemMiddleware(string name, IOptionsMonitor options, IAzureBlobFileSystemProvider fileSystemProvider, IHostingEnvironment hostingEnvironment) - { - if (options == null) throw new ArgumentNullException(nameof(options)); - if (hostingEnvironment == null) throw new ArgumentNullException(nameof(hostingEnvironment)); - - _name = name ?? throw new ArgumentNullException(nameof(name)); - _fileSystemProvider = fileSystemProvider ?? throw new ArgumentNullException(nameof(fileSystemProvider)); - - var fileSystemOptions = options.Get(name); - _rootPath = hostingEnvironment.ToAbsolute(fileSystemOptions.VirtualPath); - - options.OnChange((o, n) => OptionsOnChange(o, n, hostingEnvironment)); - } - - /// - public Task InvokeAsync(HttpContext context, RequestDelegate next) - { - if (context == null) throw new ArgumentNullException(nameof(context)); - if (next == null) throw new ArgumentNullException(nameof(next)); - - return HandleRequestAsync(context, next); - } - - private async Task HandleRequestAsync(HttpContext context, RequestDelegate next) - { - var request = context.Request; - var response = context.Response; - - if (!context.Request.Path.StartsWithSegments(_rootPath, StringComparison.InvariantCultureIgnoreCase)) - { - await next(context).ConfigureAwait(false); - return; - } - - var blob = _fileSystemProvider.GetFileSystem(_name).GetBlobClient(request.Path); - - var blobRequestConditions = GetAccessCondition(context.Request); - - Response properties; - var ignoreRange = false; - - try - { - properties = await blob.GetPropertiesAsync(blobRequestConditions, context.RequestAborted).ConfigureAwait(false); - } - catch (RequestFailedException ex) when (ex.Status == (int) HttpStatusCode.NotFound) - { - // the blob or file does not exist, let other middleware handle it - await next(context).ConfigureAwait(false); - return; - } - catch (RequestFailedException ex) when (ex.Status == (int) HttpStatusCode.PreconditionFailed) - { - // If-Range or If-Unmodified-Since is not met - // if the resource has been modified, we need to send the whole file back with a 200 OK - // a Content-Range header is needed with the new length - ignoreRange = true; - properties = await blob.GetPropertiesAsync().ConfigureAwait(false); - response.Headers.Append("Content-Range", $"bytes */{properties.Value.ContentLength}"); - } - catch (RequestFailedException ex) when (ex.Status == (int) HttpStatusCode.NotModified) - { - // If-None-Match or If-Modified-Since is not met - // we need to pass the status code back to the client - // so it knows it can reuse the cached data - response.StatusCode = (int) HttpStatusCode.NotModified; - return; - } - // for some reason we get an internal exception type with the message - // and not a request failed with status NotModified :( - catch (Exception ex) when (ex.Message == "The condition specified using HTTP conditional header(s) is not met.") - { - if (blobRequestConditions != null - && (blobRequestConditions.IfMatch.HasValue || blobRequestConditions.IfUnmodifiedSince.HasValue)) - { - // If-Range or If-Unmodified-Since is not met - // if the resource has been modified, we need to send the whole file back with a 200 OK - // a Content-Range header is needed with the new length - ignoreRange = true; - properties = await blob.GetPropertiesAsync().ConfigureAwait(false); - response.Headers.Append("Content-Range", $"bytes */{properties.Value.ContentLength}"); - } - else - { - // If-None-Match or If-Modified-Since is not met - // we need to pass the status code back to the client - // so it knows it can reuse the cached data - response.StatusCode = (int) HttpStatusCode.NotModified; - return; - } - } - catch (TaskCanceledException) - { - // client cancelled the request before it could finish, just ignore - return; - } - - var responseHeaders = response.GetTypedHeaders(); - - responseHeaders.CacheControl = - new CacheControlHeaderValue - { - Public = true, - MustRevalidate = true, - MaxAge = _maxAge, - }; - - responseHeaders.LastModified = properties.Value.LastModified; - responseHeaders.ETag = new EntityTagHeaderValue($"\"{properties.Value.ETag}\""); - responseHeaders.Append(HeaderNames.Vary, "Accept-Encoding"); - - var requestHeaders = request.GetTypedHeaders(); - - var rangeHeader = requestHeaders.Range; - - if (!ignoreRange && rangeHeader != null) - { - if (!ValidateRanges(rangeHeader.Ranges, properties.Value.ContentLength)) - { - // no ranges could be parsed - response.Clear(); - response.StatusCode = (int) HttpStatusCode.RequestedRangeNotSatisfiable; - responseHeaders.ContentRange = new ContentRangeHeaderValue(properties.Value.ContentLength); - return; - } - - if (rangeHeader.Ranges.Count == 1) - { - var range = rangeHeader.Ranges.First(); - var contentRange = GetRangeHeader(properties, range); - - response.StatusCode = (int)HttpStatusCode.PartialContent; - response.ContentType = properties.Value.ContentType; - responseHeaders.ContentRange = contentRange; - - await DownloadRangeToStreamAsync(blob, properties, response.Body, contentRange, context.RequestAborted).ConfigureAwait(false); - return; - } - - if (rangeHeader.Ranges.Count > 1) - { - // handle multipart ranges - var boundary = Guid.NewGuid().ToString(); - response.StatusCode = (int)HttpStatusCode.PartialContent; - response.ContentType = $"multipart/byteranges; boundary={boundary}"; - - foreach (var range in rangeHeader.Ranges) - { - var contentRange = GetRangeHeader(properties, range); - - await response.WriteAsync($"--{boundary}").ConfigureAwait(false); - await response.WriteAsync("\n").ConfigureAwait(false); - await response.WriteAsync($"{HeaderNames.ContentType}: {properties.Value.ContentType}").ConfigureAwait(false); - await response.WriteAsync("\n").ConfigureAwait(false); - await response.WriteAsync($"{HeaderNames.ContentRange}: {contentRange}").ConfigureAwait(false); - await response.WriteAsync("\n").ConfigureAwait(false); - await response.WriteAsync("\n").ConfigureAwait(false); - - await DownloadRangeToStreamAsync(blob, properties, response.Body, contentRange, context.RequestAborted).ConfigureAwait(false); - await response.WriteAsync("\n").ConfigureAwait(false); - } - - await response.WriteAsync($"--{boundary}--").ConfigureAwait(false); - await response.WriteAsync("\n").ConfigureAwait(false); - return; - } - } - response.StatusCode = (int)HttpStatusCode.OK; - response.ContentType = properties.Value.ContentType; - responseHeaders.ContentLength = properties.Value.ContentLength; - responseHeaders.Append(HeaderNames.AcceptRanges, "bytes"); - - await response.StartAsync().ConfigureAwait(false); - await DownloadRangeToStreamAsync(blob, response.Body, 0L, properties.Value.ContentLength, context.RequestAborted).ConfigureAwait(false); - } - - private static BlobRequestConditions? GetAccessCondition(HttpRequest request) - { - var range = request.Headers["Range"]; - if (string.IsNullOrEmpty(range)) - { - // etag - var ifNoneMatch = request.Headers["If-None-Match"]; - if (!string.IsNullOrEmpty(ifNoneMatch)) - { - return new BlobRequestConditions - { - IfNoneMatch = new ETag(ifNoneMatch) - }; - } - - var ifModifiedSince = request.Headers["If-Modified-Since"]; - if (!string.IsNullOrEmpty(ifModifiedSince)) - { - return new BlobRequestConditions - { - IfModifiedSince = DateTimeOffset.Parse(ifModifiedSince, CultureInfo.InvariantCulture) - }; - } - } - else - { - // handle If-Range header, it can be either an etag or a date - // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range and https://tools.ietf.org/html/rfc7233#section-3.2 - var ifRange = request.Headers["If-Range"]; - if (!string.IsNullOrEmpty(ifRange)) - { - var conditions = new BlobRequestConditions(); - - if (DateTimeOffset.TryParse(ifRange, out var date)) - { - conditions.IfUnmodifiedSince = date; - } - else - { - conditions.IfMatch = new ETag(ifRange); - } - } - - var ifUnmodifiedSince = request.Headers["If-Unmodified-Since"]; - if (!string.IsNullOrEmpty(ifUnmodifiedSince)) - { - return new BlobRequestConditions - { - IfUnmodifiedSince = DateTimeOffset.Parse(ifUnmodifiedSince, CultureInfo.InvariantCulture) - }; - } - } - - return null; - } - - private static bool ValidateRanges(ICollection ranges, long length) - { - if (ranges.Count == 0) - return false; - - foreach (var range in ranges) - { - if (range.From > range.To) - return false; - if (range.To >= length) - return false; - } - - return true; - } - - private static ContentRangeHeaderValue GetRangeHeader(BlobProperties properties, RangeItemHeaderValue range) - { - var length = properties.ContentLength - 1; - - long from; - long to; - if (range.To.HasValue) - { - if (range.From.HasValue) - { - to = Math.Min(range.To.Value, length); - from = range.From.Value; - } - else - { - to = length; - from = Math.Max(properties.ContentLength - range.To.Value, 0L); - } - } - else if (range.From.HasValue) - { - to = length; - from = range.From.Value; - } - else - { - to = length; - from = 0L; - } - - return new ContentRangeHeaderValue(from, to, properties.ContentLength); - } - - private static async Task DownloadRangeToStreamAsync(BlobClient blob, BlobProperties properties, - Stream outputStream, ContentRangeHeaderValue contentRange, CancellationToken cancellationToken) - { - var offset = contentRange.From.GetValueOrDefault(0L); - var length = properties.ContentLength; - - if (contentRange.To.HasValue && contentRange.From.HasValue) - { - length = contentRange.To.Value - contentRange.From.Value + 1; - } - else if (contentRange.To.HasValue) - { - length = contentRange.To.Value + 1; - } - else if (contentRange.From.HasValue) - { - length = properties.ContentLength - contentRange.From.Value + 1; - } - - await DownloadRangeToStreamAsync(blob, outputStream, offset, length, cancellationToken).ConfigureAwait(false); - } - - private static async Task DownloadRangeToStreamAsync(BlobClient blob, Stream outputStream, - long offset, long length, CancellationToken cancellationToken) - { - try - { - if (length == 0) return; - var response = await blob.DownloadAsync(new HttpRange(offset, length), cancellationToken: cancellationToken).ConfigureAwait(false); - await response.Value.Content.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); - } - catch (TaskCanceledException) - { - // client cancelled the request before it could finish, just ignore - } - } - - private void OptionsOnChange(AzureBlobFileSystemOptions options, string name, IHostingEnvironment hostingEnvironment) - { - if (name != _name) return; - - _rootPath = hostingEnvironment.ToAbsolute(options.VirtualPath); - } - } -} diff --git a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs index 81ca4ca..b15d66f 100644 --- a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs +++ b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs @@ -1,17 +1,19 @@ using System; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; using SixLabors.ImageSharp.Web.Caching; using SixLabors.ImageSharp.Web.Providers; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Infrastructure.DependencyInjection; using Umbraco.Cms.Web.Common.ApplicationBuilder; using Umbraco.Extensions; -using Umbraco.StorageProviders.AzureBlob; using Umbraco.StorageProviders.AzureBlob.Imaging; using Umbraco.StorageProviders.AzureBlob.IO; +using Umbraco.StorageProviders.IO; // ReSharper disable once CheckNamespace // uses same namespace as Umbraco Core for easier discoverability @@ -41,8 +43,6 @@ public static IUmbracoBuilder AddAzureBlobMediaFileSystem(this IUmbracoBuilder b options.VirtualPath = globalSettingsOptions.Value.UmbracoMediaPath; }); - builder.Services.TryAddSingleton(); - // ImageSharp image provider/cache builder.Services.AddUnique(); builder.Services.AddUnique(); @@ -104,7 +104,7 @@ public static IUmbracoBuilder AddAzureBlobMediaFileSystem(this IUmbracoBuilder b } /// - /// Adds the . + /// Adds the . /// /// The . /// @@ -121,7 +121,7 @@ public static IUmbracoApplicationBuilderContext UseAzureBlobMediaFileSystem(this } /// - /// Adds the . + /// Adds the . /// /// The . /// @@ -132,7 +132,28 @@ public static IApplicationBuilder UseAzureBlobMediaFileSystem(this IApplicationB { if (app == null) throw new ArgumentNullException(nameof(app)); - app.UseMiddleware(); + var fileSystem = app.ApplicationServices.GetRequiredService().GetFileSystem(AzureBlobFileSystemOptions.MediaFileSystemName); + var options = app.ApplicationServices.GetRequiredService>().Create(AzureBlobFileSystemOptions.MediaFileSystemName); + var hostingEnvironment = app.ApplicationServices.GetRequiredService(); + + var requestPath = hostingEnvironment.ToAbsolute(options.VirtualPath); + + app.UseStaticFiles(new StaticFileOptions() + { + FileProvider = new FileSystemFileProvider(fileSystem, requestPath), + RequestPath = requestPath, + OnPrepareResponse = ctx => + { + // TODO Make this configurable + var headers = ctx.Context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue + { + Public = true, + MustRevalidate = true, + MaxAge = TimeSpan.FromDays(7) + }; + } + }); return app; } From c35a4f375feac3f070cc628962cba91bf6df99bb Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Thu, 30 Sep 2021 11:26:38 +0200 Subject: [PATCH 4/7] Use SharedOptions for middleware --- .../AzureBlobMediaFileSystemExtensions.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs index b15d66f..cb0a8ce 100644 --- a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs +++ b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs @@ -1,6 +1,7 @@ using System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; @@ -138,10 +139,14 @@ public static IApplicationBuilder UseAzureBlobMediaFileSystem(this IApplicationB var requestPath = hostingEnvironment.ToAbsolute(options.VirtualPath); - app.UseStaticFiles(new StaticFileOptions() + var sharedOptions = new SharedOptions() { FileProvider = new FileSystemFileProvider(fileSystem, requestPath), - RequestPath = requestPath, + RequestPath = requestPath + }; + + app.UseStaticFiles(new StaticFileOptions(sharedOptions) + { OnPrepareResponse = ctx => { // TODO Make this configurable From b2f08b0adcf9bb917f3beda974eed50d4049512e Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Thu, 30 Sep 2021 11:28:11 +0200 Subject: [PATCH 5/7] Add initial IStorageProvider abstraction --- .../Abstractions/IStorageProvider.cs | 49 ++++ .../FileSystemDirectoryContents.cs | 2 +- .../FileSystem}/FileSystemDirectoryInfo.cs | 2 +- .../FileSystem}/FileSystemFileInfo.cs | 2 +- .../FileSystem}/FileSystemFileProvider.cs | 31 +-- .../FileSystem/FileSystemStorageProvider.cs | 71 ++++++ .../Internal/PathUtils.cs | 52 +++++ .../Physical/PhysicalStorageProvider.cs | 216 ++++++++++++++++++ ...Umbraco.Extensions.StorageProviders.csproj | 14 ++ Umbraco.StorageProviders.sln | 14 ++ 10 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 Umbraco.Extensions.StorageProviders/Abstractions/IStorageProvider.cs rename {src/Umbraco.StorageProviders/IO => Umbraco.Extensions.StorageProviders/FileSystem}/FileSystemDirectoryContents.cs (97%) rename {src/Umbraco.StorageProviders/IO => Umbraco.Extensions.StorageProviders/FileSystem}/FileSystemDirectoryInfo.cs (97%) rename {src/Umbraco.StorageProviders/IO => Umbraco.Extensions.StorageProviders/FileSystem}/FileSystemFileInfo.cs (97%) rename {src/Umbraco.StorageProviders/IO => Umbraco.Extensions.StorageProviders/FileSystem}/FileSystemFileProvider.cs (63%) create mode 100644 Umbraco.Extensions.StorageProviders/FileSystem/FileSystemStorageProvider.cs create mode 100644 Umbraco.Extensions.StorageProviders/Internal/PathUtils.cs create mode 100644 Umbraco.Extensions.StorageProviders/Physical/PhysicalStorageProvider.cs create mode 100644 Umbraco.Extensions.StorageProviders/Umbraco.Extensions.StorageProviders.csproj diff --git a/Umbraco.Extensions.StorageProviders/Abstractions/IStorageProvider.cs b/Umbraco.Extensions.StorageProviders/Abstractions/IStorageProvider.cs new file mode 100644 index 0000000..3ebf0d8 --- /dev/null +++ b/Umbraco.Extensions.StorageProviders/Abstractions/IStorageProvider.cs @@ -0,0 +1,49 @@ +using System.IO; +using Microsoft.Extensions.FileProviders; + +namespace Umbraco.Extensions.StorageProviders +{ + /// + /// A read/write storage provider abstraction. + /// + /// + public interface IStorageProvider : IFileProvider + { + /// + /// Stores the file. + /// + /// The subpath. + /// The stream. + /// if set to true [overwrite]. + void StoreFile(string subpath, Stream stream, bool overwrite = false); + + /// + /// Moves a specified file to a new location, providing the option to specify a new file name. + /// + /// The name of the file to move.. + /// The name of the destination file. This cannot be a directory. + /// true if the destination file can be overwritten; otherwise, false. + void MoveFile(string sourceSubpath, string destinationSubpath, bool overwrite = false); + + /// + /// Copies an existing file to a new file. + /// + /// The file to copy. + /// The name of the destination file. This cannot be a directory. + /// true if the destination file can be overwritten; otherwise, false. + void CopyFile(string sourceSubpath, string destinationSubpath, bool overwrite = false); + + /// + /// Deletes the specified file. + /// + /// The name of the file to be deleted. Wildcard characters are not supported. + void DeleteFile(string subpath); + + /// + /// Deletes the specified directory and, if indicated, any subdirectories and files in the directory. + /// + /// The name of the directory to remove. + /// true to remove directories, subdirectories, and files in path; otherwise, false. + void DeleteDirectory(string subpath, bool recursive = false); + } +} diff --git a/src/Umbraco.StorageProviders/IO/FileSystemDirectoryContents.cs b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemDirectoryContents.cs similarity index 97% rename from src/Umbraco.StorageProviders/IO/FileSystemDirectoryContents.cs rename to Umbraco.Extensions.StorageProviders/FileSystem/FileSystemDirectoryContents.cs index 3bf7eec..267d1a0 100644 --- a/src/Umbraco.StorageProviders/IO/FileSystemDirectoryContents.cs +++ b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemDirectoryContents.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.FileProviders; using Umbraco.Cms.Core.IO; -namespace Umbraco.StorageProviders.IO +namespace Umbraco.Extensions.StorageProviders { /// /// Represents the directory contents in an . diff --git a/src/Umbraco.StorageProviders/IO/FileSystemDirectoryInfo.cs b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs similarity index 97% rename from src/Umbraco.StorageProviders/IO/FileSystemDirectoryInfo.cs rename to Umbraco.Extensions.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs index 6a5dc75..4236cf6 100644 --- a/src/Umbraco.StorageProviders/IO/FileSystemDirectoryInfo.cs +++ b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.FileProviders; using Umbraco.Cms.Core.IO; -namespace Umbraco.StorageProviders.IO +namespace Umbraco.Extensions.StorageProviders { /// /// Represents a directory in an . diff --git a/src/Umbraco.StorageProviders/IO/FileSystemFileInfo.cs b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemFileInfo.cs similarity index 97% rename from src/Umbraco.StorageProviders/IO/FileSystemFileInfo.cs rename to Umbraco.Extensions.StorageProviders/FileSystem/FileSystemFileInfo.cs index 0e44fe4..5af0006 100644 --- a/src/Umbraco.StorageProviders/IO/FileSystemFileInfo.cs +++ b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemFileInfo.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.FileProviders; using Umbraco.Cms.Core.IO; -namespace Umbraco.StorageProviders.IO +namespace Umbraco.Extensions.StorageProviders { /// /// Represents a file in an . diff --git a/src/Umbraco.StorageProviders/IO/FileSystemFileProvider.cs b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemFileProvider.cs similarity index 63% rename from src/Umbraco.StorageProviders/IO/FileSystemFileProvider.cs rename to Umbraco.Extensions.StorageProviders/FileSystem/FileSystemFileProvider.cs index 9612d5c..d78b5b8 100644 --- a/src/Umbraco.StorageProviders/IO/FileSystemFileProvider.cs +++ b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemFileProvider.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Primitives; using Umbraco.Cms.Core.IO; -namespace Umbraco.StorageProviders.IO +namespace Umbraco.Extensions.StorageProviders { /// /// Exposes an as an . @@ -11,8 +11,15 @@ namespace Umbraco.StorageProviders.IO /// public class FileSystemFileProvider : IFileProvider { - private readonly IFileSystem _fileSystem; - private readonly string? _pathPrefix; + /// + /// The file system. + /// + protected IFileSystem FileSystem { get; } + + /// + /// The path prefix. + /// + protected string? PathPrefix { get; } /// /// Initializes a new instance of the class. @@ -22,34 +29,32 @@ public class FileSystemFileProvider : IFileProvider /// fileSystem public FileSystemFileProvider(IFileSystem fileSystem, string? pathPrefix = null) { - _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); - _pathPrefix = pathPrefix; + FileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + PathPrefix = pathPrefix; } /// public IDirectoryContents GetDirectoryContents(string subpath) { - var path = _pathPrefix + subpath; - - if (path == null || _fileSystem.DirectoryExists(path) == false) + var path = PathPrefix + subpath; + if (path == null || !FileSystem.DirectoryExists(path)) { return NotFoundDirectoryContents.Singleton; } - return new FileSystemDirectoryContents(_fileSystem, path); + return new FileSystemDirectoryContents(FileSystem, path); } /// public IFileInfo GetFileInfo(string subpath) { - var path = _pathPrefix + subpath; - - if (path == null || _fileSystem.FileExists(path) == false) + var path = PathPrefix + subpath; + if (path == null || !FileSystem.FileExists(path)) { return new NotFoundFileInfo(path); } - return new FileSystemFileInfo(_fileSystem, path); + return new FileSystemFileInfo(FileSystem, path); } /// diff --git a/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemStorageProvider.cs b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemStorageProvider.cs new file mode 100644 index 0000000..4026b2c --- /dev/null +++ b/Umbraco.Extensions.StorageProviders/FileSystem/FileSystemStorageProvider.cs @@ -0,0 +1,71 @@ +using System.IO; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Extensions.StorageProviders +{ + /// + /// Exposes an as an . + /// + /// + /// + public class FileSystemStorageProvider : FileSystemFileProvider, IStorageProvider + { + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The path prefix. + public FileSystemStorageProvider(IFileSystem fileSystem, string? pathPrefix = null) + : base(fileSystem, pathPrefix) + { } + + /// + public void CopyFile(string sourceSubpath, string destinationSubpath, bool overwrite = false) + { + var sourcePath = PathPrefix + sourceSubpath; + var destinationPath = PathPrefix + destinationSubpath; + if (sourcePath != null && destinationPath != null) + { + using var stream = FileSystem.OpenFile(sourcePath); + FileSystem.AddFile(destinationPath, stream, overwrite); + } + } + + /// + public void DeleteDirectory(string subpath, bool recursive = false) + { + var path = PathPrefix + subpath; + if (path != null) + { + FileSystem.DeleteDirectory(path, recursive); + } + } + + /// + public void DeleteFile(string subpath) + { + var path = PathPrefix + subpath; + if (path != null) + { + FileSystem.DeleteFile(path); + } + } + + /// + public void MoveFile(string sourceSubpath, string destinationSubpath, bool overwrite = false) + { + CopyFile(sourceSubpath, destinationSubpath, overwrite); + DeleteFile(sourceSubpath); + } + + /// + public void StoreFile(string subpath, Stream stream, bool overwrite = false) + { + var path = PathPrefix + subpath; + if (path != null) + { + FileSystem.AddFile(path, stream, overwrite); + } + } + } +} diff --git a/Umbraco.Extensions.StorageProviders/Internal/PathUtils.cs b/Umbraco.Extensions.StorageProviders/Internal/PathUtils.cs new file mode 100644 index 0000000..8c782ac --- /dev/null +++ b/Umbraco.Extensions.StorageProviders/Internal/PathUtils.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Primitives; +using System.IO; +using System.Linq; + +namespace Umbraco.Extensions.StorageProviders.Internal +{ + internal static class PathUtils + { + private static readonly char[] _invalidFileNameChars = Path.GetInvalidFileNameChars() + .Where(c => c != Path.DirectorySeparatorChar && c != Path.AltDirectorySeparatorChar).ToArray(); + + private static readonly char[] _pathSeparators = new[] + { + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + }; + + internal static bool HasInvalidPathChars(string path) + { + return path.IndexOfAny(_invalidFileNameChars) != -1; + } + + internal static bool PathNavigatesAboveRoot(string path) + { + var tokenizer = new StringTokenizer(path, _pathSeparators); + int depth = 0; + + foreach (StringSegment segment in tokenizer) + { + if (segment.Equals(".") || segment.Equals("")) + { + continue; + } + else if (segment.Equals("..")) + { + depth--; + + if (depth == -1) + { + return true; + } + } + else + { + depth++; + } + } + + return false; + } + } +} diff --git a/Umbraco.Extensions.StorageProviders/Physical/PhysicalStorageProvider.cs b/Umbraco.Extensions.StorageProviders/Physical/PhysicalStorageProvider.cs new file mode 100644 index 0000000..fc9c929 --- /dev/null +++ b/Umbraco.Extensions.StorageProviders/Physical/PhysicalStorageProvider.cs @@ -0,0 +1,216 @@ +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; +using System; +using System.IO; +using Umbraco.Extensions.StorageProviders.Internal; + +namespace Umbraco.Extensions.StorageProviders +{ + /// + /// Storage provider for the on-disk file system. + /// + /// + /// + public class PhysicalStorageProvider : PhysicalFileProvider, IStorageProvider + { + private static readonly char[] _pathSeparators = new[] + { + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + }; + + /// + /// Initializes a new instance of the class. + /// + /// The root directory. This should be an absolute path. + public PhysicalStorageProvider(string root) + : base(root) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The root directory. This should be an absolute path. + /// Specifies which files or directories are excluded. + public PhysicalStorageProvider(string root, ExclusionFilters filters) + : base(root, filters) + { } + + /// + public void CopyFile(string sourceSubpath, string destinationSubpath, bool overwrite = false) + { + if (string.IsNullOrEmpty(sourceSubpath) || PathUtils.HasInvalidPathChars(sourceSubpath) || + string.IsNullOrEmpty(destinationSubpath) || PathUtils.HasInvalidPathChars(destinationSubpath)) + { + return; + } + + // Relative paths starting with leading slashes are okay + sourceSubpath = sourceSubpath.TrimStart(_pathSeparators); + destinationSubpath = destinationSubpath.TrimStart(_pathSeparators); + + // Absolute paths not permitted. + if (Path.IsPathRooted(sourceSubpath) || Path.IsPathRooted(destinationSubpath)) + { + return; + } + + string? sourceFullPath = GetFullPath(sourceSubpath); + if (sourceFullPath == null) + { + return; + } + + string? destinationFullPath = GetFullPath(destinationSubpath); + if (destinationFullPath == null) + { + return; + } + + File.Copy(sourceFullPath, destinationFullPath, overwrite); + } + + /// + public void DeleteDirectory(string subpath, bool recursive = false) + { + if (subpath == null || PathUtils.HasInvalidPathChars(subpath)) + { + return; + } + + // Relative paths starting with leading slashes are okay + subpath = subpath.TrimStart(_pathSeparators); + + // Absolute paths not permitted. + if (Path.IsPathRooted(subpath)) + { + return; + } + + string? fullPath = GetFullPath(subpath); + if (fullPath == null || !Directory.Exists(fullPath)) + { + return; + } + + Directory.Delete(fullPath, recursive); + } + + /// + public void DeleteFile(string subpath) + { + if (string.IsNullOrEmpty(subpath) || PathUtils.HasInvalidPathChars(subpath)) + { + return; + } + + // Relative paths starting with leading slashes are okay + subpath = subpath.TrimStart(_pathSeparators); + + // Absolute paths not permitted. + if (Path.IsPathRooted(subpath)) + { + return; + } + + string? fullPath = GetFullPath(subpath); + if (fullPath == null || !Directory.Exists(fullPath)) + { + return; + } + + File.Delete(fullPath); + } + + /// + public void MoveFile(string sourceSubpath, string destinationSubpath, bool overwrite = false) + { + if (string.IsNullOrEmpty(sourceSubpath) || PathUtils.HasInvalidPathChars(sourceSubpath) || + string.IsNullOrEmpty(destinationSubpath) || PathUtils.HasInvalidPathChars(destinationSubpath)) + { + return; + } + + // Relative paths starting with leading slashes are okay + sourceSubpath = sourceSubpath.TrimStart(_pathSeparators); + destinationSubpath = destinationSubpath.TrimStart(_pathSeparators); + + // Absolute paths not permitted. + if (Path.IsPathRooted(sourceSubpath) || Path.IsPathRooted(destinationSubpath)) + { + return; + } + + string? sourceFullPath = GetFullPath(sourceSubpath); + if (sourceFullPath == null) + { + return; + } + + string? destinationFullPath = GetFullPath(destinationSubpath); + if (destinationFullPath == null) + { + return; + } + + File.Move(sourceFullPath, destinationFullPath, overwrite); + } + + /// + public void StoreFile(string subpath, Stream stream, bool overwrite = false) + { + if (string.IsNullOrEmpty(subpath) || PathUtils.HasInvalidPathChars(subpath) || stream is null) + { + return; + } + + // Relative paths starting with leading slashes are okay + subpath = subpath.TrimStart(_pathSeparators); + + // Absolute paths not permitted. + if (Path.IsPathRooted(subpath)) + { + return; + } + + string? fullPath = GetFullPath(subpath); + if (fullPath == null || !Directory.Exists(fullPath)) + { + return; + } + + using var fileStream = new FileStream(fullPath, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write); + stream.CopyTo(fileStream); + } + + private string? GetFullPath(string path) + { + if (PathUtils.PathNavigatesAboveRoot(path)) + { + return null; + } + + string fullPath; + try + { + fullPath = Path.GetFullPath(Path.Combine(Root, path)); + } + catch + { + return null; + } + + if (!IsUnderneathRoot(fullPath)) + { + return null; + } + + return fullPath; + } + + private bool IsUnderneathRoot(string fullPath) + { + return fullPath.StartsWith(Root, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Umbraco.Extensions.StorageProviders/Umbraco.Extensions.StorageProviders.csproj b/Umbraco.Extensions.StorageProviders/Umbraco.Extensions.StorageProviders.csproj new file mode 100644 index 0000000..6abf5df --- /dev/null +++ b/Umbraco.Extensions.StorageProviders/Umbraco.Extensions.StorageProviders.csproj @@ -0,0 +1,14 @@ + + + net5.0 + enable + AllEnabledByDefault + true + + + + + + + + diff --git a/Umbraco.StorageProviders.sln b/Umbraco.StorageProviders.sln index ddd4e39..a4196c7 100644 --- a/Umbraco.StorageProviders.sln +++ b/Umbraco.StorageProviders.sln @@ -17,6 +17,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Umbraco.StorageProviders", "src\Umbraco.StorageProviders\Umbraco.StorageProviders.csproj", "{5EC38982-2C9A-4D8D-AAE2-743A690FCD71}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.Extensions.StorageProviders", "Umbraco.Extensions.StorageProviders\Umbraco.Extensions.StorageProviders.csproj", "{505FBC05-8EC0-47B9-9051-5704D9329091}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,6 +53,18 @@ Global {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Release|x64.Build.0 = Release|Any CPU {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Release|x86.ActiveCfg = Release|Any CPU {5EC38982-2C9A-4D8D-AAE2-743A690FCD71}.Release|x86.Build.0 = Release|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Debug|Any CPU.Build.0 = Debug|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Debug|x64.ActiveCfg = Debug|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Debug|x64.Build.0 = Debug|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Debug|x86.ActiveCfg = Debug|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Debug|x86.Build.0 = Debug|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Release|Any CPU.ActiveCfg = Release|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Release|Any CPU.Build.0 = Release|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Release|x64.ActiveCfg = Release|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Release|x64.Build.0 = Release|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Release|x86.ActiveCfg = Release|Any CPU + {505FBC05-8EC0-47B9-9051-5704D9329091}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From e91cd0a65634f7cecabf34a817c6c5ccc11b9048 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Thu, 30 Sep 2021 11:28:11 +0200 Subject: [PATCH 6/7] Add initial IStorageProvider abstraction --- .../Abstractions/IStorageProvider.cs | 95 ++++ .../Abstractions/StorageProviderBase.cs | 150 ++++++ .../FileSystem/FileSystemDirectoryContents.cs | 60 +++ .../FileSystem/FileSystemDirectoryInfo.cs | 49 ++ .../FileSystem/FileSystemFileInfo.cs | 49 ++ .../FileSystem/FileSystemFileProvider.cs | 64 +++ .../FileSystem/FileSystemStorageProvider.cs | 71 +++ .../Internal/PathUtils.cs | 52 ++ .../Physical/PhysicalStorageProvider.cs | 445 ++++++++++++++++++ 9 files changed, 1035 insertions(+) create mode 100644 src/Umbraco.StorageProviders/Abstractions/IStorageProvider.cs create mode 100644 src/Umbraco.StorageProviders/Abstractions/StorageProviderBase.cs create mode 100644 src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryContents.cs create mode 100644 src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs create mode 100644 src/Umbraco.StorageProviders/FileSystem/FileSystemFileInfo.cs create mode 100644 src/Umbraco.StorageProviders/FileSystem/FileSystemFileProvider.cs create mode 100644 src/Umbraco.StorageProviders/FileSystem/FileSystemStorageProvider.cs create mode 100644 src/Umbraco.StorageProviders/Internal/PathUtils.cs create mode 100644 src/Umbraco.StorageProviders/Physical/PhysicalStorageProvider.cs diff --git a/src/Umbraco.StorageProviders/Abstractions/IStorageProvider.cs b/src/Umbraco.StorageProviders/Abstractions/IStorageProvider.cs new file mode 100644 index 0000000..b694d92 --- /dev/null +++ b/src/Umbraco.StorageProviders/Abstractions/IStorageProvider.cs @@ -0,0 +1,95 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.FileProviders; + +namespace Umbraco.StorageProviders +{ + /// + /// A read/write storage provider abstraction. + /// + /// + public interface IStorageProvider : IFileProvider + { + /// + /// Create a writable stream to store file contents. Caller should dispose the stream when complete. + /// + /// The path/name of the file. + /// true if the file can be overwritten; otherwise, false. + /// + /// The file stream. + /// + Stream? CreateWriteStream(string subpath, bool overwrite = false); + + /// + /// Moves the specified file to a new location, providing the option to specify a new file name. + /// + /// The path/name of the source file. + /// The path/name of the destination file. This cannot be a directory. + /// true if the destination file can be overwritten; otherwise, false. + /// The cancellation token. + /// + /// Returns a value indicating whether the operation succeeded. + /// + Task MoveAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default); + + /// + /// Moves the specified directory to a new location. + /// + /// The path/name of the source directory. + /// The path/name of the destination directory. + /// true if files in the destination directory can be overwritten; otherwise, false. + /// true to recursively move subdirectories and files; otherwise, false. + /// The cancellation token. + /// + /// Returns a value indicating whether the operation succeeded. + /// + Task MoveDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default); + + /// + /// Copies an existing file to a new file, providing the option to specify a new file name. + /// + /// The path/name of the source directory. + /// The path/name of the destination file. This cannot be a directory. + /// true if the destination file can be overwritten; otherwise, false. + /// The cancellation token. + /// + /// Returns a value indicating whether the operation succeeded. + /// + Task CopyAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default); + + /// + /// Copies an existing directory to a new directory. + /// + /// The path/name of the source directory. + /// The path/name of the destination directory. + /// true if files in the destination directory can be overwritten; otherwise, false. + /// true to recursively copy subdirectories and files; otherwise, false. + /// The cancellation token. + /// + /// Returns a value indicating whether the operation succeeded. + /// + Task CopyDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default); + + /// + /// Deletes the specified file. + /// + /// The path/name of the file. + /// The cancellation token. + /// + /// Returns a value indicating whether the operation succeeded. + /// + Task DeleteAsync(string subpath, CancellationToken cancellationToken = default); + + /// + /// Deletes the specified directory. + /// + /// The path/name of the directory. + /// true to recursively remove subdirectories and files; otherwise, false. + /// The cancellation token. + /// + /// Returns a value indicating whether the operation succeeded. + /// + Task DeleteDirectoryAsync(string subpath, bool recursive = false, CancellationToken cancellationToken = default); + } +} diff --git a/src/Umbraco.StorageProviders/Abstractions/StorageProviderBase.cs b/src/Umbraco.StorageProviders/Abstractions/StorageProviderBase.cs new file mode 100644 index 0000000..03a3045 --- /dev/null +++ b/src/Umbraco.StorageProviders/Abstractions/StorageProviderBase.cs @@ -0,0 +1,150 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace Umbraco.StorageProviders +{ + /// + /// Provides a generic implementation for most storage provider actions. + /// + /// + /// The default implementation where moving is based on copy/delete and copying requires reading/writing the file contents. + /// + /// + public abstract class StorageProviderBase : IStorageProvider + { + /// + public abstract IDirectoryContents GetDirectoryContents(string subpath); + + /// + public abstract IFileInfo GetFileInfo(string subpath); + + /// + public virtual IChangeToken Watch(string filter) => NullChangeToken.Singleton; + + /// + public abstract Stream CreateWriteStream(string subpath, bool overwrite = false); + + /// + public virtual async Task MoveAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default) + => await CopyAsync(sourceSubpath, destinationSubpath, overwrite, cancellationToken).ConfigureAwait(false) && + await DeleteAsync(sourceSubpath, cancellationToken).ConfigureAwait(false); + + /// + public virtual async Task MoveDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default) + { + var sourceDirectoryContents = GetDirectoryContents(sourceSubpath); + if (sourceDirectoryContents is null || sourceDirectoryContents.Exists is false) + { + return false; + } + + foreach (var sourceFileInfo in sourceDirectoryContents) + { + var sourcePath = CombinePath(sourceSubpath, sourceFileInfo.Name); + var destinationPath = CombinePath(destinationSubpath, sourceFileInfo.Name); + + if (sourceFileInfo.IsDirectory is false) + { + await MoveAsync(sourcePath, destinationPath, overwrite, cancellationToken).ConfigureAwait(false); + } + else if (recursive) + { + await MoveDirectoryAsync(sourcePath, destinationPath, overwrite, recursive, cancellationToken).ConfigureAwait(false); + } + } + + return true; + } + + /// + public virtual async Task CopyAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default) + => await CopyAsync(GetFileInfo(sourceSubpath), destinationSubpath, overwrite, cancellationToken).ConfigureAwait(false); + + /// + protected virtual async Task CopyAsync(IFileInfo sourceFileInfo, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default) + { + if (sourceFileInfo is null || sourceFileInfo.Exists is false || sourceFileInfo.IsDirectory) + { + return false; + } + + using (var source = sourceFileInfo.CreateReadStream()) + using (var destination = CreateWriteStream(destinationSubpath, overwrite)) + { + await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false); + } + + return true; + } + + /// + public virtual async Task CopyDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default) + { + var sourceDirectoryContents = GetDirectoryContents(sourceSubpath); + if (sourceDirectoryContents is null || sourceDirectoryContents.Exists is false) + { + return false; + } + + foreach (var sourceFileInfo in sourceDirectoryContents) + { + var destinationPath = CombinePath(destinationSubpath, sourceFileInfo.Name); + + if (sourceFileInfo.IsDirectory is false) + { + await CopyAsync(sourceFileInfo, destinationPath, overwrite, cancellationToken).ConfigureAwait(false); + } + else if (recursive) + { + var sourcePath = CombinePath(sourceSubpath, sourceFileInfo.Name); + + await CopyDirectoryAsync(sourcePath, destinationPath, overwrite, recursive, cancellationToken).ConfigureAwait(false); + } + } + + return true; + } + + /// + public abstract Task DeleteAsync(string subpath, CancellationToken cancellationToken = default); + + /// + public virtual async Task DeleteDirectoryAsync(string subpath, bool recursive = false, CancellationToken cancellationToken = default) + { + var directoryContents = GetDirectoryContents(subpath); + if (directoryContents is null || directoryContents.Exists is false) + { + return false; + } + + foreach (var fileInfo in directoryContents) + { + var path = CombinePath(subpath, fileInfo.Name); + + if (fileInfo.IsDirectory is false) + { + await DeleteAsync(path, cancellationToken).ConfigureAwait(false); + } + else if (recursive) + { + await DeleteDirectoryAsync(path, recursive, cancellationToken).ConfigureAwait(false); + } + } + + return true; + } + + /// + /// Combines the path and file name. + /// + /// The path. + /// The file name. + /// + /// The combined path and file name. + /// + protected virtual string CombinePath(string path, string fileName) => Path.Combine(path, fileName); + } +} diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryContents.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryContents.cs new file mode 100644 index 0000000..75ab1ff --- /dev/null +++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryContents.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.FileProviders; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.StorageProviders.FileSystem +{ + /// + /// Represents the directory contents in an . + /// + /// + public class FileSystemDirectoryContents : IDirectoryContents + { + private readonly IFileSystem _fileSystem; + private readonly string _subpath; + private IEnumerable _entries = null!; + + /// + public bool Exists => true; + + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The subpath. + /// + /// fileSystem + /// or + /// subpath + /// + public FileSystemDirectoryContents(IFileSystem fileSystem, string subpath) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _subpath = subpath ?? throw new ArgumentNullException(nameof(subpath)); + } + + /// + public IEnumerator GetEnumerator() + { + EnsureInitialized(); + return _entries.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + EnsureInitialized(); + return _entries.GetEnumerator(); + } + + private void EnsureInitialized() + { + _entries = _fileSystem.GetDirectories(_subpath).Select(d => new FileSystemDirectoryInfo(_fileSystem, d)) + .Union(_fileSystem.GetFiles(_subpath).Select(f => new FileSystemFileInfo(_fileSystem, f))) + .ToList(); + } + } +} diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs new file mode 100644 index 0000000..72e5f5d --- /dev/null +++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using Microsoft.Extensions.FileProviders; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.StorageProviders.FileSystem +{ + /// + /// Represents a directory in an . + /// + /// + public class FileSystemDirectoryInfo : IFileInfo + { + private readonly IFileSystem _fileSystem; + private readonly string _subpath; + + /// + public bool Exists => true; + + /// + public bool IsDirectory => true; + + /// + public DateTimeOffset LastModified => _fileSystem.GetLastModified(_subpath); + + /// + public long Length => -1; + + /// + public string Name => _fileSystem.GetRelativePath(_subpath); + + /// + public string PhysicalPath => null!; + + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The subpath. + public FileSystemDirectoryInfo(IFileSystem fileSystem, string subpath) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _subpath = subpath ?? throw new ArgumentNullException(nameof(subpath)); + } + + /// + public Stream CreateReadStream() => throw new InvalidOperationException("Cannot create a stream for a directory."); + } +} diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemFileInfo.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemFileInfo.cs new file mode 100644 index 0000000..be3d7a9 --- /dev/null +++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemFileInfo.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using Microsoft.Extensions.FileProviders; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.StorageProviders.FileSystem +{ + /// + /// Represents a file in an . + /// + /// + public class FileSystemFileInfo : IFileInfo + { + private readonly IFileSystem _fileSystem; + private readonly string _subpath; + + /// + public bool Exists => true; + + /// + public bool IsDirectory => false; + + /// + public DateTimeOffset LastModified => _fileSystem.GetLastModified(_subpath); + + /// + public long Length => _fileSystem.GetSize(_subpath); + + /// + public string Name => _fileSystem.GetRelativePath(_subpath); + + /// + public string PhysicalPath => null!; + + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The subpath. + public FileSystemFileInfo(IFileSystem fileSystem, string subpath) + { + _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + _subpath = subpath ?? throw new ArgumentNullException(nameof(subpath)); + } + + /// + public Stream CreateReadStream() => _fileSystem.OpenFile(_subpath); + } +} diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemFileProvider.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemFileProvider.cs new file mode 100644 index 0000000..38ccb5c --- /dev/null +++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemFileProvider.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; +using Umbraco.Cms.Core.IO; +using Umbraco.StorageProviders.FileSystem; + +namespace Umbraco.StorageProviders +{ + /// + /// Exposes an as an . + /// + /// + public class FileSystemFileProvider : IFileProvider + { + /// + /// The file system. + /// + protected IFileSystem FileSystem { get; } + + /// + /// The path prefix. + /// + protected string? PathPrefix { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The path prefix. + /// fileSystem + public FileSystemFileProvider(IFileSystem fileSystem, string? pathPrefix = null) + { + FileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + PathPrefix = pathPrefix; + } + + /// + public IDirectoryContents GetDirectoryContents(string subpath) + { + var path = PathPrefix + subpath; + if (path == null || !FileSystem.DirectoryExists(path)) + { + return NotFoundDirectoryContents.Singleton; + } + + return new FileSystemDirectoryContents(FileSystem, path); + } + + /// + public IFileInfo GetFileInfo(string subpath) + { + var path = PathPrefix + subpath; + if (path == null || !FileSystem.FileExists(path)) + { + return new NotFoundFileInfo(path); + } + + return new FileSystemFileInfo(FileSystem, path); + } + + /// + public IChangeToken Watch(string filter) => NullChangeToken.Singleton; + } +} diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemStorageProvider.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemStorageProvider.cs new file mode 100644 index 0000000..e99f94a --- /dev/null +++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemStorageProvider.cs @@ -0,0 +1,71 @@ +using System.IO; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.StorageProviders +{ + /// + /// Exposes an as an . + /// + /// + /// + public class FileSystemStorageProvider : FileSystemFileProvider, IStorageProvider + { + /// + /// Initializes a new instance of the class. + /// + /// The file system. + /// The path prefix. + public FileSystemStorageProvider(IFileSystem fileSystem, string? pathPrefix = null) + : base(fileSystem, pathPrefix) + { } + + /// + public void CopyFile(string sourceSubpath, string destinationSubpath, bool overwrite = false) + { + var sourcePath = PathPrefix + sourceSubpath; + var destinationPath = PathPrefix + destinationSubpath; + if (sourcePath != null && destinationPath != null) + { + using var stream = FileSystem.OpenFile(sourcePath); + FileSystem.AddFile(destinationPath, stream, overwrite); + } + } + + /// + public void DeleteDirectory(string subpath, bool recursive = false) + { + var path = PathPrefix + subpath; + if (path != null) + { + FileSystem.DeleteDirectory(path, recursive); + } + } + + /// + public void DeleteFile(string subpath) + { + var path = PathPrefix + subpath; + if (path != null) + { + FileSystem.DeleteFile(path); + } + } + + /// + public void MoveFile(string sourceSubpath, string destinationSubpath, bool overwrite = false) + { + CopyFile(sourceSubpath, destinationSubpath, overwrite); + DeleteFile(sourceSubpath); + } + + /// + public void StoreFile(string subpath, Stream stream, bool overwrite = false) + { + var path = PathPrefix + subpath; + if (path != null) + { + FileSystem.AddFile(path, stream, overwrite); + } + } + } +} diff --git a/src/Umbraco.StorageProviders/Internal/PathUtils.cs b/src/Umbraco.StorageProviders/Internal/PathUtils.cs new file mode 100644 index 0000000..2e50859 --- /dev/null +++ b/src/Umbraco.StorageProviders/Internal/PathUtils.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Primitives; +using System.IO; +using System.Linq; + +namespace Umbraco.StorageProviders.Internal +{ + internal static class PathUtils + { + private static readonly char[] _invalidFileNameChars = Path.GetInvalidFileNameChars() + .Where(c => c != Path.DirectorySeparatorChar && c != Path.AltDirectorySeparatorChar).ToArray(); + + private static readonly char[] _pathSeparators = new[] + { + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + }; + + internal static bool HasInvalidPathChars(string path) + { + return path.IndexOfAny(_invalidFileNameChars) != -1; + } + + internal static bool PathNavigatesAboveRoot(string path) + { + var tokenizer = new StringTokenizer(path, _pathSeparators); + int depth = 0; + + foreach (StringSegment segment in tokenizer) + { + if (segment.Equals(".") || segment.Equals("")) + { + continue; + } + else if (segment.Equals("..")) + { + depth--; + + if (depth == -1) + { + return true; + } + } + else + { + depth++; + } + } + + return false; + } + } +} diff --git a/src/Umbraco.StorageProviders/Physical/PhysicalStorageProvider.cs b/src/Umbraco.StorageProviders/Physical/PhysicalStorageProvider.cs new file mode 100644 index 0000000..f8c16f4 --- /dev/null +++ b/src/Umbraco.StorageProviders/Physical/PhysicalStorageProvider.cs @@ -0,0 +1,445 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; +using Umbraco.StorageProviders.Internal; + +namespace Umbraco.StorageProviders +{ + /// + /// Storage provider for the on-disk file system. + /// + /// + /// + public class PhysicalStorageProvider : PhysicalFileProvider, IStorageProvider + { + private static readonly char[] _pathSeparators = new[] + { + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + }; + + /// + /// Initializes a new instance of the class. + /// + /// The root directory. This should be an absolute path. + public PhysicalStorageProvider(string root) + : base(root) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The root directory. This should be an absolute path. + /// Specifies which files or directories are excluded. + public PhysicalStorageProvider(string root, ExclusionFilters filters) + : base(root, filters) + { } + + /// + public Stream? CreateWriteStream(string subpath, bool overwrite = false) + { + if (TryGetFullPath(subpath, out string fullPath)) + { + EnsureDirectory(fullPath); + + return File.Open(fullPath, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write, FileShare.None); + } + + return null; + } + + /// + public Task MoveAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default) + { + var fileInfo = GetFileInfo(sourceSubpath); + if (fileInfo.Exists && + fileInfo.PhysicalPath is string sourceFullPath && + TryGetFullPath(destinationSubpath, out string destinationFullPath)) + { + EnsureDirectory(destinationFullPath); + + File.Move(sourceFullPath, destinationFullPath, overwrite); + + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + + /// + public Task MoveDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default) + { + var directoryContents = GetDirectoryContents(sourceSubpath); + if (directoryContents.Exists && + TryGetFullPath(sourceSubpath, out string sourceFullPath) && + TryGetFullPath(destinationSubpath, out string destinationFullPath)) + { + EnsureDirectory(destinationFullPath); + + bool deleteSourceDirectory = true; + foreach (var fileInfo in directoryContents) + { + if (fileInfo.PhysicalPath is string sourceFilePath) + { + var relativeSourcePath = Path.GetRelativePath(sourceFullPath, sourceFilePath); + var destinationFilePath = Path.Combine(destinationFullPath, ); + + if (!fileInfo.IsDirectory) + { + File.Move(sourceFilePath, destinationFilePath, overwrite); + } + else if (recursive) + { + MoveDirectoryAsync(sourceFilePath, destinationFilePath) + } + else + { + // We have subdirectories, but aren't recursively moving them, so can't delete source directory + deleteSourceDirectory = false; + } + } + + + } + + var directoryInfo = new DirectoryInfo(sourceFullPath); + if (directoryInfo.Exists) + { + EnsureDirectory(destinationSubpath); + + // Move files + foreach (var fileInfo in directoryInfo.EnumerateFiles()) + { + var destinationFilePath = Path.Combine(destinationFullPath, Path.GetRelativePath(sourceFullPath, fileInfo.FullName)); + + fileInfo.MoveTo(destinationFilePath, overwrite); + } + + if (recursive) + { + foreach (var subDirectoryInfo in directoryInfo.EnumerateDirectories()) + { + + } + } + + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + /// + public Task CopyAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default) + { + if (TryGetFullPath(sourceSubpath, out string sourceFullPath) && + TryGetFullPath(destinationSubpath, out string destinationFullPath)) + { + var fileInfo = new FileInfo(sourceFullPath); + if (fileInfo.Exists) + { + EnsureDirectory(destinationSubpath); + + fileInfo.CopyTo(destinationFullPath, overwrite); + + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + /// + public Task CopyDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default) + { + if (TryGetFullPath(sourceSubpath, out string sourceFullPath) && + TryGetFullPath(destinationSubpath, out string destinationFullPath)) + { + var directoryInfo = new DirectoryInfo(sourceFullPath); + if (directoryInfo.Exists) + { + + + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + /// + public Task DeleteAsync(string subpath, CancellationToken cancellationToken = default) + { + if (TryGetFullPath(subpath, out string fullPath)) + { + var fileInfo = new FileInfo(subpath); + if (fileInfo.Exists) + { + fileInfo.Delete(); + + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + /// + public Task DeleteDirectoryAsync(string subpath, bool recursive = false, CancellationToken cancellationToken = default) + { + if (TryGetFullPath(subpath, out string fullPath)) + { + var directoryInfo = new DirectoryInfo(subpath); + if (directoryInfo.Exists) + { + directoryInfo.Delete(recursive); + + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + private bool TryGetFullPath(string subpath, out string fullPath) + { + if (string.IsNullOrEmpty(subpath) || PathUtils.HasInvalidPathChars(subpath)) + { + fullPath = null!; + return false; + } + + // Relative paths starting with leading slashes are okay + subpath = subpath.TrimStart(_pathSeparators); + + // Absolute paths not permitted + if (Path.IsPathRooted(subpath)) + { + fullPath = null!; + return false; + } + + if (PathUtils.PathNavigatesAboveRoot(subpath)) + { + fullPath = null!; + return false; + } + + try + { + fullPath = Path.GetFullPath(Path.Combine(Root, subpath)); + } + catch + { + fullPath = null!; + return false; + } + + if (!fullPath.StartsWith(Root, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + private static void EnsureDirectory(string fullPath) + { + var directoryName = Path.GetDirectoryName(fullPath); + if (directoryName is not null && !Directory.Exists(directoryName)) + { + Directory.CreateDirectory(directoryName); + } + } + } + + //private class Temp + //{ + // private static readonly char[] _pathSeparators = new[] + // { + // Path.DirectorySeparatorChar, + // Path.AltDirectorySeparatorChar + // }; + + // /// + // public void CopyFile(string sourceSubpath, string destinationSubpath, bool overwrite = false) + // { + // if (string.IsNullOrEmpty(sourceSubpath) || PathUtils.HasInvalidPathChars(sourceSubpath) || + // string.IsNullOrEmpty(destinationSubpath) || PathUtils.HasInvalidPathChars(destinationSubpath)) + // { + // return; + // } + + // // Relative paths starting with leading slashes are okay + // sourceSubpath = sourceSubpath.TrimStart(_pathSeparators); + // destinationSubpath = destinationSubpath.TrimStart(_pathSeparators); + + // // Absolute paths not permitted. + // if (Path.IsPathRooted(sourceSubpath) || Path.IsPathRooted(destinationSubpath)) + // { + // return; + // } + + // string? sourceFullPath = GetFullPath(sourceSubpath); + // if (sourceFullPath == null) + // { + // return; + // } + + // string? destinationFullPath = GetFullPath(destinationSubpath); + // if (destinationFullPath == null) + // { + // return; + // } + + // File.Copy(sourceFullPath, destinationFullPath, overwrite); + // } + + // /// + // public void DeleteDirectory(string subpath, bool recursive = false) + // { + // if (subpath == null || PathUtils.HasInvalidPathChars(subpath)) + // { + // return; + // } + + // // Relative paths starting with leading slashes are okay + // subpath = subpath.TrimStart(_pathSeparators); + + // // Absolute paths not permitted. + // if (Path.IsPathRooted(subpath)) + // { + // return; + // } + + // string? fullPath = GetFullPath(subpath); + // if (fullPath == null || !Directory.Exists(fullPath)) + // { + // return; + // } + + // Directory.Delete(fullPath, recursive); + // } + + // /// + // public void DeleteFile(string subpath) + // { + // if (string.IsNullOrEmpty(subpath) || PathUtils.HasInvalidPathChars(subpath)) + // { + // return; + // } + + // // Relative paths starting with leading slashes are okay + // subpath = subpath.TrimStart(_pathSeparators); + + // // Absolute paths not permitted. + // if (Path.IsPathRooted(subpath)) + // { + // return; + // } + + // string? fullPath = GetFullPath(subpath); + // if (fullPath == null || !Directory.Exists(fullPath)) + // { + // return; + // } + + // File.Delete(fullPath); + // } + + // /// + // public void MoveFile(string sourceSubpath, string destinationSubpath, bool overwrite = false) + // { + // if (string.IsNullOrEmpty(sourceSubpath) || PathUtils.HasInvalidPathChars(sourceSubpath) || + // string.IsNullOrEmpty(destinationSubpath) || PathUtils.HasInvalidPathChars(destinationSubpath)) + // { + // return; + // } + + // // Relative paths starting with leading slashes are okay + // sourceSubpath = sourceSubpath.TrimStart(_pathSeparators); + // destinationSubpath = destinationSubpath.TrimStart(_pathSeparators); + + // // Absolute paths not permitted. + // if (Path.IsPathRooted(sourceSubpath) || Path.IsPathRooted(destinationSubpath)) + // { + // return; + // } + + // string? sourceFullPath = GetFullPath(sourceSubpath); + // if (sourceFullPath == null) + // { + // return; + // } + + // string? destinationFullPath = GetFullPath(destinationSubpath); + // if (destinationFullPath == null) + // { + // return; + // } + + // File.Move(sourceFullPath, destinationFullPath, overwrite); + // } + + // /// + // public void StoreFile(string subpath, Stream stream, bool overwrite = false) + // { + // if (string.IsNullOrEmpty(subpath) || PathUtils.HasInvalidPathChars(subpath) || stream is null) + // { + // return; + // } + + // // Relative paths starting with leading slashes are okay + // subpath = subpath.TrimStart(_pathSeparators); + + // // Absolute paths not permitted. + // if (Path.IsPathRooted(subpath)) + // { + // return; + // } + + // string? fullPath = GetFullPath(subpath); + // if (fullPath == null || !Directory.Exists(fullPath)) + // { + // return; + // } + + // using var fileStream = new FileStream(fullPath, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write); + // stream.CopyTo(fileStream); + // } + + // private string? GetFullPath(string path) + // { + // if (PathUtils.PathNavigatesAboveRoot(path)) + // { + // return null; + // } + + // string fullPath; + // try + // { + // fullPath = Path.GetFullPath(Path.Combine(Root, path)); + // } + // catch + // { + // return null; + // } + + // if (!IsUnderneathRoot(fullPath)) + // { + // return null; + // } + + // return fullPath; + // } + + // private bool IsUnderneathRoot(string fullPath) + // { + // return fullPath.StartsWith(Root, StringComparison.OrdinalIgnoreCase); + // } + //} +} From aef671b459254d6ac3c0bbc2a309c93c192c88d2 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Mon, 17 Oct 2022 16:59:08 +0200 Subject: [PATCH 7/7] Fix merge issues --- .../AzureBlobCdnMediaUrlProviderExtensions.cs | 47 ------------------- .../AzureBlobMediaFileSystemExtensions.cs | 1 - .../CdnMediaUrlProvider.cs | 1 - .../Properties/AssemblyInfo.cs | 3 -- .../Umbraco.StorageProviders.csproj | 2 +- 5 files changed, 1 insertion(+), 53 deletions(-) delete mode 100644 src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobCdnMediaUrlProviderExtensions.cs delete mode 100644 src/Umbraco.StorageProviders/Properties/AssemblyInfo.cs diff --git a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobCdnMediaUrlProviderExtensions.cs b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobCdnMediaUrlProviderExtensions.cs deleted file mode 100644 index bb4e930..0000000 --- a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobCdnMediaUrlProviderExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Umbraco.StorageProviders; -using Umbraco.StorageProviders.AzureBlob.IO; - -// ReSharper disable once CheckNamespace -// uses same namespace as Umbraco Core for easier discoverability -namespace Umbraco.Cms.Core.DependencyInjection -{ - /// - /// Extension methods to help registering a CDN media URL provider. - /// - public static class AzureBlobCdnMediaUrlProviderExtensions - { - /// - /// Registers and configures the . - /// - /// The . - /// - /// The . - /// - /// builder - public static IUmbracoBuilder AddAzureBlobCdnMediaUrlProvider(this IUmbracoBuilder builder) - { - if (builder == null) throw new ArgumentNullException(nameof(builder)); - - builder.AddCdnMediaUrlProvider(); - - builder.Services.AddOptions() - .BindConfiguration("Umbraco:Storage:AzureBlob:Media:Cdn") - .Configure>( - (options, factory) => - { - var mediaOptions = factory.Create(AzureBlobFileSystemOptions.MediaFileSystemName); - if (!string.IsNullOrEmpty(mediaOptions.ContainerName)) - { - options.Url = new Uri(options.Url, mediaOptions.ContainerName); - } - } - ) - .ValidateDataAnnotations(); - - return builder; - } - } -} diff --git a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs index 41039c8..39be081 100644 --- a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs +++ b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Infrastructure.DependencyInjection; using Umbraco.StorageProviders.AzureBlob.IO; diff --git a/src/Umbraco.StorageProviders/CdnMediaUrlProvider.cs b/src/Umbraco.StorageProviders/CdnMediaUrlProvider.cs index 4e21e77..86cdb5c 100644 --- a/src/Umbraco.StorageProviders/CdnMediaUrlProvider.cs +++ b/src/Umbraco.StorageProviders/CdnMediaUrlProvider.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; diff --git a/src/Umbraco.StorageProviders/Properties/AssemblyInfo.cs b/src/Umbraco.StorageProviders/Properties/AssemblyInfo.cs deleted file mode 100644 index 55acad6..0000000 --- a/src/Umbraco.StorageProviders/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System; - -[assembly:CLSCompliant(false)] diff --git a/src/Umbraco.StorageProviders/Umbraco.StorageProviders.csproj b/src/Umbraco.StorageProviders/Umbraco.StorageProviders.csproj index 32a6be4..c04398b 100644 --- a/src/Umbraco.StorageProviders/Umbraco.StorageProviders.csproj +++ b/src/Umbraco.StorageProviders/Umbraco.StorageProviders.csproj @@ -1,4 +1,4 @@ - + Umbraco Storage Providers Shared storage providers infrastructure for Umbraco CMS