diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Routing/ControllerActionSearcherTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Routing/ControllerActionSearcherTests.cs index d5d3b3e26b..7c96738a1e 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Routing/ControllerActionSearcherTests.cs +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Routing/ControllerActionSearcherTests.cs @@ -53,8 +53,8 @@ namespace Umbraco.Tests.UnitTests.Umbraco.Web.Website.Routing private class Render2Controller : RenderController { - public Render2Controller(ILogger logger, ICompositeViewEngine compositeViewEngine, IUmbracoContextAccessor umbracoContextAccessor) - : base(logger, compositeViewEngine, umbracoContextAccessor) + public Render2Controller(ILoggerFactory loggerFactory, ICompositeViewEngine compositeViewEngine, IUmbracoContextAccessor umbracoContextAccessor) + : base(loggerFactory, compositeViewEngine, umbracoContextAccessor) { } } diff --git a/src/Umbraco.Web.BackOffice/Trees/ApplicationTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ApplicationTreeController.cs index ec90965455..c938dced9d 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ApplicationTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ApplicationTreeController.cs @@ -35,14 +35,15 @@ namespace Umbraco.Web.BackOffice.Trees private readonly IControllerFactory _controllerFactory; private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; - + /// + /// Initializes a new instance of the class. + /// public ApplicationTreeController( ITreeService treeService, ISectionService sectionService, ILocalizedTextService localizedTextService, IControllerFactory controllerFactory, - IActionDescriptorCollectionProvider actionDescriptorCollectionProvider - ) + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) { _treeService = treeService; _sectionService = sectionService; @@ -56,28 +57,31 @@ namespace Umbraco.Web.BackOffice.Trees /// /// The application to load tree for /// An optional single tree alias, if specified will only load the single tree for the request app - /// + /// The query strings /// Tree use. - /// public async Task> GetApplicationTrees(string application, string tree, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings, TreeUse use = TreeUse.Main) { application = application.CleanForXss(); if (string.IsNullOrEmpty(application)) + { return NotFound(); + } var section = _sectionService.GetByAlias(application); if (section == null) + { return NotFound(); + } - //find all tree definitions that have the current application alias + // find all tree definitions that have the current application alias var groupedTrees = _treeService.GetBySectionGrouped(application, use); var allTrees = groupedTrees.Values.SelectMany(x => x).ToList(); if (allTrees.Count == 0) { - //if there are no trees defined for this section but the section is defined then we can have a simple - //full screen section without trees + // if there are no trees defined for this section but the section is defined then we can have a simple + // full screen section without trees var name = _localizedTextService.Localize("sections/" + application); return TreeRootNode.CreateSingleTreeRoot(Constants.System.RootString, null, null, name, TreeNodeCollection.Empty, true); } @@ -90,7 +94,9 @@ namespace Umbraco.Web.BackOffice.Trees : allTrees.FirstOrDefault(x => x.TreeAlias == tree); if (t == null) + { return NotFound(); + } var treeRootNode = await GetTreeRootNode(t, Constants.System.Root, queryStrings); @@ -114,9 +120,12 @@ namespace Umbraco.Web.BackOffice.Trees { return nodeResult.Result; } + var node = nodeResult.Value; if (node != null) + { nodes.Add(node); + } } var name = _localizedTextService.Localize("sections/" + application); @@ -148,11 +157,15 @@ namespace Umbraco.Web.BackOffice.Trees var node = nodeResult.Value; if (node != null) + { nodes.Add(node); + } } if (nodes.Count == 0) + { continue; + } // no name => third party // use localization key treeHeaders/thirdPartyGroup @@ -179,7 +192,9 @@ namespace Umbraco.Web.BackOffice.Trees private async Task> TryGetRootNode(Tree tree, FormCollection querystring) { if (tree == null) + { throw new ArgumentNullException(nameof(tree)); + } return await GetRootNode(tree, querystring); } @@ -190,7 +205,9 @@ namespace Umbraco.Web.BackOffice.Trees private async Task> GetTreeRootNode(Tree tree, int id, FormCollection querystring) { if (tree == null) + { throw new ArgumentNullException(nameof(tree)); + } var childrenResult = await GetChildren(tree, id, querystring); if (!(childrenResult.Result is null)) @@ -222,7 +239,9 @@ namespace Umbraco.Web.BackOffice.Trees sectionRoot.Path = rootNode.Path; foreach (var d in rootNode.AdditionalData) + { sectionRoot.AdditionalData[d.Key] = d.Value; + } return sectionRoot; } @@ -233,7 +252,9 @@ namespace Umbraco.Web.BackOffice.Trees private async Task> GetRootNode(Tree tree, FormCollection querystring) { if (tree == null) + { throw new ArgumentNullException(nameof(tree)); + } var result = await GetApiControllerProxy(tree.TreeControllerType, "GetRootNode", querystring); @@ -253,7 +274,10 @@ namespace Umbraco.Web.BackOffice.Trees var rootNode = rootNodeResult.Value; if (rootNode == null) + { throw new InvalidOperationException($"Failed to get root node for tree \"{tree.TreeAlias}\"."); + } + return rootNode; } @@ -263,7 +287,9 @@ namespace Umbraco.Web.BackOffice.Trees private async Task> GetChildren(Tree tree, int id, FormCollection querystring) { if (tree == null) + { throw new ArgumentNullException(nameof(tree)); + } // the method we proxy has an 'id' parameter which is *not* in the querystring, // we need to add it for the proxy to work (else, it does not find the method, @@ -299,7 +325,9 @@ namespace Umbraco.Web.BackOffice.Trees // note: this is all required in order to execute the auth-filters for the sub request, we // need to "trick" mvc into thinking that it is actually executing the proxied controller. + // TODO: We have a method for this: ControllerExtensions.GetControllerName var controllerName = controllerType.Name.Substring(0, controllerType.Name.Length - 10); // remove controller part of name; + // create proxy route data specifying the action & controller to execute var routeData = new RouteData(new RouteValueDictionary() { @@ -324,9 +352,12 @@ namespace Umbraco.Web.BackOffice.Trees var proxyControllerContext = new ControllerContext(actionContext); var controller = (TreeController)_controllerFactory.CreateController(proxyControllerContext); + // TODO: What about other filters? Will they execute? var isAllowed = await controller.ControllerContext.InvokeAuthorizationFiltersForRequest(actionContext); if (!isAllowed) + { return Forbid(); + } return controller; } diff --git a/src/Umbraco.Web.Common/ApplicationModels/BackOfficeApplicationModelProvider.cs b/src/Umbraco.Web.Common/ApplicationModels/BackOfficeApplicationModelProvider.cs index 11d82d4db5..4d75e2219f 100644 --- a/src/Umbraco.Web.Common/ApplicationModels/BackOfficeApplicationModelProvider.cs +++ b/src/Umbraco.Web.Common/ApplicationModels/BackOfficeApplicationModelProvider.cs @@ -1,11 +1,12 @@ -using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Mvc.ModelBinding; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Umbraco.Web.Common.Attributes; namespace Umbraco.Web.Common.ApplicationModels { + // TODO: This should just exist in the back office project /// @@ -13,45 +14,43 @@ namespace Umbraco.Web.Common.ApplicationModels /// public class BackOfficeApplicationModelProvider : IApplicationModelProvider { - public BackOfficeApplicationModelProvider(IModelMetadataProvider modelMetadataProvider) + private readonly List _actionModelConventions = new List() { - ActionModelConventions = new List() - { - new BackOfficeIdentityCultureConvention() - }; - } + new BackOfficeIdentityCultureConvention() + }; + /// /// /// Will execute after /// public int Order => 0; - public List ActionModelConventions { get; } - + /// public void OnProvidersExecuted(ApplicationModelProviderContext context) { } + /// public void OnProvidersExecuting(ApplicationModelProviderContext context) { - foreach (var controller in context.Result.Controllers) + foreach (ControllerModel controller in context.Result.Controllers) { if (!IsBackOfficeController(controller)) - continue; - - foreach (var action in controller.Actions) { - foreach (var convention in ActionModelConventions) + continue; + } + + foreach (ActionModel action in controller.Actions) + { + foreach (IActionModelConvention convention in _actionModelConventions) { convention.Apply(action); } } - } } private bool IsBackOfficeController(ControllerModel controller) => controller.Attributes.OfType().Any(); - } } diff --git a/src/Umbraco.Web.Common/ApplicationModels/BackOfficeIdentityCultureConvention.cs b/src/Umbraco.Web.Common/ApplicationModels/BackOfficeIdentityCultureConvention.cs index 0a5a1f9945..ffbb76dd0d 100644 --- a/src/Umbraco.Web.Common/ApplicationModels/BackOfficeIdentityCultureConvention.cs +++ b/src/Umbraco.Web.Common/ApplicationModels/BackOfficeIdentityCultureConvention.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.ApplicationModels; using Umbraco.Web.Common.Filters; namespace Umbraco.Web.Common.ApplicationModels @@ -8,9 +8,7 @@ namespace Umbraco.Web.Common.ApplicationModels public class BackOfficeIdentityCultureConvention : IActionModelConvention { - public void Apply(ActionModel action) - { - action.Filters.Add(new BackOfficeCultureFilter()); - } + /// + public void Apply(ActionModel action) => action.Filters.Add(new BackOfficeCultureFilter()); } } diff --git a/src/Umbraco.Web.Common/ApplicationModels/UmbracoApiBehaviorApplicationModelProvider.cs b/src/Umbraco.Web.Common/ApplicationModels/UmbracoApiBehaviorApplicationModelProvider.cs index be296969e7..edf4571a7e 100644 --- a/src/Umbraco.Web.Common/ApplicationModels/UmbracoApiBehaviorApplicationModelProvider.cs +++ b/src/Umbraco.Web.Common/ApplicationModels/UmbracoApiBehaviorApplicationModelProvider.cs @@ -1,8 +1,8 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Mvc.ModelBinding; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Umbraco.Core; using Umbraco.Web.Common.Attributes; @@ -27,13 +27,18 @@ namespace Umbraco.Web.Common.ApplicationModels /// public class UmbracoApiBehaviorApplicationModelProvider : IApplicationModelProvider { + private readonly List _actionModelConventions; + + /// + /// Initializes a new instance of the class. + /// public UmbracoApiBehaviorApplicationModelProvider(IModelMetadataProvider modelMetadataProvider) { // see see https://docs.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-3.1#apicontroller-attribute // for what these things actually do // NOTE: we don't have attribute routing requirements and we cannot use ApiVisibilityConvention without attribute routing - ActionModelConventions = new List() + _actionModelConventions = new List() { new ClientErrorResultFilterConvention(), // Ensures the responses without any body is converted into a simple json object with info instead of a string like "Status Code: 404; Not Found" new ConsumesConstraintForFormFileParameterConvention(), // If an controller accepts files, it must accept multipart/form-data. @@ -47,32 +52,33 @@ namespace Umbraco.Web.Common.ApplicationModels // TODO: Need to determine exactly how this affects errors var defaultErrorType = typeof(ProblemDetails); var defaultErrorTypeAttribute = new ProducesErrorResponseTypeAttribute(defaultErrorType); - ActionModelConventions.Add(new ApiConventionApplicationModelConvention(defaultErrorTypeAttribute)); + _actionModelConventions.Add(new ApiConventionApplicationModelConvention(defaultErrorTypeAttribute)); } + /// /// /// Will execute after /// public int Order => 0; - public List ActionModelConventions { get; } - + /// public void OnProvidersExecuted(ApplicationModelProviderContext context) { } + /// public void OnProvidersExecuting(ApplicationModelProviderContext context) { foreach (var controller in context.Result.Controllers) { if (!IsUmbracoApiController(controller)) + { continue; - - + } foreach (var action in controller.Actions) { - foreach (var convention in ActionModelConventions) + foreach (var convention in _actionModelConventions) { convention.Apply(action); } diff --git a/src/Umbraco.Web.Common/ApplicationModels/UmbracoJsonModelBinderConvention.cs b/src/Umbraco.Web.Common/ApplicationModels/UmbracoJsonModelBinderConvention.cs index 5a9e3ff90d..affcc2e7e5 100644 --- a/src/Umbraco.Web.Common/ApplicationModels/UmbracoJsonModelBinderConvention.cs +++ b/src/Umbraco.Web.Common/ApplicationModels/UmbracoJsonModelBinderConvention.cs @@ -1,9 +1,6 @@ -using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Mvc.ModelBinding; using System.Linq; -using Umbraco.Web.Common.Attributes; -using Umbraco.Web.Actions; -using Umbraco.Web.Common.Filters; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Umbraco.Web.Common.ModelBinders; namespace Umbraco.Web.Common.ApplicationModels @@ -16,14 +13,13 @@ namespace Umbraco.Web.Common.ApplicationModels /// public class UmbracoJsonModelBinderConvention : IActionModelConvention { + /// public void Apply(ActionModel action) { - foreach (var p in action.Parameters.Where(p => p.BindingInfo?.BindingSource == BindingSource.Body)) + foreach (ParameterModel p in action.Parameters.Where(p => p.BindingInfo?.BindingSource == BindingSource.Body)) { p.BindingInfo.BinderType = typeof(UmbracoJsonModelBinder); } } } - - } diff --git a/src/Umbraco.Web.Common/ApplicationModels/VirtualPageApplicationModelProvider.cs b/src/Umbraco.Web.Common/ApplicationModels/VirtualPageApplicationModelProvider.cs new file mode 100644 index 0000000000..62867d045b --- /dev/null +++ b/src/Umbraco.Web.Common/ApplicationModels/VirtualPageApplicationModelProvider.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Umbraco.Core; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web.Common.Controllers; + +namespace Umbraco.Web.Common.ApplicationModels +{ + /// + /// Applies the to any action on a controller that is + /// + public class VirtualPageApplicationModelProvider : IApplicationModelProvider + { + private readonly List _actionModelConventions = new List() + { + new VirtualPageConvention() + }; + + /// + /// + /// Will execute after + /// + public int Order => 0; + + /// + public void OnProvidersExecuted(ApplicationModelProviderContext context) { } + + /// + public void OnProvidersExecuting(ApplicationModelProviderContext context) + { + foreach (ControllerModel controller in context.Result.Controllers) + { + if (!IsVirtualPageController(controller)) + { + continue; + } + + foreach (ActionModel action in controller.Actions.ToList()) + { + if (action.ActionName == nameof(IVirtualPageController.FindContent) + && action.ActionMethod.ReturnType == typeof(IPublishedContent)) + { + // this is not an action, it's just the implementation of IVirtualPageController + controller.Actions.Remove(action); + } + else + { + foreach (IActionModelConvention convention in _actionModelConventions) + { + convention.Apply(action); + } + } + } + } + } + + private bool IsVirtualPageController(ControllerModel controller) + => controller.ControllerType.Implements(); + } +} diff --git a/src/Umbraco.Web.Common/ApplicationModels/VirtualPageConvention.cs b/src/Umbraco.Web.Common/ApplicationModels/VirtualPageConvention.cs new file mode 100644 index 0000000000..d35af70bb0 --- /dev/null +++ b/src/Umbraco.Web.Common/ApplicationModels/VirtualPageConvention.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Umbraco.Web.Common.Controllers; +using Umbraco.Web.Common.Filters; + +namespace Umbraco.Web.Common.ApplicationModels +{ + /// + /// Adds the as a convention + /// + public class VirtualPageConvention : IActionModelConvention + { + /// + public void Apply(ActionModel action) => action.Filters.Add(new UmbracoVirtualPageFilterAttribute()); + } +} diff --git a/src/Umbraco.Web.Common/Controllers/IVirtualPageController.cs b/src/Umbraco.Web.Common/Controllers/IVirtualPageController.cs new file mode 100644 index 0000000000..fc36207ee0 --- /dev/null +++ b/src/Umbraco.Web.Common/Controllers/IVirtualPageController.cs @@ -0,0 +1,15 @@ +using Umbraco.Core.Models.PublishedContent; + +namespace Umbraco.Web.Common.Controllers +{ + /// + /// Used for custom routed controllers to execute within the context of Umbraco + /// + public interface IVirtualPageController + { + /// + /// Returns the to use as the current page for the request + /// + IPublishedContent FindContent(); + } +} diff --git a/src/Umbraco.Web.Common/Controllers/RenderController.cs b/src/Umbraco.Web.Common/Controllers/RenderController.cs index 48fe50facc..bfa129df25 100644 --- a/src/Umbraco.Web.Common/Controllers/RenderController.cs +++ b/src/Umbraco.Web.Common/Controllers/RenderController.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -6,7 +5,6 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; -using Umbraco.Core.Models.PublishedContent; using Umbraco.Web.Common.ActionsResults; using Umbraco.Web.Common.Filters; using Umbraco.Web.Common.Routing; @@ -15,107 +13,32 @@ using Umbraco.Web.Routing; namespace Umbraco.Web.Common.Controllers { + /// /// Represents the default front-end rendering controller. /// [ModelBindingException] [PublishedRequestFilter] - public class RenderController : UmbracoController, IRenderController + public class RenderController : UmbracoPageController, IRenderController { private readonly ILogger _logger; - private readonly ICompositeViewEngine _compositeViewEngine; private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private UmbracoRouteValues _umbracoRouteValues; /// /// Initializes a new instance of the class. /// - public RenderController(ILogger logger, ICompositeViewEngine compositeViewEngine, IUmbracoContextAccessor umbracoContextAccessor) + public RenderController(ILoggerFactory loggerFactory, ICompositeViewEngine compositeViewEngine, IUmbracoContextAccessor umbracoContextAccessor) + : base(loggerFactory, compositeViewEngine) { - _logger = logger; - _compositeViewEngine = compositeViewEngine; + _logger = loggerFactory.CreateLogger(); _umbracoContextAccessor = umbracoContextAccessor; } - /// - /// Gets the current content item. - /// - protected IPublishedContent CurrentPage - { - get - { - if (!UmbracoRouteValues.PublishedRequest.HasPublishedContent()) - { - // This will never be accessed this way since the controller will handle redirects and not founds - // before this can be accessed but we need to be explicit. - throw new InvalidOperationException("There is no published content found in the request"); - } - - return UmbracoRouteValues.PublishedRequest.PublishedContent; - } - } - /// /// Gets the umbraco context /// protected IUmbracoContext UmbracoContext => _umbracoContextAccessor.UmbracoContext; - /// - /// Gets the - /// - protected UmbracoRouteValues UmbracoRouteValues - { - get - { - if (_umbracoRouteValues != null) - { - return _umbracoRouteValues; - } - - _umbracoRouteValues = HttpContext.Features.Get(); - - if (_umbracoRouteValues == null) - { - throw new InvalidOperationException($"No {nameof(UmbracoRouteValues)} feature was found in the HttpContext"); - } - - return _umbracoRouteValues; - } - } - - /// - /// Ensures that a physical view file exists on disk. - /// - /// The view name. - protected bool EnsurePhsyicalViewExists(string template) - { - ViewEngineResult result = _compositeViewEngine.FindView(ControllerContext, template, false); - if (result.View != null) - { - return true; - } - - _logger.LogWarning("No physical template file was found for template {Template}", template); - return false; - } - - /// - /// Gets an action result based on the template name found in the route values and a model. - /// - /// The type of the model. - /// The model. - /// The action result. - /// If the template found in the route values doesn't physically exist and exception is thrown - protected IActionResult CurrentTemplate(T model) - { - if (EnsurePhsyicalViewExists(UmbracoRouteValues.TemplateName) == false) - { - throw new InvalidOperationException("No physical template file was found for template " + UmbracoRouteValues.TemplateName); - } - - return View(UmbracoRouteValues.TemplateName, model); - } - /// /// The default action to render the front-end view. /// diff --git a/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs b/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs new file mode 100644 index 0000000000..bc0181412e --- /dev/null +++ b/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.Extensions.Logging; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web.Common.Routing; +using Umbraco.Web.Routing; + +namespace Umbraco.Web.Common.Controllers +{ + /// + /// An abstract controller for a front-end Umbraco page + /// + public abstract class UmbracoPageController : UmbracoController + { + private UmbracoRouteValues _umbracoRouteValues; + private readonly ICompositeViewEngine _compositeViewEngine; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + protected UmbracoPageController(ILoggerFactory loggerFactory, ICompositeViewEngine compositeViewEngine) + { + _logger = loggerFactory.CreateLogger(); + _compositeViewEngine = compositeViewEngine; + } + + /// + /// Gets the + /// + protected virtual UmbracoRouteValues UmbracoRouteValues + { + get + { + if (_umbracoRouteValues != null) + { + return _umbracoRouteValues; + } + + _umbracoRouteValues = HttpContext.Features.Get(); + + if (_umbracoRouteValues == null) + { + throw new InvalidOperationException($"No {nameof(UmbracoRouteValues)} feature was found in the HttpContext"); + } + + return _umbracoRouteValues; + } + } + + /// + /// Gets the current content item. + /// + protected virtual IPublishedContent CurrentPage + { + get + { + if (!UmbracoRouteValues.PublishedRequest.HasPublishedContent()) + { + // This will never be accessed this way since the controller will handle redirects and not founds + // before this can be accessed but we need to be explicit. + throw new InvalidOperationException("There is no published content found in the request"); + } + + return UmbracoRouteValues.PublishedRequest.PublishedContent; + } + } + + /// + /// Gets an action result based on the template name found in the route values and a model. + /// + /// The type of the model. + /// The model. + /// The action result. + /// If the template found in the route values doesn't physically exist and exception is thrown + protected IActionResult CurrentTemplate(T model) + { + if (EnsurePhsyicalViewExists(UmbracoRouteValues.TemplateName) == false) + { + throw new InvalidOperationException("No physical template file was found for template " + UmbracoRouteValues.TemplateName); + } + + return View(UmbracoRouteValues.TemplateName, model); + } + + /// + /// Ensures that a physical view file exists on disk. + /// + /// The view name. + protected bool EnsurePhsyicalViewExists(string template) + { + ViewEngineResult result = _compositeViewEngine.FindView(ControllerContext, template, false); + if (result.View != null) + { + return true; + } + + _logger.LogWarning("No physical template file was found for template {Template}", template); + return false; + } + + } +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index ba6cbe03a9..e8097335d6 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -57,6 +57,7 @@ using IHostingEnvironment = Umbraco.Core.Hosting.IHostingEnvironment; namespace Umbraco.Web.Common.DependencyInjection { + // TODO: We could add parameters to configure each of these for flexibility /// @@ -102,6 +103,10 @@ namespace Umbraco.Web.Common.DependencyInjection ILoggerFactory loggerFactory = LoggerFactory.Create(cfg => cfg.AddSerilog(Log.Logger, false)); TypeLoader typeLoader = services.AddTypeLoader(Assembly.GetEntryAssembly(), webHostEnvironment, tempHostingEnvironment, loggerFactory, appCaches, config, profiler); + // adds the umbraco startup filter which will call UseUmbraco early on before + // other start filters are applied (depending on the ordering of IStartupFilters in DI). + services.AddTransient(); + return new UmbracoBuilder(services, config, typeLoader, loggerFactory); } @@ -228,6 +233,7 @@ namespace Umbraco.Web.Common.DependencyInjection builder.Services.ConfigureOptions(); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient()); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Transient()); builder.Services.AddUmbracoImageSharp(builder.Config); // AspNetCore specific services diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoStartupFilter.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoStartupFilter.cs new file mode 100644 index 0000000000..008f8b0b35 --- /dev/null +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoStartupFilter.cs @@ -0,0 +1,22 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Umbraco.Extensions; + +namespace Umbraco.Web.Common.DependencyInjection +{ + /// + /// A registered early in DI so that it executes before any user IStartupFilters + /// to ensure that all Umbraco service and requirements are started correctly and in order. + /// + public sealed class UmbracoStartupFilter : IStartupFilter + { + /// + public Action Configure(Action next) => + app => + { + app.UseUmbraco(); + next(app); + }; + } +} diff --git a/src/Umbraco.Web.Common/Extensions/ControllerActionEndpointConventionBuilderExtensions.cs b/src/Umbraco.Web.Common/Extensions/ControllerActionEndpointConventionBuilderExtensions.cs new file mode 100644 index 0000000000..becb79a70e --- /dev/null +++ b/src/Umbraco.Web.Common/Extensions/ControllerActionEndpointConventionBuilderExtensions.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web.Common.Filters; + +namespace Umbraco.Web.Common.Extensions +{ + public static class ControllerActionEndpointConventionBuilderExtensions + { + /// + /// Allows for defining a callback to set the returned for the current request for this route + /// + public static void ForUmbracoPage( + this ControllerActionEndpointConventionBuilder builder, + Func findContent) + => builder.Add(convention => + { + // filter out matched endpoints that are suppressed + if (convention.Metadata.OfType().FirstOrDefault()?.SuppressMatching != true) + { + // Get the controller action descriptor + ControllerActionDescriptor actionDescriptor = convention.Metadata.OfType().FirstOrDefault(); + if (actionDescriptor != null) + { + // This is more or less like the IApplicationModelProvider, it allows us to add filters, etc... to the ControllerActionDescriptor + // dynamically. Here we will add our custom virtual page filter along with a callback in the endpoint's metadata + // to execute in order to find the IPublishedContent for the request. + + var filter = new UmbracoVirtualPageFilterAttribute(); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(filter, 0)); + convention.Metadata.Add(filter); + convention.Metadata.Add(new CustomRouteContentFinderDelegate(findContent)); + } + } + }); + } +} diff --git a/src/Umbraco.Web.Common/Filters/UmbracoVirtualPageFilterAttribute.cs b/src/Umbraco.Web.Common/Filters/UmbracoVirtualPageFilterAttribute.cs new file mode 100644 index 0000000000..b6b372c14a --- /dev/null +++ b/src/Umbraco.Web.Common/Filters/UmbracoVirtualPageFilterAttribute.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web.Common.Controllers; +using Umbraco.Web.Common.Extensions; +using Umbraco.Web.Common.Routing; +using Umbraco.Web.Routing; + +namespace Umbraco.Web.Common.Filters +{ + /// + /// Used to set the request feature based on the specified (if any) + /// for the custom route. + /// + public class UmbracoVirtualPageFilterAttribute : Attribute, IAsyncActionFilter + { + /// + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + Endpoint endpoint = context.HttpContext.GetEndpoint(); + + // Check if there is any delegate in the metadata of the route, this + // will occur when using the ForUmbraco method during routing. + CustomRouteContentFinderDelegate contentFinder = endpoint.Metadata.OfType().FirstOrDefault(); + + if (contentFinder != null) + { + await SetUmbracoRouteValues(context, contentFinder.FindContent(context.HttpContext)); + } + else + { + // Check if the controller is IVirtualPageController and then use that to FindContent + if (context.Controller is IVirtualPageController ctrl) + { + await SetUmbracoRouteValues(context, ctrl.FindContent()); + } + } + + await next(); + } + + private async Task SetUmbracoRouteValues(ActionExecutingContext context, IPublishedContent content) + { + if (content != null) + { + IUmbracoContextAccessor umbracoContext = context.HttpContext.RequestServices.GetRequiredService(); + IPublishedRouter router = context.HttpContext.RequestServices.GetRequiredService(); + IPublishedRequestBuilder requestBuilder = await router.CreateRequestAsync(umbracoContext.UmbracoContext.CleanedUmbracoUrl); + requestBuilder.SetPublishedContent(content); + IPublishedRequest publishedRequest = requestBuilder.Build(); + + var routeValues = new UmbracoRouteValues( + publishedRequest, + (ControllerActionDescriptor)context.ActionDescriptor); + + context.HttpContext.Features.Set(routeValues); + } + } + } +} diff --git a/src/Umbraco.Web.Common/Routing/CustomRouteContentFinderDelegate.cs b/src/Umbraco.Web.Common/Routing/CustomRouteContentFinderDelegate.cs new file mode 100644 index 0000000000..e81f5f75f8 --- /dev/null +++ b/src/Umbraco.Web.Common/Routing/CustomRouteContentFinderDelegate.cs @@ -0,0 +1,15 @@ +using System; +using Microsoft.AspNetCore.Http; +using Umbraco.Core.Models.PublishedContent; + +namespace Umbraco.Web.Common.Extensions +{ + internal class CustomRouteContentFinderDelegate + { + private readonly Func _findContent; + + public CustomRouteContentFinderDelegate(Func findContent) => _findContent = findContent; + + public IPublishedContent FindContent(HttpContext httpContext) => _findContent(httpContext); + } +} diff --git a/src/Umbraco.Web.Common/Routing/RoutableDocumentFilter.cs b/src/Umbraco.Web.Common/Routing/RoutableDocumentFilter.cs index 18190f9ad9..71c01be5e0 100644 --- a/src/Umbraco.Web.Common/Routing/RoutableDocumentFilter.cs +++ b/src/Umbraco.Web.Common/Routing/RoutableDocumentFilter.cs @@ -178,7 +178,19 @@ namespace Umbraco.Web.Common.Routing // We don't want to include dynamic endpoints in this check since we would have no idea if that // matches since they will probably match everything. bool isDynamic = x.Metadata.OfType().Any(x => x.IsDynamic); - return !isDynamic; + if (isDynamic) + { + return false; + } + + // filter out matched endpoints that are suppressed + var isSuppressed = x.Metadata.OfType().FirstOrDefault()?.SuppressMatching == true; + if (isSuppressed) + { + return false; + } + + return true; }); var routeValues = new RouteValueDictionary(); diff --git a/src/Umbraco.Web.UI.NetCore/Startup.cs b/src/Umbraco.Web.UI.NetCore/Startup.cs index 7f2ede1845..c3d3d18451 100644 --- a/src/Umbraco.Web.UI.NetCore/Startup.cs +++ b/src/Umbraco.Web.UI.NetCore/Startup.cs @@ -64,7 +64,6 @@ namespace Umbraco.Web.UI.NetCore app.UseDeveloperExceptionPage(); } - app.UseUmbraco(); app.UseUmbracoBackOffice(); app.UseUmbracoWebsite(); }