diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformerTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformerTests.cs index 47fcbd010c..d00f85fd0a 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformerTests.cs +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformerTests.cs @@ -14,6 +14,7 @@ using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; @@ -92,28 +93,28 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Routing => Mock.Of(x => x.RouteRequestAsync(It.IsAny(), It.IsAny()) == Task.FromResult(request)); [Test] - public async Task Noop_When_Runtime_Level_Not_Run() + public async Task Null_When_Runtime_Level_Not_Run() { UmbracoRouteValueTransformer transformer = GetTransformer( Mock.Of(), Mock.Of()); RouteValueDictionary result = await transformer.TransformAsync(new DefaultHttpContext(), new RouteValueDictionary()); - Assert.AreEqual(0, result.Count); + Assert.IsNull(result); } [Test] - public async Task Noop_When_No_Umbraco_Context() + public async Task Null_When_No_Umbraco_Context() { UmbracoRouteValueTransformer transformer = GetTransformerWithRunState( Mock.Of()); RouteValueDictionary result = await transformer.TransformAsync(new DefaultHttpContext(), new RouteValueDictionary()); - Assert.AreEqual(0, result.Count); + Assert.IsNull(result); } [Test] - public async Task Noop_When_Not_Document_Request() + public async Task Null_When_Not_Document_Request() { var umbracoContext = Mock.Of(); UmbracoRouteValueTransformer transformer = GetTransformerWithRunState( @@ -121,7 +122,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Routing Mock.Of(x => x.IsDocumentRequest(It.IsAny()) == false)); RouteValueDictionary result = await transformer.TransformAsync(new DefaultHttpContext(), new RouteValueDictionary()); - Assert.AreEqual(0, result.Count); + Assert.IsNull(result); } [Test] @@ -173,10 +174,10 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Routing } [Test] - public async Task Assigns_Values_To_RouteValueDictionary() + public async Task Assigns_Values_To_RouteValueDictionary_When_Content() { IUmbracoContext umbracoContext = GetUmbracoContext(true); - IPublishedRequest request = Mock.Of(); + IPublishedRequest request = Mock.Of(x => x.PublishedContent == Mock.Of()); UmbracoRouteValues routeValues = GetRouteValues(request); UmbracoRouteValueTransformer transformer = GetTransformerWithRunState( @@ -190,6 +191,23 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Routing Assert.AreEqual(routeValues.ActionName, result[ActionToken]); } + [Test] + public async Task Returns_Null_RouteValueDictionary_When_No_Content() + { + IUmbracoContext umbracoContext = GetUmbracoContext(true); + IPublishedRequest request = Mock.Of(x => x.PublishedContent == null); + UmbracoRouteValues routeValues = GetRouteValues(request); + + UmbracoRouteValueTransformer transformer = GetTransformerWithRunState( + Mock.Of(x => x.TryGetUmbracoContext(out umbracoContext)), + router: GetRouter(request), + routeValuesFactory: GetRouteValuesFactory(request)); + + RouteValueDictionary result = await transformer.TransformAsync(new DefaultHttpContext(), new RouteValueDictionary()); + + Assert.IsNull(result); + } + private class TestController : RenderController { public TestController(ILogger logger, ICompositeViewEngine compositeViewEngine, IUmbracoContextAccessor umbracoContextAccessor) diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs index 62ec5a9921..72d8ec58ce 100644 --- a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Infrastructure.DependencyInjection; @@ -11,6 +13,7 @@ using Umbraco.Cms.Web.Website.Middleware; using Umbraco.Cms.Web.Website.Models; using Umbraco.Cms.Web.Website.Routing; using Umbraco.Cms.Web.Website.ViewEngines; +using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor; namespace Umbraco.Extensions { @@ -38,8 +41,9 @@ namespace Umbraco.Extensions builder.Services.AddDataProtection(); builder.Services.AddAntiforgery(); - builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.TryAddEnumerable(Singleton()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Web.Website/Routing/NotFoundSelectorPolicy.cs b/src/Umbraco.Web.Website/Routing/NotFoundSelectorPolicy.cs new file mode 100644 index 0000000000..dbc06175fd --- /dev/null +++ b/src/Umbraco.Web.Website/Routing/NotFoundSelectorPolicy.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Web.Common.Controllers; +using Umbraco.Cms.Web.Common.Routing; + +namespace Umbraco.Cms.Web.Website.Routing +{ + /// + /// Used to handle 404 routes that haven't been handled by the end user + /// + internal class NotFoundSelectorPolicy : MatcherPolicy, IEndpointSelectorPolicy + { + private readonly Lazy _notFound; + private readonly EndpointDataSource _endpointDataSource; + + public NotFoundSelectorPolicy(EndpointDataSource endpointDataSource) + { + _notFound = new Lazy(GetNotFoundEndpoint); + _endpointDataSource = endpointDataSource; + } + + // return the endpoint for the RenderController.Index action. + private Endpoint GetNotFoundEndpoint() + { + Endpoint e = _endpointDataSource.Endpoints.First(x => + { + // return the endpoint for the RenderController.Index action. + ControllerActionDescriptor descriptor = x.Metadata?.GetMetadata(); + return descriptor?.ControllerTypeInfo == typeof(RenderController) + && descriptor?.ActionName == nameof(RenderController.Index); + }); + return e; + } + + public override int Order => 0; + + public bool AppliesToEndpoints(IReadOnlyList endpoints) + { + // Don't apply this filter to any endpoint group that is a controller route + // i.e. only dynamic routes. + foreach (Endpoint endpoint in endpoints) + { + ControllerAttribute controller = endpoint.Metadata?.GetMetadata(); + if (controller != null) + { + return false; + } + } + + // then ensure this is only applied if all endpoints are IDynamicEndpointMetadata + return endpoints.All(x => x.Metadata?.GetMetadata() != null); + } + + public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) + { + if (AllInvalid(candidates)) + { + UmbracoRouteValues umbracoRouteValues = httpContext.Features.Get(); + if (umbracoRouteValues?.PublishedRequest != null + && !umbracoRouteValues.PublishedRequest.HasPublishedContent() + && umbracoRouteValues.PublishedRequest.ResponseStatusCode == StatusCodes.Status404NotFound) + { + // not found/404 + httpContext.SetEndpoint(_notFound.Value); + } + } + + return Task.CompletedTask; + } + + private static bool AllInvalid(CandidateSet candidates) + { + for (int i = 0; i < candidates.Count; i++) + { + if (candidates.IsValidCandidate(i)) + { + return false; + } + } + return true; + } + } +} diff --git a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs index 41fd20a69b..fc54350097 100644 --- a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs +++ b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs @@ -28,6 +28,7 @@ using RouteDirection = Umbraco.Cms.Core.Routing.RouteDirection; namespace Umbraco.Cms.Web.Website.Routing { + /// /// The route value transformer for Umbraco front-end routes /// @@ -92,31 +93,41 @@ namespace Umbraco.Cms.Web.Website.Routing // If we aren't running, then we have nothing to route if (_runtime.Level != RuntimeLevel.Run) { - return values; + return null; } // will be null for any client side requests like JS, etc... - if (!_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext)) + if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext umbracoContext)) { - return values; + return null; } if (!_routableDocumentFilter.IsDocumentRequest(httpContext.Request.Path)) { - return values; + return null; + } + + // Don't execute if there are already UmbracoRouteValues assigned. + // This can occur if someone else is dynamically routing and in which case we don't want to overwrite + // the routing work being done there. + UmbracoRouteValues umbracoRouteValues = httpContext.Features.Get(); + if (umbracoRouteValues != null) + { + return null; } // Check if there is no existing content and return the no content controller if (!umbracoContext.Content.HasContent()) { - values[ControllerToken] = ControllerExtensions.GetControllerName(); - values[ActionToken] = nameof(RenderNoContentController.Index); - - return values; + return new RouteValueDictionary + { + [ControllerToken] = ControllerExtensions.GetControllerName(), + [ActionToken] = nameof(RenderNoContentController.Index) + }; } IPublishedRequest publishedRequest = await RouteRequestAsync(httpContext, umbracoContext); - UmbracoRouteValues umbracoRouteValues = await _routeValuesFactory.CreateAsync(httpContext, publishedRequest); + umbracoRouteValues = await _routeValuesFactory.CreateAsync(httpContext, publishedRequest); // Store the route values as a httpcontext feature httpContext.Features.Set(umbracoRouteValues); @@ -125,16 +136,32 @@ namespace Umbraco.Cms.Web.Website.Routing PostedDataProxyInfo postedInfo = GetFormInfo(httpContext, values); if (postedInfo != null) { - return HandlePostedValues(postedInfo, httpContext, values); + return HandlePostedValues(postedInfo, httpContext); } - values[ControllerToken] = umbracoRouteValues.ControllerName; + if (!umbracoRouteValues?.PublishedRequest?.HasPublishedContent() ?? false) + { + // No content was found, not by any registered 404 handlers and + // not by the IContentLastChanceFinder. In this case we want to return + // our default 404 page but we cannot return route values now because + // it's possible that a developer is handling dynamic routes too. + // Our 404 page will be handled with the NotFoundSelectorPolicy + return null; + } + + // See https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.routing.dynamicroutevaluetransformer.transformasync?view=aspnetcore-5.0#Microsoft_AspNetCore_Mvc_Routing_DynamicRouteValueTransformer_TransformAsync_Microsoft_AspNetCore_Http_HttpContext_Microsoft_AspNetCore_Routing_RouteValueDictionary_ + // We should apparenlty not be modified these values. + // So we create new ones. + var newValues = new RouteValueDictionary + { + [ControllerToken] = umbracoRouteValues.ControllerName + }; if (string.IsNullOrWhiteSpace(umbracoRouteValues.ActionName) == false) { - values[ActionToken] = umbracoRouteValues.ActionName; + newValues[ActionToken] = umbracoRouteValues.ActionName; } - return values; + return newValues; } private async Task RouteRequestAsync(HttpContext httpContext, IUmbracoContext umbracoContext) @@ -196,11 +223,14 @@ namespace Umbraco.Cms.Web.Website.Routing }; } - private RouteValueDictionary HandlePostedValues(PostedDataProxyInfo postedInfo, HttpContext httpContext, RouteValueDictionary values) + private RouteValueDictionary HandlePostedValues(PostedDataProxyInfo postedInfo, HttpContext httpContext) { // set the standard route values/tokens - values[ControllerToken] = postedInfo.ControllerName; - values[ActionToken] = postedInfo.ActionName; + var values = new RouteValueDictionary + { + [ControllerToken] = postedInfo.ControllerName, + [ActionToken] = postedInfo.ActionName + }; ControllerActionDescriptor surfaceControllerDescriptor = _controllerActionSearcher.Find(httpContext, postedInfo.ControllerName, postedInfo.ActionName);