From 599ec18ecc6b2890d96654c19a516f704aef1cf6 Mon Sep 17 00:00:00 2001 From: Lars-Erik Date: Thu, 4 Apr 2024 13:44:20 +0200 Subject: [PATCH 1/6] Implementors using Umbraco.Tests.Integration won't have to override GetLocalizedTextService (cherry picked from commit b0016687eb583a549da8992f5fba92e269b4cbfa) (cherry picked from commit 2bb56f1b81a84df3ef03ba0170f741cea6007c28) --- .../DependencyInjection/UmbracoBuilderExtensions.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs index dac260b985..bae9c7efbe 100644 --- a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs @@ -123,7 +123,18 @@ public static class UmbracoBuilderExtensions var currFolder = new DirectoryInfo(srcFolder); - var uiProject = currFolder.GetDirectories("Umbraco.Web.UI", SearchOption.TopDirectoryOnly).First(); + if (!currFolder.Exists) + { + currFolder = new DirectoryInfo(Path.GetTempPath()); + } + + var uiProject = currFolder.GetDirectories("Umbraco.Web.UI", SearchOption.TopDirectoryOnly).FirstOrDefault(); + if (uiProject == null) + { + uiProject = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "Umbraco.Web.UI")); + uiProject.Create(); + } + var mainLangFolder = new DirectoryInfo(Path.Combine(uiProject.FullName, globalSettings.Value.UmbracoPath.TrimStart("~/"), "config", "lang")); return new LocalizedTextServiceFileSources( From 5b46c718e69e902ff7f82a68bd780d51cd2c0caf Mon Sep 17 00:00:00 2001 From: Joshua Daniel Pratt Nielsen Date: Sat, 20 Apr 2024 01:32:55 +0200 Subject: [PATCH 2/6] Fix logic for retrieving lastKnownElement (cherry picked from commit cae106bfe8fa11c080fc90ba4354a0791e47f9a2) --- .../forms/umbfocuslock.directive.js | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/forms/umbfocuslock.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/forms/umbfocuslock.directive.js index 69c11a11cc..3d412d34e1 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/forms/umbfocuslock.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/forms/umbfocuslock.directive.js @@ -88,26 +88,27 @@ // If an infinite editor is being closed then we reset the focus to the element that triggered the the overlay if(closingEditor){ - // If there is only one editor open, search for the "editor-info" inside it and set focus on it // This is relevant when a property editor has been selected and the editor where we selected it from // is closed taking us back to the first layer // Otherwise set it to the last element in the lastKnownFocusedElements array - if(infiniteEditors && infiniteEditors.length === 1){ - var editorInfo = infiniteEditors[0].querySelector('.editor-info'); - if(infiniteEditors && infiniteEditors.length === 1 && editorInfo !== null) { - lastKnownElement = editorInfo; - // Clear the array - clearLastKnownFocusedElements(); - } + var editorInfo = (infiniteEditors && infiniteEditors.length === 1) + ? infiniteEditors[0].querySelector('.editor-info') + : null; + + if(editorInfo !== null){ + lastKnownElement = editorInfo; + + // Clear the array + clearLastKnownFocusedElements(); } - else { - var lastItemIndex = $rootScope.lastKnownFocusableElements.length - 1; - lastKnownElement = $rootScope.lastKnownFocusableElements[lastItemIndex]; + else{ + var lastIndex = $rootScope.lastKnownFocusableElements.length - 1; + lastKnownElement = $rootScope.lastKnownFocusableElements[lastIndex]; - // Remove the last item from the array so we always set the correct lastKnowFocus for each layer - $rootScope.lastKnownFocusableElements.splice(lastItemIndex, 1); + // Remove the last item from the array so we always set the correct lastKnowFocus for each layer + $rootScope.lastKnownFocusableElements.splice(lastIndex, 1); } // Update the lastknowelement variable here From b6031dea1a86f15e0271d711aa14806ec5b1758a Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 3 May 2024 09:27:20 +0200 Subject: [PATCH 3/6] bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 6ab0f37af9..fcf4a18f98 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "13.3.0", + "version": "13.3.1", "assemblyVersion": { "precision": "build" }, From 18765465ae7ce5608d5e2e3b5c4a0d53030c6d1c Mon Sep 17 00:00:00 2001 From: Mole Date: Fri, 10 May 2024 11:36:12 +0200 Subject: [PATCH 4/6] V13: Optimize custom MVC routing (#16218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduce EagerMatcherPolicy to conditionally bypass content routing * Ensure that the candidate we disable dynamic routing for is valid * Skip Umbraco endpoints * Simplify logic a bit * Move install logic to matcher * Ensure that dynamic routing is still skipped when in upgrade state * Fixup comments * Reduce nesting a bit * Don't show maintenance page when statically routed controllers are hít * Remove excess check, since installer requests are statically routed (cherry picked from commit ba9ddd11da66b6ecdf72fdbfc660234d63843bb8) --- src/Umbraco.Core/Constants-Web.cs | 1 + .../UmbracoBuilderExtensions.cs | 1 + .../UmbracoApplicationBuilder.Website.cs | 3 +- .../Routing/EagerMatcherPolicy.cs | 229 ++++++++++++++++++ .../Routing/UmbracoRouteValueTransformer.cs | 28 --- 5 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index 0c39c1b1b0..1364abac5e 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -62,6 +62,7 @@ public static partial class Constants public const string ControllerToken = "controller"; public const string ActionToken = "action"; public const string AreaToken = "area"; + public const string DynamicRoutePattern = "/{**umbracoSlug}"; } public static class RoutePath diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs index dc98c5b813..f182bda7b6 100644 --- a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs @@ -64,6 +64,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilder.Website.cs b/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilder.Website.cs index 549c0844ff..e527724add 100644 --- a/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilder.Website.cs +++ b/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilder.Website.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core; using Umbraco.Cms.Web.Common.ApplicationBuilder; using Umbraco.Cms.Web.Common.Middleware; using Umbraco.Cms.Web.Website.Routing; @@ -39,7 +40,7 @@ public static class UmbracoApplicationBuilderExtensions FrontEndRoutes surfaceRoutes = builder.ApplicationServices.GetRequiredService(); surfaceRoutes.CreateRoutes(builder.EndpointRouteBuilder); - builder.EndpointRouteBuilder.MapDynamicControllerRoute("/{**slug}"); + builder.EndpointRouteBuilder.MapDynamicControllerRoute(Constants.Web.Routing.DynamicRoutePattern); return builder; } diff --git a/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs b/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs new file mode 100644 index 0000000000..3fe0814a15 --- /dev/null +++ b/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs @@ -0,0 +1,229 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Controllers; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Website.Routing; + + +/** + * A matcher policy that discards the catch-all (slug) route if there are any other valid routes with a lower order. + * + * The purpose of this is to skip our expensive if it's not required, + * for instance if there's a statically routed endpoint registered before the dynamic route, + * for more information see: https://github.com/umbraco/Umbraco-CMS/issues/16015. + * The core reason why this is necessary is that ALL routes get evaluated: + * " + * all routes get evaluated, they get to produce candidates and then the best candidate is selected. + * Since you have a dynamic route, it needs to run to produce the final endpoints and + * then those are ranked in along with the rest of the candidates to choose the final endpoint. + * " + * From: https://github.com/dotnet/aspnetcore/issues/45175#issuecomment-1322497958 + * + * This also handles rerouting under install/upgrade states. + */ + +internal class EagerMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy +{ + private readonly IRuntimeState _runtimeState; + private readonly EndpointDataSource _endpointDataSource; + private readonly UmbracoRequestPaths _umbracoRequestPaths; + private GlobalSettings _globalSettings; + private readonly Lazy _installEndpoint; + private readonly Lazy _renderEndpoint; + + public EagerMatcherPolicy( + IRuntimeState runtimeState, + EndpointDataSource endpointDataSource, + UmbracoRequestPaths umbracoRequestPaths, + IOptionsMonitor globalSettings) + { + _runtimeState = runtimeState; + _endpointDataSource = endpointDataSource; + _umbracoRequestPaths = umbracoRequestPaths; + _globalSettings = globalSettings.CurrentValue; + globalSettings.OnChange(settings => _globalSettings = settings); + _installEndpoint = new Lazy(GetInstallEndpoint); + _renderEndpoint = new Lazy(GetRenderEndpoint); + } + + // We want this to run as the very first policy, so we can discard the UmbracoRouteValueTransformer before the framework runs it. + public override int Order => int.MinValue + 10; + + // We know we don't have to run this matcher against the backoffice endpoints. + public bool AppliesToEndpoints(IReadOnlyList endpoints) => true; + + public async Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) + { + if (_runtimeState.Level != RuntimeLevel.Run) + { + var handled = await HandleInstallUpgrade(httpContext, candidates); + if (handled) + { + return; + } + } + + // If there's only one candidate, we don't need to do anything. + if (candidates.Count < 2) + { + return; + } + + // If there are multiple candidates, we want to discard the catch-all (slug) + // IF there is any candidates with a lower order. Since this will be a statically routed endpoint registered before the dynamic route. + // Which means that we don't have to run our UmbracoRouteValueTransformer to route dynamically (expensive). + var lowestOrder = int.MaxValue; + int? dynamicId = null; + RouteEndpoint? dynamicEndpoint = null; + for (var i = 0; i < candidates.Count; i++) + { + CandidateState candidate = candidates[i]; + + // If it's not a RouteEndpoint there's not much we can do to count it in the order. + if (candidate.Endpoint is not RouteEndpoint routeEndpoint) + { + continue; + } + + if (routeEndpoint.Order < lowestOrder) + { + // We have to ensure that the route is valid for the current request method. + // This is because attribute routing will always have an order of 0. + // This means that you could attribute route a POST to /example, but also have an umbraco page at /example + // This would then result in a 404, because we'd see the attribute route with order 0, and always consider that the lowest order + // We'd then disable the dynamic endpoint since another endpoint has a lower order, and end up with only 1 invalid endpoint. + // (IsValidCandidate does not take this into account since the candidate itself is still valid) + HttpMethodMetadata? methodMetaData = routeEndpoint.Metadata.GetMetadata(); + if (methodMetaData?.HttpMethods.Contains(httpContext.Request.Method) is false) + { + continue; + } + + lowestOrder = routeEndpoint.Order; + } + + // We only want to consider our dynamic route, this way it's still possible to register your own custom route before ours. + if (routeEndpoint.DisplayName != Constants.Web.Routing.DynamicRoutePattern) + { + continue; + } + + dynamicEndpoint = routeEndpoint; + dynamicId = i; + } + + // Invalidate the dynamic route if another route has a lower order. + // This means that if you register your static route after the dynamic route, the dynamic route will take precedence + // This more closely resembles the existing behaviour. + if (dynamicEndpoint is not null && dynamicId is not null && dynamicEndpoint.Order > lowestOrder) + { + candidates.SetValidity(dynamicId.Value, false); + } + } + + /// + /// Replaces the first endpoint candidate with the specified endpoint, invalidating all other candidates, + /// guaranteeing that the specified endpoint will be hit. + /// + /// The candidate set to manipulate. + /// The target endpoint that will be hit. + /// + private static void SetEndpoint(CandidateSet candidates, Endpoint endpoint, RouteValueDictionary routeValueDictionary) + { + candidates.ReplaceEndpoint(0, endpoint, routeValueDictionary); + + for (int i = 1; i < candidates.Count; i++) + { + candidates.SetValidity(1, false); + } + } + + private Endpoint GetInstallEndpoint() + { + Endpoint endpoint = _endpointDataSource.Endpoints.First(x => + { + ControllerActionDescriptor? descriptor = x.Metadata.GetMetadata(); + return descriptor?.ControllerTypeInfo.Name == "InstallController" + && descriptor.ActionName == "Index"; + }); + + return endpoint; + } + + private Endpoint GetRenderEndpoint() + { + Endpoint endpoint = _endpointDataSource.Endpoints.First(x => + { + ControllerActionDescriptor? descriptor = x.Metadata.GetMetadata(); + return descriptor?.ControllerTypeInfo == typeof(RenderController) + && descriptor.ActionName == nameof(RenderController.Index); + }); + + return endpoint; + } + + private Task HandleInstallUpgrade(HttpContext httpContext, CandidateSet candidates) + { + if (_runtimeState.Level != RuntimeLevel.Upgrade) + { + // We need to let the installer API requests through + // Currently we do this with a check for the installer path + // Ideally we should do this in a more robust way, for instance with a dedicated attribute we can then check for. + if (_umbracoRequestPaths.IsInstallerRequest(httpContext.Request.Path)) + { + return Task.FromResult(true); + } + + SetEndpoint(candidates, _installEndpoint.Value, new RouteValueDictionary + { + [Constants.Web.Routing.ControllerToken] = "Install", + [Constants.Web.Routing.ActionToken] = "Index", + [Constants.Web.Routing.AreaToken] = Constants.Web.Mvc.InstallArea, + }); + + return Task.FromResult(true); + } + + // Check if maintenance page should be shown + // Current behaviour is that statically routed endpoints still work in upgrade state + // This means that IF there is a static route, we should not show the maintenance page. + // And instead carry on as we normally would. + var hasStaticRoute = false; + for (var i = 0; i < candidates.Count; i++) + { + CandidateState candidate = candidates[i]; + IDynamicEndpointMetadata? dynamicEndpointMetadata = candidate.Endpoint.Metadata.GetMetadata(); + if (dynamicEndpointMetadata is null || dynamicEndpointMetadata.IsDynamic is false) + { + hasStaticRoute = true; + break; + } + } + + if (_runtimeState.Level != RuntimeLevel.Upgrade + || _globalSettings.ShowMaintenancePageWhenInUpgradeState is false + || hasStaticRoute) + { + return Task.FromResult(false); + } + + // Otherwise we'll re-route to the render controller (this will in turn show the maintenance page through a filter) + // With this approach however this could really just be a plain old endpoint instead of a filter. + SetEndpoint(candidates, _renderEndpoint.Value, new RouteValueDictionary + { + [Constants.Web.Routing.ControllerToken] = ControllerExtensions.GetControllerName(), + [Constants.Web.Routing.ActionToken] = nameof(RenderController.Index), + }); + + return Task.FromResult(true); + + } +} diff --git a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs index 2afba1e0bb..ee4195221c 100644 --- a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs +++ b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs @@ -139,23 +139,6 @@ public class UmbracoRouteValueTransformer : DynamicRouteValueTransformer public override async ValueTask TransformAsync( HttpContext httpContext, RouteValueDictionary values) { - // If we aren't running, then we have nothing to route. We allow the frontend to continue while in upgrade mode. - if (_runtime.Level != RuntimeLevel.Run && _runtime.Level != RuntimeLevel.Upgrade) - { - if (_runtime.Level == RuntimeLevel.Install) - { - return new RouteValueDictionary() - { - //TODO figure out constants - [ControllerToken] = "Install", - [ActionToken] = "Index", - [AreaToken] = Constants.Web.Mvc.InstallArea, - }; - } - - return null!; - } - // will be null for any client side requests like JS, etc... if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext)) { @@ -172,17 +155,6 @@ public class UmbracoRouteValueTransformer : DynamicRouteValueTransformer return null!; } - // Check if the maintenance page should be shown - if (_runtime.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState) - { - return new RouteValueDictionary - { - // Redirects to the RenderController who handles maintenance page in a filter, instead of having a dedicated controller - [ControllerToken] = ControllerExtensions.GetControllerName(), - [ActionToken] = nameof(RenderController.Index), - }; - } - // Check if there is no existing content and return the no content controller if (!umbracoContext.Content?.HasContent() ?? false) { From 94cef504a3e10a6bc2b577e7d0ba3cd80af95175 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Mon, 13 May 2024 15:44:07 +0200 Subject: [PATCH 5/6] Property source level variation should only be applied when configured (#16270) (cherry picked from commit ab32bac5d96695eebe4f333a9bd6fa8d8b820f71) --- .../Property.cs | 15 ++++++++----- .../PublishedContentVarianceTests.cs | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.PublishedCache.NuCache/Property.cs b/src/Umbraco.PublishedCache.NuCache/Property.cs index 596bae2090..ed9f7277ef 100644 --- a/src/Umbraco.PublishedCache.NuCache/Property.cs +++ b/src/Umbraco.PublishedCache.NuCache/Property.cs @@ -25,7 +25,7 @@ internal class Property : PublishedPropertyBase // the invariant-neutral source and inter values private readonly object? _sourceValue; private readonly ContentVariation _variations; - private bool _sourceValueIsInvariant; + private readonly ContentVariation _sourceVariations; // the variant and non-variant object values private CacheValues? _cacheValues; @@ -88,7 +88,7 @@ internal class Property : PublishedPropertyBase // this variable is used for contextualizing the variation level when calculating property values. // it must be set to the union of variance (the combination of content type and property type variance). _variations = propertyType.Variations | content.ContentType.Variations; - _sourceValueIsInvariant = propertyType.Variations is ContentVariation.Nothing; + _sourceVariations = propertyType.Variations; } // clone for previewing as draft a published content that is published and has no draft @@ -104,7 +104,7 @@ internal class Property : PublishedPropertyBase _isMember = origin._isMember; _publishedSnapshotAccessor = origin._publishedSnapshotAccessor; _variations = origin._variations; - _sourceValueIsInvariant = origin._sourceValueIsInvariant; + _sourceVariations = origin._sourceVariations; } // used to cache the CacheValues of this property @@ -148,9 +148,14 @@ internal class Property : PublishedPropertyBase public override object? GetSourceValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + _content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, ref culture, ref segment); - if (_sourceValueIsInvariant || (culture == string.Empty && segment == string.Empty)) + // source values are tightly bound to the property/schema culture and segment configurations, so we need to + // sanitize the contextualized culture/segment states before using them to access the source values. + culture = _sourceVariations.VariesByCulture() ? culture : string.Empty; + segment = _sourceVariations.VariesBySegment() ? segment : string.Empty; + + if (culture == string.Empty && segment == string.Empty) { return _sourceValue; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/PublishedContentVarianceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/PublishedContentVarianceTests.cs index 7d117b96c5..a4a15b8f22 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/PublishedContentVarianceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/PublishedContentVarianceTests.cs @@ -76,6 +76,28 @@ public class PublishedContentVarianceTests Assert.AreEqual(expectedValue, value); } + [TestCase(DaCulture, Segment1, "DaDk property value")] + [TestCase(DaCulture, Segment2, "DaDk property value")] + [TestCase(EnCulture, Segment1, "EnUs property value")] + [TestCase(EnCulture, Segment2, "EnUs property value")] + public void Content_Culture_And_Segment_Variation_Can_Get_Culture_Variant_Property(string culture, string segment, string expectedValue) + { + var content = CreatePublishedContent(ContentVariation.CultureAndSegment, ContentVariation.Culture, variationContextCulture: culture, variationContextSegment: segment); + var value = GetPropertyValue(content); + Assert.AreEqual(expectedValue, value); + } + + [TestCase(DaCulture, Segment1, "Segment1 property value")] + [TestCase(DaCulture, Segment2, "Segment2 property value")] + [TestCase(EnCulture, Segment1, "Segment1 property value")] + [TestCase(EnCulture, Segment2, "Segment2 property value")] + public void Content_Culture_And_Segment_Variation_Can_Get_Segment_Variant_Property(string culture, string segment, string expectedValue) + { + var content = CreatePublishedContent(ContentVariation.CultureAndSegment, ContentVariation.Segment, variationContextCulture: culture, variationContextSegment: segment); + var value = GetPropertyValue(content); + Assert.AreEqual(expectedValue, value); + } + private object? GetPropertyValue(IPublishedContent content) => content.GetProperty(PropertyTypeAlias)!.GetValue(); private IPublishedContent CreatePublishedContent(ContentVariation contentTypeVariation, ContentVariation propertyTypeVariation, string? variationContextCulture = null, string? variationContextSegment = null) From 5f24de308584b9771240a6db1a34630a5114c450 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 17 May 2024 08:37:51 +0200 Subject: [PATCH 6/6] Merge pull request from GHSA-j74q-mv2c-rxmp --- src/Umbraco.Core/Routing/WebPath.cs | 24 ++++++ .../Controllers/ImagesController.cs | 3 +- .../Controllers/PreviewController.cs | 4 +- .../Umbraco.Core/Routing/WebPathTests.cs | 83 +++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Core/Routing/WebPath.cs b/src/Umbraco.Core/Routing/WebPath.cs index 7ecafff8a3..47c6b15871 100644 --- a/src/Umbraco.Core/Routing/WebPath.cs +++ b/src/Umbraco.Core/Routing/WebPath.cs @@ -50,4 +50,28 @@ public class WebPath return sb.ToString(); } + + + /// + /// Determines whether the provided web path is well-formed according to the specified UriKind. + /// + /// The web path to check. This can be null. + /// The kind of Uri (Absolute, Relative, or RelativeOrAbsolute). + /// + /// true if is well-formed; otherwise, false. + /// + public static bool IsWellFormedWebPath(string? webPath, UriKind uriKind) + { + if (string.IsNullOrWhiteSpace(webPath)) + { + return false; + } + + if (webPath.StartsWith("//")) + { + return uriKind is not UriKind.Relative; + } + + return Uri.IsWellFormedUriString(webPath, uriKind); + } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs b/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs index 90ef6e6cf4..b70661c0af 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs @@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; @@ -122,7 +123,7 @@ public class ImagesController : UmbracoAuthorizedApiController private bool IsAllowed(string encodedImagePath) { - if(Uri.IsWellFormedUriString(encodedImagePath, UriKind.Relative)) + if(WebPath.IsWellFormedWebPath(encodedImagePath, UriKind.Relative)) { return true; } diff --git a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs index 19ca323d9d..17875c2950 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; @@ -152,8 +153,7 @@ public class PreviewController : Controller // Expire Client-side cookie that determines whether the user has accepted to be in Preview Mode when visiting the website. _cookieManager.ExpireCookie(Constants.Web.AcceptPreviewCookieName); - if (Uri.IsWellFormedUriString(redir, UriKind.Relative) - && redir.StartsWith("//") == false + if (WebPath.IsWellFormedWebPath(redir, UriKind.Relative) && Uri.TryCreate(redir, UriKind.Relative, out Uri? url)) { return Redirect(url.ToString()); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/WebPathTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/WebPathTests.cs index 4072e3df8b..72ab9150bc 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/WebPathTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/WebPathTests.cs @@ -30,4 +30,87 @@ public class WebPathTests [Test] public void Combine_must_handle_null() => Assert.Throws(() => WebPath.Combine(null)); + + + [Test] + [TestCase("ftp://hello.com/", UriKind.Absolute, ExpectedResult = true)] + [TestCase("file:///hello.com/", UriKind.Absolute, ExpectedResult = true)] + [TestCase("ws://hello.com/", UriKind.Absolute, ExpectedResult = true)] + [TestCase("wss://hello.com/", UriKind.Absolute, ExpectedResult = true)] + [TestCase("https://hello.com:8080/", UriKind.Absolute, ExpectedResult = true)] + [TestCase("http://hello.com:8080/", UriKind.Absolute, ExpectedResult = true)] + [TestCase("https://hello.com/path", UriKind.Absolute, ExpectedResult = true)] + [TestCase("http://hello.com/path", UriKind.Absolute, ExpectedResult = true)] + [TestCase("https://hello.com/path?query=param", UriKind.Absolute, ExpectedResult = true)] + [TestCase("http://hello.com/path?query=param", UriKind.Absolute, ExpectedResult = true)] + [TestCase("https://hello.com/path#fragment", UriKind.Absolute, ExpectedResult = true)] + [TestCase("http://hello.com/path#fragment", UriKind.Absolute, ExpectedResult = true)] + [TestCase("https://hello.com/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)] + [TestCase("http://hello.com/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)] + [TestCase("https://hello.com:8080/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)] + [TestCase("http://hello.com:8080/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)] + [TestCase("//hello.com:8080/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)] + [TestCase("//hello.com:8080/path", UriKind.Absolute, ExpectedResult = true)] + [TestCase("//hello.com:8080", UriKind.Absolute, ExpectedResult = true)] + [TestCase("//hello.com", UriKind.Absolute, ExpectedResult = true)] + [TestCase("/test/test.jpg", UriKind.Absolute, ExpectedResult = false)] + [TestCase("/test", UriKind.Absolute, ExpectedResult = false)] + [TestCase("test", UriKind.Absolute, ExpectedResult = false)] + [TestCase("", UriKind.Absolute, ExpectedResult = false)] + [TestCase(null, UriKind.Absolute, ExpectedResult = false)] + [TestCase("this is not welformed", UriKind.Absolute, ExpectedResult = false)] + [TestCase("ftp://hello.com/", UriKind.Relative, ExpectedResult = false)] + [TestCase("file:///hello.com/", UriKind.Relative, ExpectedResult = false)] + [TestCase("ws://hello.com/", UriKind.Relative, ExpectedResult = false)] + [TestCase("wss://hello.com/", UriKind.Relative, ExpectedResult = false)] + [TestCase("https://hello.com:8080/", UriKind.Relative, ExpectedResult = false)] + [TestCase("http://hello.com:8080/", UriKind.Relative, ExpectedResult = false)] + [TestCase("https://hello.com/path", UriKind.Relative, ExpectedResult = false)] + [TestCase("http://hello.com/path", UriKind.Relative, ExpectedResult = false)] + [TestCase("https://hello.com/path?query=param", UriKind.Relative, ExpectedResult = false)] + [TestCase("http://hello.com/path?query=param", UriKind.Relative, ExpectedResult = false)] + [TestCase("https://hello.com/path#fragment", UriKind.Relative, ExpectedResult = false)] + [TestCase("http://hello.com/path#fragment", UriKind.Relative, ExpectedResult = false)] + [TestCase("https://hello.com/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)] + [TestCase("http://hello.com/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)] + [TestCase("https://hello.com:8080/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)] + [TestCase("http://hello.com:8080/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)] + [TestCase("//hello.com:8080/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)] + [TestCase("//hello.com:8080/path", UriKind.Relative, ExpectedResult = false)] + [TestCase("//hello.com:8080", UriKind.Relative, ExpectedResult = false)] + [TestCase("//hello.com", UriKind.Relative, ExpectedResult = false)] + [TestCase("/test/test.jpg", UriKind.Relative, ExpectedResult = true)] + [TestCase("/test", UriKind.Relative, ExpectedResult = true)] + [TestCase("test", UriKind.Relative, ExpectedResult = true)] + [TestCase("", UriKind.Relative, ExpectedResult = false)] + [TestCase(null, UriKind.Relative, ExpectedResult = false)] + [TestCase("this is not welformed", UriKind.Relative, ExpectedResult = false)] + [TestCase("ftp://hello.com/", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("file:///hello.com/", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("ws://hello.com/", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("wss://hello.com/", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("https://hello.com:8080/", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("http://hello.com:8080/", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("https://hello.com/path", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("http://hello.com/path", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("https://hello.com/path?query=param", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("http://hello.com/path?query=param", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("https://hello.com/path#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("http://hello.com/path#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("https://hello.com/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("http://hello.com/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("https://hello.com:8080/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("http://hello.com:8080/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("//hello.com:8080/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("//hello.com:8080/path", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("//hello.com:8080", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("//hello.com", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("/test/test.jpg", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("/test", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("test", UriKind.RelativeOrAbsolute, ExpectedResult = true)] + [TestCase("", UriKind.RelativeOrAbsolute, ExpectedResult = false)] + [TestCase(null, UriKind.RelativeOrAbsolute, ExpectedResult = false)] + [TestCase("this is not welformed", UriKind.RelativeOrAbsolute, ExpectedResult = false)] + public bool IsWellFormedWebPath(string? webPath, UriKind uriKind) => WebPath.IsWellFormedWebPath(webPath, uriKind); + }