using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; using Umbraco.Cms.Web.Website.Controllers; using Umbraco.Extensions; namespace Umbraco.Cms.Web.Website.Routing; /// /// Ensures the surface controller requests takes priority over other things like virtual routes. /// Also ensures that requests to a surface controller on a virtual route will return 405, like HttpMethodMatcherPolicy ensures for non-virtual route requests. /// internal class SurfaceControllerMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy { private const string Http405EndpointDisplayName = "405 HTTP Method Not Supported"; public override int Order { get; } // default order should be okay. Count be everything positive to not conflict with MS policies public bool AppliesToEndpoints(IReadOnlyList endpoints) { // In theory all endpoints can have the query string data for a surface controller return true; } public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) { ArgumentNullException.ThrowIfNull(nameof(httpContext)); ArgumentNullException.ThrowIfNull(nameof(candidates)); if (candidates.Count < 2) { return Task.CompletedTask; } int? surfaceControllerIndex = GetSurfaceControllerCandidateIndex(candidates); if (surfaceControllerIndex.HasValue) { HashSet allowedHttpMethods = GetAllowedHttpMethods(candidates[surfaceControllerIndex.Value]); if (allowedHttpMethods.Any() && allowedHttpMethods.Contains(httpContext.Request.Method) is false) { // We need to handle this as a 405 like the HttpMethodMatcherPolicy would do. httpContext.SetEndpoint(CreateRejectEndpoint(allowedHttpMethods)); httpContext.Request.RouteValues = null!; } else { // Otherwise we invalidate all other endpoints than the surface controller that matched. InvalidateAllCandidatesExceptIndex(candidates, surfaceControllerIndex.Value); } } return Task.CompletedTask; } private static HashSet GetAllowedHttpMethods(CandidateState candidate) { var surfaceControllerAllowedHttpMethods = new HashSet(StringComparer.OrdinalIgnoreCase); IHttpMethodMetadata? httpMethodMetadata = candidate.Endpoint?.Metadata.GetMetadata(); if (httpMethodMetadata is not null) { foreach (var httpMethod in httpMethodMetadata.HttpMethods) { surfaceControllerAllowedHttpMethods.Add(httpMethod); } } return surfaceControllerAllowedHttpMethods; } private static int? GetSurfaceControllerCandidateIndex(CandidateSet candidates) { for (var i = 0; i < candidates.Count; i++) { if (candidates.IsValidCandidate(i)) { CandidateState candidate = candidates[i]; ControllerActionDescriptor? controllerActionDescriptor = candidate.Endpoint?.Metadata.GetMetadata(); if (controllerActionDescriptor?.ControllerTypeInfo.IsType() == true) { return i; } } } return null; } private static void InvalidateAllCandidatesExceptIndex(CandidateSet candidates, int index) { for (var i = 0; i < candidates.Count; i++) { if (i != index) { candidates.SetValidity(i, false); } } } private static Endpoint CreateRejectEndpoint(ISet allowedHttpMethods) => new Endpoint( (context) => { context.Response.StatusCode = 405; context.Response.Headers.Allow = string.Join(", ", allowedHttpMethods); return Task.CompletedTask; }, EndpointMetadataCollection.Empty, Http405EndpointDisplayName); }