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/AzureBlobFileSystemMiddleware.cs b/src/Umbraco.StorageProviders.AzureBlob/AzureBlobFileSystemMiddleware.cs deleted file mode 100644 index 752d409..0000000 --- a/src/Umbraco.StorageProviders.AzureBlob/AzureBlobFileSystemMiddleware.cs +++ /dev/null @@ -1,385 +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 string _containerRootPath; - 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); - _containerRootPath = fileSystemOptions.ContainerRootPath ?? _rootPath; - - 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; - } - - string containerPath = $"{_containerRootPath.TrimEnd('/')}/{(request.Path.Value.Remove(0, _rootPath.Length)).TrimStart('/')}"; - var blob = _fileSystemProvider.GetFileSystem(_name).GetBlobClient(containerPath); - - 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); - _containerRootPath = options.ContainerRootPath ?? _rootPath; - } - } -} 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/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs index b23289b..27e1f1e 100644 --- a/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs +++ b/src/Umbraco.StorageProviders.AzureBlob/DependencyInjection/AzureBlobMediaFileSystemExtensions.cs @@ -1,17 +1,20 @@ using System; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles.Infrastructure; 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 +44,6 @@ public static IUmbracoBuilder AddAzureBlobMediaFileSystem(this IUmbracoBuilder b options.VirtualPath = globalSettingsOptions.Value.UmbracoMediaPath; }); - builder.Services.TryAddSingleton(); - // ImageSharp image provider/cache builder.Services.Insert(0, ServiceDescriptor.Singleton()); builder.Services.AddUnique(); @@ -104,7 +105,7 @@ public static IUmbracoBuilder AddAzureBlobMediaFileSystem(this IUmbracoBuilder b } /// - /// Adds the . + /// Adds the . /// /// The . /// @@ -121,7 +122,7 @@ public static IUmbracoApplicationBuilderContext UseAzureBlobMediaFileSystem(this } /// - /// Adds the . + /// Adds the . /// /// The . /// @@ -132,7 +133,32 @@ 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); + + var sharedOptions = new SharedOptions() + { + FileProvider = new FileSystemFileProvider(fileSystem, requestPath), + RequestPath = requestPath + }; + + app.UseStaticFiles(new StaticFileOptions(sharedOptions) + { + 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; } diff --git a/src/Umbraco.StorageProviders.AzureBlob/Umbraco.StorageProviders.AzureBlob.csproj b/src/Umbraco.StorageProviders.AzureBlob/Umbraco.StorageProviders.AzureBlob.csproj index 3391ec1..31c1bb5 100644 --- a/src/Umbraco.StorageProviders.AzureBlob/Umbraco.StorageProviders.AzureBlob.csproj +++ b/src/Umbraco.StorageProviders.AzureBlob/Umbraco.StorageProviders.AzureBlob.csproj @@ -4,13 +4,14 @@ enable AllEnabledByDefault true - Azure blob storage plugin for Umbraco CMS - + - - + + + + diff --git a/src/Umbraco.StorageProviders.AzureBlob/version.json b/src/Umbraco.StorageProviders.AzureBlob/version.json deleted file mode 100644 index 4330f5c..0000000 --- a/src/Umbraco.StorageProviders.AzureBlob/version.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": "0.1", - "nugetPackageVersion": { - "semVer": 2 - }, - "pathFilters": ["."] -} 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/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; + } +} 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..5b3e5c4 --- /dev/null +++ b/src/Umbraco.StorageProviders/Umbraco.StorageProviders.csproj @@ -0,0 +1,13 @@ + + + net5.0 + enable + AllEnabledByDefault + true + + + + + + +