From 897cf4ca195927e7bf9932f740fd25461b68a226 Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Date: Wed, 2 Nov 2022 15:26:07 +0100 Subject: [PATCH] V11: Using IFileProvider to access assets added from packages (#13141) * Creating a FileProviderFactory for getting the package.manifest and grid.editors.config.js files through a file provider * Collecting the package.manifest-s from different sources * Searching different sources for grid.editors.config.js * Using an IFileProvider to collect all tours * Refactoring IconService.cs * Typo * Optimizations when looping through the file system * Moving WebRootFileProviderFactory to Umbraco.Web.Common proj * Removes double registering * pluginLangFileSources includes the localPluginFileSources * Comments * Remove linq from foreach * Change workflow for grid.editors.config.js so we check first physical file, then RCL, then Embedded * Clean up * Check if config dir exists * Discover nested package.manifest files * Fix IFileInfo.PhysicalPath check * Revert 712810e1fd995720047832ee689f804185ea69d6 as that way files in content root are preferred over those in web root * Adding comments * Refactoring * Remove PhysicalPath check * Fix registration of WebRootFileProviderFactory --- .../Configuration/Grid/GridConfig.cs | 25 ++- .../Configuration/Grid/GridEditorsConfig.cs | 86 ++++++-- .../IGridEditorsConfigFileProviderFactory.cs | 10 + .../IO/IManifestFileProviderFactory.cs | 10 + .../Manifest/ManifestParser.cs | 92 ++++++++- .../Controllers/TourController.cs | 187 ++++++++++++------ .../UmbracoBuilder.LocalizedText.cs | 26 ++- .../Services/IconService.cs | 51 +++-- .../Trees/MemberGroupTreeController.cs | 3 +- .../UmbracoBuilderExtensions.cs | 8 +- .../WebRootFileProviderFactory.cs | 27 +++ .../Testing/UmbracoIntegrationTest.cs | 5 - .../Manifest/ManifestParserTests.cs | 3 +- 13 files changed, 391 insertions(+), 142 deletions(-) create mode 100644 src/Umbraco.Core/IO/IGridEditorsConfigFileProviderFactory.cs create mode 100644 src/Umbraco.Core/IO/IManifestFileProviderFactory.cs create mode 100644 src/Umbraco.Web.Common/FileProviders/WebRootFileProviderFactory.cs diff --git a/src/Umbraco.Core/Configuration/Grid/GridConfig.cs b/src/Umbraco.Core/Configuration/Grid/GridConfig.cs index 44c9c37dfd8a..be13776da8e9 100644 --- a/src/Umbraco.Core/Configuration/Grid/GridConfig.cs +++ b/src/Umbraco.Core/Configuration/Grid/GridConfig.cs @@ -1,8 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Manifest; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Web.Common.DependencyInjection; namespace Umbraco.Cms.Core.Configuration.Grid; @@ -13,9 +16,27 @@ public GridConfig( IManifestParser manifestParser, IJsonSerializer jsonSerializer, IHostingEnvironment hostingEnvironment, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + IGridEditorsConfigFileProviderFactory gridEditorsConfigFileProviderFactory) => EditorsConfig = - new GridEditorsConfig(appCaches, hostingEnvironment, manifestParser, jsonSerializer, loggerFactory.CreateLogger()); + new GridEditorsConfig(appCaches, hostingEnvironment, manifestParser, jsonSerializer, loggerFactory.CreateLogger(), gridEditorsConfigFileProviderFactory); + + [Obsolete("Use other ctor - Will be removed in Umbraco 13")] + public GridConfig( + AppCaches appCaches, + IManifestParser manifestParser, + IJsonSerializer jsonSerializer, + IHostingEnvironment hostingEnvironment, + ILoggerFactory loggerFactory) + : this( + appCaches, + manifestParser, + jsonSerializer, + hostingEnvironment, + loggerFactory, + StaticServiceProvider.Instance.GetRequiredService()) + { + } public IGridEditorsConfig EditorsConfig { get; } } diff --git a/src/Umbraco.Core/Configuration/Grid/GridEditorsConfig.cs b/src/Umbraco.Core/Configuration/Grid/GridEditorsConfig.cs index 11ae329192fb..ab0ec8b18209 100644 --- a/src/Umbraco.Core/Configuration/Grid/GridEditorsConfig.cs +++ b/src/Umbraco.Core/Configuration/Grid/GridEditorsConfig.cs @@ -1,12 +1,15 @@ -using System.Reflection; using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Cache; -using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Manifest; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; +using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Cms.Core.Configuration.Grid; @@ -17,6 +20,7 @@ internal class GridEditorsConfig : IGridEditorsConfig private readonly IJsonSerializer _jsonSerializer; private readonly ILogger _logger; + private readonly IGridEditorsConfigFileProviderFactory _gridEditorsConfigFileProviderFactory; private readonly IManifestParser _manifestParser; public GridEditorsConfig( @@ -24,13 +28,32 @@ public GridEditorsConfig( IHostingEnvironment hostingEnvironment, IManifestParser manifestParser, IJsonSerializer jsonSerializer, - ILogger logger) + ILogger logger, + IGridEditorsConfigFileProviderFactory gridEditorsConfigFileProviderFactory) { _appCaches = appCaches; _hostingEnvironment = hostingEnvironment; _manifestParser = manifestParser; _jsonSerializer = jsonSerializer; _logger = logger; + _gridEditorsConfigFileProviderFactory = gridEditorsConfigFileProviderFactory; + } + + [Obsolete("Use other ctor - Will be removed in Umbraco 13")] + public GridEditorsConfig( + AppCaches appCaches, + IHostingEnvironment hostingEnvironment, + IManifestParser manifestParser, + IJsonSerializer jsonSerializer, + ILogger logger) + : this( + appCaches, + hostingEnvironment, + manifestParser, + jsonSerializer, + logger, + StaticServiceProvider.Instance.GetRequiredService()) + { } public IEnumerable Editors @@ -39,13 +62,37 @@ public IEnumerable Editors { List GetResult() { - var configFolder = - new DirectoryInfo(_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.Config)); + IFileInfo? gridConfig = null; var editors = new List(); - var gridConfig = Path.Combine(configFolder.FullName, "grid.editors.config.js"); - if (File.Exists(gridConfig)) + var configPath = Constants.SystemDirectories.Config.TrimStart(Constants.CharArrays.Tilde); + + // Get physical file if it exists + var configPhysicalDirPath = _hostingEnvironment.MapPathContentRoot(configPath); + + if (Directory.Exists(configPhysicalDirPath) == true) + { + var physicalFileProvider = new PhysicalFileProvider(configPhysicalDirPath); + gridConfig = GetConfigFile(physicalFileProvider, string.Empty); + } + + // If there is no physical file, check in RCLs + if (gridConfig is null) + { + IFileProvider? compositeFileProvider = _gridEditorsConfigFileProviderFactory.Create(); + + if (compositeFileProvider is null) + { + throw new ArgumentNullException(nameof(compositeFileProvider)); + } + + gridConfig = GetConfigFile(compositeFileProvider, configPath); + } + + if (gridConfig is not null) { - var sourceString = File.ReadAllText(gridConfig); + using Stream stream = gridConfig.CreateReadStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + var sourceString = reader.ReadToEnd(); try { @@ -63,19 +110,16 @@ List GetResult() // Read default from embedded file else { - Assembly assembly = GetType().Assembly; - Stream? resourceStream = assembly.GetManifestResourceStream( - "Umbraco.Cms.Core.EmbeddedResources.Grid.grid.editors.config.js"); + IFileProvider configFileProvider = new EmbeddedFileProvider(GetType().Assembly, "Umbraco.Cms.Core.EmbeddedResources.Grid"); + IFileInfo embeddedConfig = configFileProvider.GetFileInfo("grid.editors.config.js"); - if (resourceStream is not null) - { - using var reader = new StreamReader(resourceStream, Encoding.UTF8); - var sourceString = reader.ReadToEnd(); - editors.AddRange(_jsonSerializer.Deserialize>(sourceString)!); - } + using Stream stream = embeddedConfig.CreateReadStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + var sourceString = reader.ReadToEnd(); + editors.AddRange(_jsonSerializer.Deserialize>(sourceString)!); } - // add manifest editors, skip duplicates + // Add manifest editors, skip duplicates foreach (GridEditor gridEditor in _manifestParser.CombinedManifest.GridEditors) { if (editors.Contains(gridEditor) == false) @@ -95,4 +139,10 @@ List GetResult() return result!; } } + + private static IFileInfo? GetConfigFile(IFileProvider fileProvider, string path) + { + IFileInfo fileInfo = fileProvider.GetFileInfo($"{path}/grid.editors.config.js"); + return fileInfo.Exists ? fileInfo : null; + } } diff --git a/src/Umbraco.Core/IO/IGridEditorsConfigFileProviderFactory.cs b/src/Umbraco.Core/IO/IGridEditorsConfigFileProviderFactory.cs new file mode 100644 index 000000000000..2c401a3f9935 --- /dev/null +++ b/src/Umbraco.Core/IO/IGridEditorsConfigFileProviderFactory.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.FileProviders; + +namespace Umbraco.Cms.Core.IO; + +/// +/// Factory for creating instances for providing the grid.editors.config.js file. +/// +public interface IGridEditorsConfigFileProviderFactory : IFileProviderFactory +{ +} diff --git a/src/Umbraco.Core/IO/IManifestFileProviderFactory.cs b/src/Umbraco.Core/IO/IManifestFileProviderFactory.cs new file mode 100644 index 000000000000..982b029c27d3 --- /dev/null +++ b/src/Umbraco.Core/IO/IManifestFileProviderFactory.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.FileProviders; + +namespace Umbraco.Cms.Core.IO; + +/// +/// Factory for creating instances for providing the package.manifest file. +/// +public interface IManifestFileProviderFactory : IFileProviderFactory +{ +} diff --git a/src/Umbraco.Infrastructure/Manifest/ManifestParser.cs b/src/Umbraco.Infrastructure/Manifest/ManifestParser.cs index 4dbd6abd4066..887ac05dc49c 100644 --- a/src/Umbraco.Infrastructure/Manifest/ManifestParser.cs +++ b/src/Umbraco.Infrastructure/Manifest/ManifestParser.cs @@ -1,13 +1,17 @@ using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Manifest; @@ -21,6 +25,7 @@ public class ManifestParser : IManifestParser private readonly IAppPolicyCache _cache; private readonly IDataValueEditorFactory _dataValueEditorFactory; + private readonly IManifestFileProviderFactory _manifestFileProviderFactory; private readonly ManifestFilterCollection _filters; private readonly IHostingEnvironment _hostingEnvironment; @@ -46,7 +51,8 @@ public ManifestParser( IJsonSerializer jsonSerializer, ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, - IDataValueEditorFactory dataValueEditorFactory) + IDataValueEditorFactory dataValueEditorFactory, + IManifestFileProviderFactory manifestFileProviderFactory) { if (appCaches == null) { @@ -64,6 +70,34 @@ public ManifestParser( _localizedTextService = localizedTextService; _shortStringHelper = shortStringHelper; _dataValueEditorFactory = dataValueEditorFactory; + _manifestFileProviderFactory = manifestFileProviderFactory; + } + + [Obsolete("Use other ctor - Will be removed in Umbraco 13")] + public ManifestParser( + AppCaches appCaches, + ManifestValueValidatorCollection validators, + ManifestFilterCollection filters, + ILogger logger, + IIOHelper ioHelper, + IHostingEnvironment hostingEnvironment, + IJsonSerializer jsonSerializer, + ILocalizedTextService localizedTextService, + IShortStringHelper shortStringHelper, + IDataValueEditorFactory dataValueEditorFactory) + : this( + appCaches, + validators, + filters, + logger, + ioHelper, + hostingEnvironment, + jsonSerializer, + localizedTextService, + shortStringHelper, + dataValueEditorFactory, + StaticServiceProvider.Instance.GetRequiredService()) + { } public string AppPluginsPath @@ -89,12 +123,20 @@ public CompositePackageManifest CombinedManifest public IEnumerable GetManifests() { var manifests = new List(); + IFileProvider? manifestFileProvider = _manifestFileProviderFactory.Create(); + + if (manifestFileProvider is null) + { + throw new ArgumentNullException(nameof(manifestFileProvider)); + } - foreach (var path in GetManifestFiles()) + foreach (IFileInfo file in GetManifestFiles(manifestFileProvider, Constants.SystemDirectories.AppPlugins)) { try { - var text = File.ReadAllText(path); + using Stream stream = file.CreateReadStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + var text = reader.ReadToEnd(); text = TrimPreamble(text); if (string.IsNullOrWhiteSpace(text)) { @@ -102,12 +144,12 @@ public IEnumerable GetManifests() } PackageManifest manifest = ParseManifest(text); - manifest.Source = path; + manifest.Source = file.PhysicalPath!; // We assure that the PhysicalPath is not null in GetManifestFiles() manifests.Add(manifest); } catch (Exception e) { - _logger.LogError(e, "Failed to parse manifest at '{Path}', ignoring.", path); + _logger.LogError(e, "Failed to parse manifest at '{Path}', ignoring.", file.PhysicalPath); } } @@ -242,14 +284,44 @@ private static string TrimPreamble(string text) return text; } - // gets all manifest files (recursively) - private IEnumerable GetManifestFiles() + // Gets all manifest files + private static IEnumerable GetManifestFiles(IFileProvider fileProvider, string path) { - if (Directory.Exists(_path) == false) + var manifestFiles = new List(); + IEnumerable pluginFolders = fileProvider.GetDirectoryContents(path); + + foreach (IFileInfo pluginFolder in pluginFolders) { - return Array.Empty(); + if (!pluginFolder.IsDirectory) + { + continue; + } + + manifestFiles.AddRange(GetNestedManifestFiles(fileProvider, $"{path}/{pluginFolder.Name}")); } - return Directory.GetFiles(_path, "package.manifest", SearchOption.AllDirectories); + return manifestFiles; + } + + // Helper method to get all nested package.manifest files (recursively) + private static IEnumerable GetNestedManifestFiles(IFileProvider fileProvider, string path) + { + foreach (IFileInfo file in fileProvider.GetDirectoryContents(path)) + { + if (file.IsDirectory) + { + var virtualPath = WebPath.Combine(path, file.Name); + + // Recursively find nested package.manifest files + foreach (IFileInfo nested in GetNestedManifestFiles(fileProvider, virtualPath)) + { + yield return nested; + } + } + else if (file.Name.InvariantEquals("package.manifest") && !string.IsNullOrEmpty(file.PhysicalPath)) + { + yield return file; + } + } } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/TourController.cs b/src/Umbraco.Web.BackOffice/Controllers/TourController.cs index ae31876ac519..4d708b4fc857 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/TourController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/TourController.cs @@ -1,16 +1,21 @@ using System.Text; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Tour; using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; +using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Cms.Web.BackOffice.Controllers; @@ -20,22 +25,44 @@ public class TourController : UmbracoAuthorizedJsonController private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; private readonly IContentTypeService _contentTypeService; private readonly TourFilterCollection _filters; - private readonly IHostingEnvironment _hostingEnvironment; + private readonly IWebHostEnvironment _webHostEnvironment; private readonly TourSettings _tourSettings; + // IHostingEnvironment is still injected as when removing it, the number of + // parameters matches with the obsolete ctor and the two ctors become ambiguous + // [ActivatorUtilitiesConstructor] won't solve the problem in this case + // IHostingEnvironment can be removed when the obsolete ctor is removed + [ActivatorUtilitiesConstructor] public TourController( TourFilterCollection filters, IHostingEnvironment hostingEnvironment, IOptionsSnapshot tourSettings, IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IContentTypeService contentTypeService) + IContentTypeService contentTypeService, + IWebHostEnvironment webHostEnvironment) { _filters = filters; - _hostingEnvironment = hostingEnvironment; - _tourSettings = tourSettings.Value; _backofficeSecurityAccessor = backofficeSecurityAccessor; _contentTypeService = contentTypeService; + _webHostEnvironment = webHostEnvironment; + } + + [Obsolete("Use other ctor - Will be removed in Umbraco 13")] + public TourController( + TourFilterCollection filters, + IHostingEnvironment hostingEnvironment, + IOptionsSnapshot tourSettings, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IContentTypeService contentTypeService) + : this( + filters, + hostingEnvironment, + tourSettings, + backofficeSecurityAccessor, + contentTypeService, + StaticServiceProvider.Instance.GetRequiredService()) + { } public async Task> GetTours() @@ -53,69 +80,55 @@ public async Task> GetTours() return result; } - //get all filters that will be applied to all tour aliases + // Get all filters that will be applied to all tour aliases var aliasOnlyFilters = _filters.Where(x => x.PluginName == null && x.TourFileName == null).ToList(); - //don't pass in any filters for core tours that have a plugin name assigned + // Don't pass in any filters for core tours that have a plugin name assigned var nonPluginFilters = _filters.Where(x => x.PluginName == null).ToList(); - //add core tour files - IEnumerable embeddedTourNames = GetType() - .Assembly - .GetManifestResourceNames() - .Where(x => x.StartsWith("Umbraco.Cms.Web.BackOffice.EmbeddedResources.Tours.")); - foreach (var embeddedTourName in embeddedTourNames) + // Get core tour files + IFileProvider toursProvider = new EmbeddedFileProvider(GetType().Assembly, "Umbraco.Cms.Web.BackOffice.EmbeddedResources.Tours"); + + IEnumerable embeddedTourFiles = toursProvider.GetDirectoryContents(string.Empty) + .Where(x => !x.IsDirectory && x.Name.EndsWith(".json")); + + foreach (var embeddedTour in embeddedTourFiles) { - await TryParseTourFile(embeddedTourName, result, nonPluginFilters, aliasOnlyFilters, async x => await GetContentFromEmbeddedResource(x)); + using Stream stream = embeddedTour.CreateReadStream(); + await TryParseTourFile(embeddedTour.Name, result, nonPluginFilters, aliasOnlyFilters, stream); } + // Collect all tour files from packages - /App_Plugins physical or virtual locations + IEnumerable> toursFromPackages = GetTourFiles(_webHostEnvironment.WebRootFileProvider, Constants.SystemDirectories.AppPlugins); - //collect all tour files in packages - var appPlugins = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.AppPlugins); - if (Directory.Exists(appPlugins)) + foreach (var tuple in toursFromPackages) { - foreach (var plugin in Directory.EnumerateDirectories(appPlugins)) - { - var pluginName = Path.GetFileName(plugin.TrimEnd(Constants.CharArrays.Backslash)); - var pluginFilters = _filters.Where(x => x.PluginName != null && x.PluginName.IsMatch(pluginName)) - .ToList(); - - //If there is any filter applied to match the plugin only (no file or tour alias) then ignore the plugin entirely - var isPluginFiltered = pluginFilters.Any(x => x.TourFileName == null && x.TourAlias == null); - if (isPluginFiltered) - { - continue; - } + string pluginName = tuple.Item2; + var pluginFilters = _filters.Where(x => x.PluginName != null && x.PluginName.IsMatch(pluginName)).ToList(); - //combine matched package filters with filters not specific to a package - var combinedFilters = nonPluginFilters.Concat(pluginFilters).ToList(); + // Combine matched package filters with filters not specific to a package + var combinedFilters = nonPluginFilters.Concat(pluginFilters).ToList(); - foreach (var backofficeDir in Directory.EnumerateDirectories(plugin, "backoffice")) - { - foreach (var tourDir in Directory.EnumerateDirectories(backofficeDir, "tours")) - { - foreach (var tourFile in Directory.EnumerateFiles(tourDir, "*.json")) - { - await TryParseTourFile( - tourFile, - result, - combinedFilters, - aliasOnlyFilters, - async x => await System.IO.File.ReadAllTextAsync(x), - pluginName); - } - } - } + IFileInfo tourFile = tuple.Item1; + using (Stream stream = tourFile.CreateReadStream()) + { + await TryParseTourFile( + tourFile.Name, + result, + combinedFilters, + aliasOnlyFilters, + stream, + pluginName); } } - //Get all allowed sections for the current user + // Get all allowed sections for the current user var allowedSections = user.AllowedSections.ToList(); var toursToBeRemoved = new List(); - //Checking to see if the user has access to the required tour sections, else we remove the tour + // Checking to see if the user has access to the required tour sections, else we remove the tour foreach (BackOfficeTourFile backOfficeTourFile in result) { if (backOfficeTourFile.Tours != null) @@ -140,21 +153,70 @@ await TryParseTourFile( return result.Except(toursToBeRemoved).OrderBy(x => x.FileName, StringComparer.InvariantCultureIgnoreCase); } - private async Task GetContentFromEmbeddedResource(string fileName) + private IEnumerable> GetTourFiles(IFileProvider fileProvider, string folder) { - Stream? resourceStream = GetType().Assembly.GetManifestResourceStream(fileName); + IEnumerable pluginFolders = fileProvider.GetDirectoryContents(folder).Where(x => x.IsDirectory); - if (resourceStream is null) + foreach (IFileInfo pluginFolder in pluginFolders) { - return string.Empty; + var pluginFilters = _filters.Where(x => x.PluginName != null && x.PluginName.IsMatch(pluginFolder.Name)).ToList(); + + // If there is any filter applied to match the plugin only (no file or tour alias) then ignore the plugin entirely + var isPluginFiltered = pluginFilters.Any(x => x.TourFileName == null && x.TourAlias == null); + if (isPluginFiltered) + { + continue; + } + + // get the full virtual path for the plugin folder + var pluginFolderPath = WebPath.Combine(folder, pluginFolder.Name); + + // loop through the folder(s) in order to find tours + // - there could be multiple on case sensitive file system + // Hard-coding the "backoffice" directory name to gain a better performance when traversing the App_Plugins directory + foreach (var subDir in GetToursFolderPaths(fileProvider, pluginFolderPath, "backoffice")) + { + IEnumerable tourFiles = fileProvider + .GetDirectoryContents(subDir) + .Where(x => x.Name.InvariantEndsWith(".json")); + + foreach (IFileInfo file in tourFiles) + { + yield return new Tuple(file, pluginFolder.Name); + } + } } + } - using var reader = new StreamReader(resourceStream, Encoding.UTF8); - return await reader.ReadToEndAsync(); + private static IEnumerable GetToursFolderPaths(IFileProvider fileProvider, string path, string subDirName) + { + // Hard-coding the "tours" directory name to gain a better performance when traversing the sub directories + const string toursDirName = "tours"; + + // It is necessary to iterate through the subfolders because on Linux we'll get casing issues when + // we try to access {path}/{pluginDirectory.Name}/backoffice/tours directly + foreach (IFileInfo subDir in fileProvider.GetDirectoryContents(path)) + { + // InvariantEquals({dirName}) is used to gain a better performance when looking for the tours folder + if (subDir.IsDirectory && subDir.Name.InvariantEquals(subDirName)) + { + var virtualPath = WebPath.Combine(path, subDir.Name); + + if (subDir.Name.InvariantEquals(toursDirName)) + { + yield return virtualPath; + } + + foreach (var nested in GetToursFolderPaths(fileProvider, virtualPath, toursDirName)) + { + yield return nested; + } + } + } } /// - /// Gets a tours for a specific doctype + /// Gets a tours for a specific doctype. /// /// The documenttype alias /// A @@ -190,7 +252,7 @@ private async Task TryParseTourFile( ICollection result, List filters, List aliasOnlyFilters, - Func> fileNameToFileContent, + Stream fileStream, string? pluginName = null) { var fileName = Path.GetFileNameWithoutExtension(tourFile); @@ -199,24 +261,25 @@ private async Task TryParseTourFile( return; } - //get the filters specific to this file + // Get the filters specific to this file var fileFilters = filters.Where(x => x.TourFileName != null && x.TourFileName.IsMatch(fileName)).ToList(); - //If there is any filter applied to match the file only (no tour alias) then ignore the file entirely + // If there is any filter applied to match the file only (no tour alias) then ignore the file entirely var isFileFiltered = fileFilters.Any(x => x.TourAlias == null); if (isFileFiltered) { return; } - //now combine all aliases to filter below + // Now combine all aliases to filter below var aliasFilters = aliasOnlyFilters.Concat(filters.Where(x => x.TourAlias != null)) .Select(x => x.TourAlias) .ToList(); try { - var contents = await fileNameToFileContent(tourFile); + using var reader = new StreamReader(fileStream, Encoding.UTF8); + var contents = reader.ReadToEnd(); BackOfficeTour[]? tours = JsonConvert.DeserializeObject(contents); IEnumerable? backOfficeTours = tours?.Where(x => @@ -234,7 +297,7 @@ private async Task TryParseTourFile( Tours = localizedTours ?? new List() }; - //don't add if all of the tours are filtered + // Don't add if all of the tours are filtered if (tour.Tours.Any()) { result.Add(tour); diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs index 60a946a553d0..7a92a77b3c37 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs @@ -1,8 +1,6 @@ -using System.IO; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; @@ -31,7 +29,7 @@ private static IUmbracoBuilder AddSupplemenataryLocalizedTextFileSources(this IU /// - /// Loads the suplimentary localization files from plugins and user config + /// Loads the suplimentary localization files from plugins and user config. /// private static IEnumerable GetSupplementaryFileSources( IWebHostEnvironment webHostEnvironment) @@ -41,10 +39,10 @@ private static IEnumerable GetSuppl IEnumerable localPluginFileSources = GetPluginLanguageFileSources(contentFileProvider, Cms.Core.Constants.SystemDirectories.AppPlugins, false); - // gets all langs files in /app_plugins real or virtual locations + // Gets all language files in /app_plugins real or virtual locations IEnumerable pluginLangFileSources = GetPluginLanguageFileSources(webFileProvider, Cms.Core.Constants.SystemDirectories.AppPlugins, false); - // user defined language files that overwrite the default, these should not be used by plugin creators + // User defined language files that overwrite the default, these should not be used by plugin creators var userConfigLangFolder = Cms.Core.Constants.SystemDirectories.Config .TrimStart(Cms.Core.Constants.CharArrays.Tilde); @@ -56,7 +54,7 @@ private static IEnumerable GetSuppl { foreach (IFileInfo langFile in contentFileProvider.GetDirectoryContents($"{userConfigLangFolder}/{langFileSource.Name}")) { - if (langFile.Name.InvariantEndsWith(".xml") && langFile.PhysicalPath is not null) + if (langFile.Name.InvariantEndsWith(".xml")) { configLangFileSources.Add(new LocalizedTextServiceSupplementaryFileSource(langFile, true)); } @@ -64,10 +62,9 @@ private static IEnumerable GetSuppl } } - return - localPluginFileSources - .Concat(pluginLangFileSources) - .Concat(configLangFileSources); + return localPluginFileSources + .Concat(pluginLangFileSources) + .Concat(configLangFileSources); } @@ -75,7 +72,7 @@ private static IEnumerable GetSuppl /// Loads the suplimentary localaization files via the file provider. /// /// - /// locates all *.xml files in the lang folder of any sub folder of the one provided. + /// Locates all *.xml files in the lang folder of any sub folder of the one provided. /// e.g /app_plugins/plugin-name/lang/*.xml /// private static IEnumerable GetPluginLanguageFileSources( @@ -87,17 +84,16 @@ private static IEnumerable GetPlugi foreach (IFileInfo pluginFolder in pluginFolders) { - // get the full virtual path for the plugin folder + // Get the full virtual path for the plugin folder var pluginFolderPath = WebPath.Combine(folder, pluginFolder.Name); - // loop through the lang folder(s) + // Loop through the lang folder(s) // - there could be multiple on case sensitive file system foreach (var langFolder in GetLangFolderPaths(fileProvider, pluginFolderPath)) { - // request all the files out of the path, these will have physicalPath set. + // Request all the files out of the path IEnumerable localizationFiles = fileProvider .GetDirectoryContents(langFolder) - .Where(x => !string.IsNullOrEmpty(x.PhysicalPath)) .Where(x => x.Name.InvariantEndsWith(".xml")); foreach (IFileInfo file in localizationFiles) diff --git a/src/Umbraco.Web.BackOffice/Services/IconService.cs b/src/Umbraco.Web.BackOffice/Services/IconService.cs index ac7ae2455e42..068db52be669 100644 --- a/src/Umbraco.Web.BackOffice/Services/IconService.cs +++ b/src/Umbraco.Web.BackOffice/Services/IconService.cs @@ -34,7 +34,6 @@ public IconService( { } - [Obsolete("Use other ctor - Will be removed in Umbraco 12")] public IconService( IOptionsMonitor globalSettings, IHostingEnvironment hostingEnvironment, @@ -101,6 +100,7 @@ public IconService( } } + // TODO: Refactor to return IEnumerable private IEnumerable GetAllIconsFiles() { var icons = new HashSet(new CaseInsensitiveFileInfoComparer()); @@ -161,46 +161,45 @@ private IEnumerable GetAllIconsFiles() /// A collection of representing the found SVG icon files. private static IEnumerable GetIconsFiles(IFileProvider fileProvider, string path) { - // Iterate through all plugin folders, this is necessary because on Linux we'll get casing issues when + // Iterate through all plugin folders and their subfolders, this is necessary because on Linux we'll get casing issues when // we directly try to access {path}/{pluginDirectory.Name}/{Constants.SystemDirectories.PluginIcons} foreach (IFileInfo pluginDirectory in fileProvider.GetDirectoryContents(path)) { - // Ideally there shouldn't be any files, but we'd better check to be sure if (!pluginDirectory.IsDirectory) { continue; } - // Iterate through the sub directories of each plugin folder + // Iterate through the sub directories of each plugin folder in order to support case insensitive paths (for Linux) foreach (IFileInfo subDir1 in fileProvider.GetDirectoryContents($"{path}/{pluginDirectory.Name}")) { - // Skip files again - if (!subDir1.IsDirectory) - { - continue; - } - - // Iterate through second level sub directories - foreach (IFileInfo subDir2 in fileProvider.GetDirectoryContents($"{path}/{pluginDirectory.Name}/{subDir1.Name}")) + // Hard-coding the "backoffice" directory name to gain a better performance when traversing the pluginDirectory directories + if (subDir1.IsDirectory && subDir1.Name.InvariantEquals("backoffice")) { - // Skip files again - if (!subDir2.IsDirectory) + // Iterate through second level sub directories in order to support case insensitive paths (for Linux) + foreach (IFileInfo subDir2 in fileProvider.GetDirectoryContents($"{path}/{pluginDirectory.Name}/{subDir1.Name}")) { - continue; - } + if (!subDir2.IsDirectory) + { + continue; + } - // Does the directory match the plugin icons folder? (case insensitive for legacy support) - if (!$"/{subDir1.Name}/{subDir2.Name}".InvariantEquals(Constants.SystemDirectories.PluginIcons)) - { - continue; - } + // Does the directory match the plugin icons folder? (case insensitive for legacy support) + if (!$"/{subDir1.Name}/{subDir2.Name}".InvariantEquals(Constants.SystemDirectories.PluginIcons)) + { + continue; + } - // Iterate though the files of the second level sub directory. This should be where the SVG files are located :D - foreach (IFileInfo file in fileProvider.GetDirectoryContents($"{path}/{pluginDirectory.Name}/{subDir1.Name}/{subDir2.Name}")) - { - if (file.Name.InvariantEndsWith(".svg") && file.PhysicalPath != null) + // Iterate though the files of the second level sub directory. This should be where the SVG files are located :D + foreach (IFileInfo file in fileProvider.GetDirectoryContents($"{path}/{pluginDirectory.Name}/{subDir1.Name}/{subDir2.Name}")) { - yield return new FileInfo(file.PhysicalPath); + // TODO: Refactor to work with IFileInfo, then we can also remove the .PhysicalPath check + // as this won't work for files that aren't located on a physical file system + // (e.g. embedded resource, Azure Blob Storage, etc.) + if (file.Name.InvariantEndsWith(".svg") && !string.IsNullOrEmpty(file.PhysicalPath)) + { + yield return new FileInfo(file.PhysicalPath); + } } } } diff --git a/src/Umbraco.Web.BackOffice/Trees/MemberGroupTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/MemberGroupTreeController.cs index af49c43e9047..275e28d9aab2 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MemberGroupTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MemberGroupTreeController.cs @@ -20,8 +20,7 @@ public class MemberGroupTreeController : MemberTypeAndGroupTreeControllerBase { private readonly IMemberGroupService _memberGroupService; - [ - ActivatorUtilitiesConstructor] + [ActivatorUtilitiesConstructor] public MemberGroupTreeController( ILocalizedTextService localizedTextService, UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 500bc2244759..edfe999d206f 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -27,6 +26,7 @@ using Umbraco.Cms.Core.Diagnostics; using Umbraco.Cms.Core.Extensions; using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.Macros; using Umbraco.Cms.Core.Net; @@ -48,6 +48,7 @@ using Umbraco.Cms.Web.Common.AspNetCore; using Umbraco.Cms.Web.Common.Controllers; using Umbraco.Cms.Web.Common.DependencyInjection; +using Umbraco.Cms.Web.Common.FileProviders; using Umbraco.Cms.Web.Common.Localization; using Umbraco.Cms.Web.Common.Macros; using Umbraco.Cms.Web.Common.Middleware; @@ -146,6 +147,11 @@ public static IUmbracoBuilder AddUmbracoCore(this IUmbracoBuilder builder) builder.Services.TryAddEnumerable(ServiceDescriptor .Singleton()); + // WebRootFileProviderFactory is just a wrapper around the IWebHostEnvironment.WebRootFileProvider, + // therefore no need to register it as singleton + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + // Must be added here because DbProviderFactories is netstandard 2.1 so cannot exist in Infra for now builder.Services.AddSingleton(factory => new DbProviderFactoryCreator( DbProviderFactories.GetFactory, diff --git a/src/Umbraco.Web.Common/FileProviders/WebRootFileProviderFactory.cs b/src/Umbraco.Web.Common/FileProviders/WebRootFileProviderFactory.cs new file mode 100644 index 000000000000..64824dd0906d --- /dev/null +++ b/src/Umbraco.Web.Common/FileProviders/WebRootFileProviderFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Web.Common.FileProviders; + +public class WebRootFileProviderFactory : IManifestFileProviderFactory, IGridEditorsConfigFileProviderFactory +{ + private readonly IWebHostEnvironment _webHostEnvironment; + + /// + /// Initializes a new instance of the class. + /// + /// The web hosting environment an application is running in. + public WebRootFileProviderFactory(IWebHostEnvironment webHostEnvironment) + { + _webHostEnvironment = webHostEnvironment; + } + + /// + /// Creates a new instance, pointing at . + /// + /// + /// The newly created instance. + /// + public IFileProvider Create() => _webHostEnvironment.WebRootFileProvider; +} diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs index 5babe9aa0b89..a174476acd3e 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -9,7 +7,6 @@ using NUnit.Framework; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; @@ -22,8 +19,6 @@ using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Integration.DependencyInjection; using Umbraco.Cms.Tests.Integration.Extensions; -using Umbraco.Cms.Web.Common.Hosting; -using Umbraco.Extensions; using Constants = Umbraco.Cms.Core.Constants; namespace Umbraco.Cms.Tests.Integration.Testing; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Manifest/ManifestParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Manifest/ManifestParserTests.cs index 03f635b2cd3b..71d1a25228c4 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Manifest/ManifestParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Manifest/ManifestParserTests.cs @@ -47,7 +47,8 @@ public void Setup() new JsonNetSerializer(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); } private ManifestParser _parser;