From d15b5b221e9f6cbf06cf60f74e6dfd1426a5fb1a Mon Sep 17 00:00:00 2001 From: Callum Whyte Date: Mon, 20 Jan 2025 20:27:54 +1100 Subject: [PATCH 01/49] Allow skipSelect blueprints only when one blueprint exists (#17818) --- .../src/views/content/content.create.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js index 54753a94f1..64e366c342 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js @@ -86,7 +86,7 @@ function contentCreateController($scope, }); $scope.docType = docType; if (blueprints.length) { - if (blueprintConfig.skipSelect) { + if (blueprintConfig.skipSelect && blueprints.length === 1) { createFromBlueprint(blueprints[0].id); } else { $scope.selectContentType = false; From 7552e315fbfd588fd22469bf063b1eacd4f8b16e Mon Sep 17 00:00:00 2001 From: Callum Whyte Date: Mon, 20 Jan 2025 20:27:54 +1100 Subject: [PATCH 02/49] Allow skipSelect blueprints only when one blueprint exists (#17818) --- .../src/views/content/content.create.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js index 54753a94f1..64e366c342 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js @@ -86,7 +86,7 @@ function contentCreateController($scope, }); $scope.docType = docType; if (blueprints.length) { - if (blueprintConfig.skipSelect) { + if (blueprintConfig.skipSelect && blueprints.length === 1) { createFromBlueprint(blueprints[0].id); } else { $scope.selectContentType = false; From 25628a8b7602b80abe238b10d18c3e9f02493b90 Mon Sep 17 00:00:00 2001 From: Alex Clark Date: Mon, 20 Jan 2025 03:04:02 -0600 Subject: [PATCH 03/49] Lucene Package Update to Address CVE-2024-43383 (#17942) * Update Lucene Package to 4.8.0-beta00017 * Add Package Reference --------- Co-authored-by: Sebastiaan Janssen --- Directory.Packages.props | 4 +++- src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d0ced18737..eb15199e0a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -91,6 +91,8 @@ + + @@ -98,4 +100,4 @@ - \ No newline at end of file + diff --git a/src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj b/src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj index 7e28eb6971..dd9d5a4115 100644 --- a/src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj +++ b/src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj @@ -10,6 +10,8 @@ + + From 95eb58587b95b091fa861216842e3cdf9af27c32 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 20 Jan 2025 14:14:28 +0100 Subject: [PATCH 04/49] Merge commit from fork --- .../Controllers/PreviewController.cs | 28 +++++++++++++++++++ .../Views/UmbracoViewPage.cs | 5 +++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs index 17875c2950..f1531ecb8d 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewEngines; @@ -130,6 +131,11 @@ public class PreviewController : Controller [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] public ActionResult Frame(int id, string culture) { + if (ValidateProvidedCulture(culture) is false) + { + throw new InvalidOperationException($"Could not recognise the provided culture: {culture}"); + } + EnterPreview(id); // use a numeric URL because content may not be in cache and so .Url would fail @@ -138,6 +144,28 @@ public class PreviewController : Controller return RedirectPermanent($"../../{id}{query}"); } + private static bool ValidateProvidedCulture(string culture) + { + if (string.IsNullOrEmpty(culture)) + { + return true; + } + + // We can be confident the backoffice will have provided a valid culture in linking to the + // preview, so we don't need to check that the culture matches an Umbraco language. + // We are only concerned here with protecting against XSS attacks from a fiddled preview + // URL, so we can just confirm we have a valid culture. + try + { + CultureInfo.GetCultureInfo(culture, true); + return true; + } + catch (CultureNotFoundException) + { + return false; + } + } + public ActionResult? EnterPreview(int id) { IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; diff --git a/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs b/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs index 086a0b0c81..403e6324e1 100644 --- a/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs +++ b/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs @@ -141,7 +141,10 @@ public abstract class UmbracoViewPage : RazorPage string.Format( ContentSettings.PreviewBadge, HostingEnvironment.ToAbsolute(GlobalSettings.UmbracoPath), - Context.Request.GetEncodedUrl(), + System.Web.HttpUtility.HtmlEncode(Context.Request.GetEncodedUrl()), // Belt and braces - via a browser at least it doesn't seem possible to have anything other than + // a valid culture code provided in the querystring of this URL. + // But just to be sure of prevention of an XSS vulnterablity we'll HTML encode here too. + // An expected URL is untouched by this encoding. UmbracoContext.PublishedRequest?.PublishedContent?.Id); } else From e934a943b5068913be40a32bfbc5fa92091eb95c Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 20 Jan 2025 17:37:04 +0100 Subject: [PATCH 05/49] build on windows --- build/azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index c8e370d513..572f93df0a 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -71,7 +71,7 @@ stages: - job: A displayName: Build Umbraco CMS pool: - vmImage: 'ubuntu-latest' + vmImage: 'windows-latest' steps: - task: NodeTool@0 displayName: Use Node.js $(nodeVersion) From e77e9c5691685e6a84886d9c6b77888e5debdcaa Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Thu, 24 Oct 2024 14:45:23 +0200 Subject: [PATCH 06/49] Format sql statement (#17354) (cherry picked from commit aa9f194d7611bb830a8fc3b80295c8115fb5b2d6) --- .../SyntaxProvider/SqlServerSyntaxProviderTests.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/SyntaxProvider/SqlServerSyntaxProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/SyntaxProvider/SqlServerSyntaxProviderTests.cs index be90d8695b..7e1d1f163f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/SyntaxProvider/SqlServerSyntaxProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/SyntaxProvider/SqlServerSyntaxProviderTests.cs @@ -68,12 +68,8 @@ public class SqlServerSyntaxProviderTests : UmbracoIntegrationTest } Assert.AreEqual( - @$"DELETE FROM {t("cmsContentNu")} WHERE {c("nodeId")} IN (SELECT {c("nodeId")} FROM (SELECT DISTINCT cmsContentNu.nodeId -FROM {t("cmsContentNu")} -INNER JOIN {t("umbracoNode")} -ON {t("cmsContentNu")}.{c("nodeId")} = {t("umbracoNode")}.{c("id")} -WHERE (({t("umbracoNode")}.{c("nodeObjectType")} = @0))) x)".Replace(Environment.NewLine, " ").Replace("\n", " ") - .Replace("\r", " "), + @$"DELETE FROM {t("cmsContentNu")} WHERE {c("nodeId")} IN (SELECT {c("nodeId")} FROM (SELECT DISTINCT cmsContentNu.nodeId FROM {t("cmsContentNu")} INNER JOIN {t("umbracoNode")} ON {t("cmsContentNu")}.{c("nodeId")} = {t("umbracoNode")}.{c("id")} WHERE (({t("umbracoNode")}.{c("nodeObjectType")} = @0))) x)".Replace(Environment.NewLine, " ") + .Replace("\n", " ").Replace("\r", " "), sqlOutput.SQL.Replace(Environment.NewLine, " ").Replace("\n", " ").Replace("\r", " ")); Assert.AreEqual(1, sqlOutput.Arguments.Length); From 2161edb871b416faf8f4b14e1634e2f4fcdd4d66 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Tue, 21 Jan 2025 07:41:06 +0100 Subject: [PATCH 07/49] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index ba35bbaff3..a88b4d4fd1 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.5.2", + "version": "13.5.3", "assemblyVersion": { "precision": "build" }, From c7d157bbfd807aff521d65afef8ec815db87dab2 Mon Sep 17 00:00:00 2001 From: Zeegaan Date: Tue, 21 Jan 2025 09:02:00 +0100 Subject: [PATCH 08/49] build on ubuntu --- build/azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 572f93df0a..c8e370d513 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -71,7 +71,7 @@ stages: - job: A displayName: Build Umbraco CMS pool: - vmImage: 'windows-latest' + vmImage: 'ubuntu-latest' steps: - task: NodeTool@0 displayName: Use Node.js $(nodeVersion) From 7567990da19eec5c57dcf2227c887febe280a065 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 21 Jan 2025 09:10:43 +0100 Subject: [PATCH 09/49] Add NoopCurrentMemberClaimsProvider so Umbraco can boot without the Delivery API enabled (#18049) --- .../Controllers/Security/CurrentMemberController.cs | 2 +- .../Services/CurrentMemberClaimsProvider.cs | 1 + .../DeliveryApi}/ICurrentMemberClaimsProvider.cs | 2 +- .../DeliveryApi/NoopCurrentMemberClaimsProvider.cs | 6 ++++++ .../DependencyInjection/UmbracoBuilder.CoreServices.cs | 1 + 5 files changed, 10 insertions(+), 2 deletions(-) rename src/{Umbraco.Cms.Api.Delivery/Services => Umbraco.Core/DeliveryApi}/ICurrentMemberClaimsProvider.cs (87%) create mode 100644 src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs index 9e71636324..41d1970d06 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenIddict.Server.AspNetCore; using Umbraco.Cms.Api.Delivery.Routing; -using Umbraco.Cms.Api.Delivery.Services; +using Umbraco.Cms.Core.DeliveryApi; namespace Umbraco.Cms.Api.Delivery.Controllers.Security; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs index 3250b24ae6..8a358f11a8 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs @@ -1,4 +1,5 @@ using OpenIddict.Abstractions; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Api.Delivery.Services; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs b/src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs similarity index 87% rename from src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs rename to src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs index 902129af6b..cd636cb06e 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs +++ b/src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Api.Delivery.Services; +namespace Umbraco.Cms.Core.DeliveryApi; public interface ICurrentMemberClaimsProvider { diff --git a/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs b/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs new file mode 100644 index 0000000000..8080c27562 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public class NoopCurrentMemberClaimsProvider : ICurrentMemberClaimsProvider +{ + public Task> GetClaimsAsync() => Task.FromResult(new Dictionary()); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index a9ffc67f64..ef0e25204c 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -454,6 +454,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); From a2fd82a3f36593c2b2d8f6e675226f41bf789357 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 21 Jan 2025 09:10:43 +0100 Subject: [PATCH 10/49] Add NoopCurrentMemberClaimsProvider so Umbraco can boot without the Delivery API enabled (#18049) --- .../Controllers/Security/CurrentMemberController.cs | 2 +- .../Services/CurrentMemberClaimsProvider.cs | 1 + .../DeliveryApi}/ICurrentMemberClaimsProvider.cs | 2 +- .../DeliveryApi/NoopCurrentMemberClaimsProvider.cs | 6 ++++++ .../DependencyInjection/UmbracoBuilder.CoreServices.cs | 1 + 5 files changed, 10 insertions(+), 2 deletions(-) rename src/{Umbraco.Cms.Api.Delivery/Services => Umbraco.Core/DeliveryApi}/ICurrentMemberClaimsProvider.cs (87%) create mode 100644 src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs index 9e71636324..41d1970d06 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenIddict.Server.AspNetCore; using Umbraco.Cms.Api.Delivery.Routing; -using Umbraco.Cms.Api.Delivery.Services; +using Umbraco.Cms.Core.DeliveryApi; namespace Umbraco.Cms.Api.Delivery.Controllers.Security; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs index 3250b24ae6..8a358f11a8 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs @@ -1,4 +1,5 @@ using OpenIddict.Abstractions; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Api.Delivery.Services; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs b/src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs similarity index 87% rename from src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs rename to src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs index 902129af6b..cd636cb06e 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs +++ b/src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Api.Delivery.Services; +namespace Umbraco.Cms.Core.DeliveryApi; public interface ICurrentMemberClaimsProvider { diff --git a/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs b/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs new file mode 100644 index 0000000000..8080c27562 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public class NoopCurrentMemberClaimsProvider : ICurrentMemberClaimsProvider +{ + public Task> GetClaimsAsync() => Task.FromResult(new Dictionary()); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index a9ffc67f64..ef0e25204c 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -454,6 +454,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); From 6bd11bf233fc8d792bb9886f4e4ca7dca8ffb30f Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 21 Jan 2025 09:19:31 +0100 Subject: [PATCH 11/49] Fixes failing front-end unit test to align with new behaviour from PR #17818 ( Allow skipSelect blueprints only when one blueprint exists). --- .../unit/app/content/create-content-controller.spec.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/test/unit/app/content/create-content-controller.spec.js b/src/Umbraco.Web.UI.Client/test/unit/app/content/create-content-controller.spec.js index 5954a2f984..86495b59ea 100644 --- a/src/Umbraco.Web.UI.Client/test/unit/app/content/create-content-controller.spec.js +++ b/src/Umbraco.Web.UI.Client/test/unit/app/content/create-content-controller.spec.js @@ -1,4 +1,4 @@ -(function () { +(function () { describe("create content dialog", function () { @@ -89,13 +89,16 @@ expect(searcher.search).toHaveBeenCalledWith("blueprintId", "1"); }); - it("skips selection and creates first blueprint when configured to", + it("skips selection and creates first blueprint when configured to and only one blueprint exists", function () { initialize({ allowBlank: true, skipSelect: true }); + // Ensure only one blueprint is available. + allowedTypes[1].blueprints = { "1": "a" }; + scope.createOrSelectBlueprintIfAny(allowedTypes[1]); expect(location.path).toHaveBeenCalledWith("/content/content/edit/1234"); From 0e4f883bc1d48804405693fb54b12b71de06886e Mon Sep 17 00:00:00 2001 From: Martin Vennevold Date: Tue, 21 Jan 2025 09:44:22 +0100 Subject: [PATCH 12/49] Fix create child issue in list view with infinite editor (#13355). (#17637) --- .../src/views/propertyeditors/listview/listview.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js index 0edcd67c19..c9fc26e053 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js @@ -781,7 +781,7 @@ function listViewController($scope, $interpolate, $routeParams, $injector, $time $scope.options.allowBulkDelete; if ($scope.isTrashed === false) { - getContentTypesCallback(id).then(function (listViewAllowedTypes) { + getContentTypesCallback($scope.contentId).then(function (listViewAllowedTypes) { $scope.listViewAllowedTypes = listViewAllowedTypes; var blueprints = false; From 1a18d6c035d9ec3d32c3a578d387b62c1a110003 Mon Sep 17 00:00:00 2001 From: Nathaniel Nunes Date: Tue, 21 Jan 2025 18:26:40 +0530 Subject: [PATCH 13/49] Replaced deprecated navigator.platform with navigator.userAgent for platform detection. (#17373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Niels Lyngsø --- .../src/views/common/drawers/help/help.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js index f69467b0a1..ede50dc93e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js @@ -67,7 +67,7 @@ if(browserInfo != null){ vm.systemInfo.push({name :"Browser", data: browserInfo.name + " " + browserInfo.version}); } - vm.systemInfo.push({name :"Browser OS", data: getPlatform()}); + vm.systemInfo.push({name :"Browser (user agent)", data: getPlatform()}); } ); tourService.getGroupedTours().then(function(groupedTours) { vm.tours = groupedTours; @@ -257,7 +257,7 @@ } function getPlatform() { - return window.navigator.platform; + return navigator.userAgent; } evts.push(eventsService.on("appState.tour.complete", function (event, tour) { From edc78a5a4c5e8fbc85517da261e3a22596b6b46a Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 22 Jan 2025 12:26:06 +0100 Subject: [PATCH 14/49] Handles migration case where an expected constraint is renamed but the constraint does not exist. (#18063) --- .../Upgrade/V_13_3_0/AlignUpgradedDatabase.cs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs index 6ee48ce0e7..f45b5d371b 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs @@ -1,4 +1,4 @@ -using NPoco; +using NPoco; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using ColumnInfo = Umbraco.Cms.Infrastructure.Persistence.SqlSyntax.ColumnInfo; @@ -153,16 +153,26 @@ SELECT obj_Constraint.NAME AS 'constraintName' "); var currentConstraintName = Database.ExecuteScalar(constraintNameQuery); - - // only rename the constraint if necessary + // Only rename the constraint if necessary. if (currentConstraintName == expectedConstraintName) { return; } - Sql renameConstraintQuery = Database.SqlContext.Sql( - $"EXEC sp_rename N'{currentConstraintName}', N'{expectedConstraintName}', N'OBJECT'"); - Database.Execute(renameConstraintQuery); + if (currentConstraintName is null) + { + // Constraint does not exist, so we need to create it. + Sql createConstraintStatement = Database.SqlContext.Sql(@$" +ALTER TABLE umbracoContentVersion ADD CONSTRAINT [DF_umbracoContentVersion_versionDate] DEFAULT (getdate()) FOR [versionDate]"); + Database.Execute(createConstraintStatement); + } + else + { + // Constraint exists, and differs from the expected name, so we need to rename it. + Sql renameConstraintQuery = Database.SqlContext.Sql( + $"EXEC sp_rename N'{currentConstraintName}', N'{expectedConstraintName}', N'OBJECT'"); + Database.Execute(renameConstraintQuery); + } } private void UpdateExternalLoginIndexes(IEnumerable> indexes) From 3d253f5f06c182255810dbd209774c03c09aec77 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:15:26 +0100 Subject: [PATCH 15/49] bump version --- version.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/version.json b/version.json index 571b476065..54e67d46be 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.6.0-rc2", + "version": "13.6.0-rc3", "assemblyVersion": { "precision": "build" }, @@ -9,8 +9,7 @@ "semVer": 2.0 }, "publicReleaseRefSpec": [ - "^refs/heads/main$", - "^refs/heads/release/" + "^refs/heads/main$", "^refs/heads/release/" ], "release": { "branchName": "release/{version}", From 8485458896095150cf506ffdf7085ce20a9d3f4c Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Thu, 23 Jan 2025 17:01:55 +0100 Subject: [PATCH 16/49] Add clientside validation to webhook events (#18089) --- .../src/views/webhooks/edit.controller.js | 1 + src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js index 4a12d5254d..ce5de98d4c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js @@ -247,6 +247,7 @@ function save() { if (!formHelper.submitForm({ scope: $scope })) { + vm.saveButtonState = 'error'; return; } diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html index f64ef5d69b..5a4678c5eb 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html @@ -5,7 +5,7 @@
- + - +
@@ -50,6 +50,8 @@ alias="webhookEvents" required="true"> + + - + - +
From 44bf3b77b397cd73f328b8766851f31bbe19c66a Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 24 Jan 2025 14:41:30 +0100 Subject: [PATCH 17/49] Make it possible to reset media picker crops (#18110) Co-authored-by: Andy Butland --- .../mediaentryeditor.controller.js | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js index 05be10c5d0..aed2133839 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js @@ -3,15 +3,15 @@ angular.module("umbraco") function ($scope, localizationService, entityResource, editorService, overlayService, eventsService, mediaHelper) { var unsubscribe = []; - + const vm = this; - + vm.loading = true; vm.model = $scope.model; vm.mediaEntry = vm.model.mediaEntry; vm.currentCrop = null; vm.title = ""; - + vm.focalPointChanged = focalPointChanged; vm.onImageLoaded = onImageLoaded; vm.openMedia = openMedia; @@ -20,7 +20,7 @@ angular.module("umbraco") vm.deselectCrop = deselectCrop; vm.resetCrop = resetCrop; vm.submitAndClose = submitAndClose; - vm.close = close; + vm.close = close; function init() { @@ -58,6 +58,12 @@ angular.module("umbraco") return; } + // the focal point can be null in some cases - most often right after a save. this throws the crop + // thumbnails (previews) off, so let's enforce the default focal point. + if (!vm.mediaEntry.focalPoint){ + vm.mediaEntry.focalPoint = {left: 0.5, top: 0.5}; + } + vm.loading = true; entityResource.getById(vm.mediaEntry.mediaKey, "Media").then(function (mediaEntity) { @@ -85,12 +91,12 @@ angular.module("umbraco") }); }); } - + function onImageLoaded(isCroppable, hasDimensions) { vm.isCroppable = isCroppable; vm.hasDimensions = hasDimensions; } - + function repickMedia() { vm.model.propertyEditor.changeMediaFor(vm.model.mediaEntry, onMediaReplaced); } @@ -105,7 +111,7 @@ angular.module("umbraco") updateMedia(); } - + function openMedia() { const mediaEditor = { @@ -117,7 +123,7 @@ angular.module("umbraco") editorService.close(); } }; - + editorService.mediaEditor(mediaEditor); } @@ -131,23 +137,24 @@ angular.module("umbraco") // set form to dirty to track changes setDirty(); } - + function selectCrop(targetCrop) { vm.currentCrop = targetCrop; setDirty(); // TODO: start watchin values of crop, first when changed set to dirty. } - + function deselectCrop() { vm.currentCrop = null; } - + function resetCrop() { if (vm.currentCrop) { - $scope.$evalAsync( () => { - vm.model.propertyEditor.resetCrop(vm.currentCrop); - vm.forceUpdateCrop = Math.random(); - }); + vm.model.propertyEditor.resetCrop(vm.currentCrop); + // deselecting the crop here has a dual purpose: + // 1. it replicates the behaviour of the image cropper (e.g. on media items). + // 2. it ensures that the newly reset crop does not get overwritten by a new crop with default values. + deselectCrop(); } } @@ -160,7 +167,7 @@ angular.module("umbraco") vm.model.submit(vm.model); } } - + function close() { if (vm.model && vm.model.close) { @@ -169,7 +176,7 @@ angular.module("umbraco") const labelKeys = vm.model.createFlow === true ? ["mediaPicker_confirmCancelMediaEntryCreationHeadline", "mediaPicker_confirmCancelMediaEntryCreationMessage"] : ["prompt_discardChanges", "mediaPicker_confirmCancelMediaEntryHasChanges"]; - + localizationService.localizeMany(labelKeys).then(localizations => { const confirm = { title: localizations[0], @@ -196,7 +203,7 @@ angular.module("umbraco") } init(); - + $scope.$on("$destroy", function () { unsubscribe.forEach(x => x()); }); From 7850078623c1339fd358a634cfb36a2f1ed04526 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 24 Jan 2025 19:46:28 +0100 Subject: [PATCH 18/49] Redirect to the published URL when exiting preview (#18114) Co-authored-by: Andy Butland --- .../Controllers/PreviewController.cs | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs index 17875c2950..6ab88844b7 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs @@ -1,9 +1,12 @@ +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Editors; using Umbraco.Cms.Core.Features; using Umbraco.Cms.Core.Hosting; @@ -27,7 +30,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers; [DisableBrowserCache] [Area(Constants.Web.Mvc.BackOfficeArea)] -public class PreviewController : Controller +public partial class PreviewController : Controller { private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; private readonly ICookieManager _cookieManager; @@ -39,7 +42,9 @@ public class PreviewController : Controller private readonly IRuntimeMinifier _runtimeMinifier; private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly ICompositeViewEngine _viewEngines; + private readonly WebRoutingSettings _webRoutingSettings; + [Obsolete("Please use the non-obsolete constructor.")] public PreviewController( UmbracoFeatures features, IOptionsSnapshot globalSettings, @@ -51,9 +56,38 @@ public class PreviewController : Controller IRuntimeMinifier runtimeMinifier, ICompositeViewEngine viewEngines, IUmbracoContextAccessor umbracoContextAccessor) + : this( + features, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService>(), + publishedSnapshotService, + backofficeSecurityAccessor, + localizationService, + hostingEnvironment, + cookieManager, + runtimeMinifier, + viewEngines, + umbracoContextAccessor) + { + } + + [ActivatorUtilitiesConstructor] + public PreviewController( + UmbracoFeatures features, + IOptionsSnapshot globalSettings, + IOptionsSnapshot webRoutingSettings, + IPublishedSnapshotService publishedSnapshotService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILocalizationService localizationService, + IHostingEnvironment hostingEnvironment, + ICookieManager cookieManager, + IRuntimeMinifier runtimeMinifier, + ICompositeViewEngine viewEngines, + IUmbracoContextAccessor umbracoContextAccessor) { _features = features; _globalSettings = globalSettings.Value; + _webRoutingSettings = webRoutingSettings.Value; _publishedSnapshotService = publishedSnapshotService; _backofficeSecurityAccessor = backofficeSecurityAccessor; _localizationService = localizationService; @@ -153,6 +187,43 @@ 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); + // are we attempting a redirect to the default route (by ID with optional culture)? + Match match = DefaultPreviewRedirectRegex().Match(redir ?? string.Empty); + if (match.Success) + { + var id = int.Parse(match.Groups["id"].Value); + + // first try to resolve the published URL + if (_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext) && + umbracoContext.Content is not null) + { + IPublishedContent? publishedContent = umbracoContext.Content.GetById(id); + if (publishedContent is null) + { + // content is not published, redirect to root + return Redirect("/"); + } + + var culture = publishedContent.ContentType.VariesByCulture() + && match.Groups.TryGetValue("culture", out Group? group) + ? group.Value + : null; + + var publishedUrl = publishedContent.Url(culture); + if (WebPath.IsWellFormedWebPath(publishedUrl, UriKind.RelativeOrAbsolute)) + { + return Redirect(publishedUrl); + } + } + + // could not resolve the published URL - are we allowed to route content by ID? + if (_webRoutingSettings.DisableFindContentByIdPath) + { + // no we are not - redirect to root instead + return Redirect("/"); + } + } + if (WebPath.IsWellFormedWebPath(redir, UriKind.Relative) && Uri.TryCreate(redir, UriKind.Relative, out Uri? url)) { @@ -161,4 +232,7 @@ public class PreviewController : Controller return Redirect("/"); } + + [GeneratedRegex("^\\/(?\\d*)(\\?culture=(?[\\w-]*))?$")] + private static partial Regex DefaultPreviewRedirectRegex(); } From 313417cb91b04b253f81e56050626ee2eb3e1e69 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 28 Jan 2025 10:52:21 +0100 Subject: [PATCH 19/49] Tidied up XML header comment in ITagQuery. --- src/Umbraco.Core/PublishedCache/ITagQuery.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Core/PublishedCache/ITagQuery.cs b/src/Umbraco.Core/PublishedCache/ITagQuery.cs index e0c6a135c9..8df363dbe4 100644 --- a/src/Umbraco.Core/PublishedCache/ITagQuery.cs +++ b/src/Umbraco.Core/PublishedCache/ITagQuery.cs @@ -33,7 +33,7 @@ public interface ITagQuery /// /// Gets all document tags. /// - /// /// + /// /// If no culture is specified, it retrieves tags with an invariant culture. /// If a culture is specified, it only retrieves tags for that culture. /// Use "*" to retrieve tags for all cultures. From bf340cd7d4c139f08d6cc0dc669038c3d1ab6022 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:53:00 +0100 Subject: [PATCH 20/49] fix: remove unused parameters and documentation (#18095) this fixes an issue where unused parameters were published as supported, but they were in fact never supported. --- .../src/common/services/assets.service.js | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js b/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js index 8149e45ee8..73ac74847b 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js @@ -6,7 +6,7 @@ * @requires angularHelper * * @description - * Promise-based utillity service to lazy-load client-side dependencies inside angular controllers. + * Promise-based utility service to lazy-load client-side dependencies inside angular controllers. * * ##usage * To use, simply inject the assetsService into any controller that needs it, and make @@ -20,10 +20,10 @@ * }); * * - * You can also load individual files, which gives you greater control over what attibutes are passed to the file, as well as timeout + * You can also load individual files, which gives you greater control over what attributes are passed to the file: * *
- *      angular.module("umbraco").controller("my.controller". function(assetsService){
+ *      angular.module("umbraco").controller("my.controller". function(assetsService) {
  *          assetsService.loadJs("script.js", $scope, {charset: 'utf-8'}, 10000 }).then(function(){
  *                 //this code executes when the script is done loading
  *          });
@@ -33,7 +33,7 @@
  * For these cases, there are 2 individual methods, one for javascript, and one for stylesheets:
  *
  * 
- *      angular.module("umbraco").controller("my.controller". function(assetsService){
+ *      angular.module("umbraco").controller("my.controller". function(assetsService) {
  *          assetsService.loadCss("stye.css", $scope, {media: 'print'}, 10000 }).then(function(){
  *                 //loadcss cannot determine when the css is done loading, so this will trigger instantly
  *          });
@@ -55,7 +55,7 @@ angular.module('umbraco.services')
             var _op = (url.indexOf("?") > 0) ? "&" : "?";
             url = url + _op + "umb__rnd=" + rnd;
             return url;
-        };
+        }
 
         function convertVirtualPath(path) {
             //make this work for virtual paths
@@ -72,7 +72,7 @@ angular.module('umbraco.services')
         function getFlatpickrLocales(locales, supportedLocales) {
             return getLocales(locales, supportedLocales, 'lib/flatpickr/l10n/');
         }
-        
+
         function getLocales(locales, supportedLocales, path) {
             var localeUrls = [];
             locales = locales.split(',');
@@ -168,17 +168,13 @@ angular.module('umbraco.services')
              *
              * @param {String} path path to the css file to load
              * @param {Scope} scope optional scope to pass into the loader
-             * @param {Object} keyvalue collection of attributes to pass to the stylesheet element
-             * @param {Number} timeout in milliseconds
              * @returns {Promise} Promise object which resolves when the file has loaded
              */
-            loadCss: function (path, scope, attributes, timeout) {
+            loadCss: function (path, scope) {
 
                 path = convertVirtualPath(path);
 
-                var asset = this._getAssetPromise(path); // $q.defer();
-                var t = timeout || 5000;
-                var a = attributes || undefined;
+                const asset = this._getAssetPromise(path);
 
                 if (asset.state === "new") {
                     asset.state = "loading";
@@ -207,17 +203,13 @@ angular.module('umbraco.services')
              *
              * @param {String} path path to the js file to load
              * @param {Scope} scope optional scope to pass into the loader
-             * @param {Object} keyvalue collection of attributes to pass to the script element
-             * @param {Number} timeout in milliseconds
              * @returns {Promise} Promise object which resolves when the file has loaded
              */
-            loadJs: function (path, scope, attributes, timeout) {
+            loadJs: function (path, scope) {
 
                 path = convertVirtualPath(path);
 
-                var asset = this._getAssetPromise(path); // $q.defer();
-                var t = timeout || 5000;
-                var a = attributes || undefined;
+                const asset = this._getAssetPromise(path);
 
                 if (asset.state === "new") {
                     asset.state = "loading";
@@ -250,7 +242,7 @@ angular.module('umbraco.services')
              *
              * @param {Array} pathArray string array of paths to the files to load
              * @param {Scope} scope optional scope to pass into the loader
-             * @param {string} defaultAssetType optional default asset type used to load assets with no extension 
+             * @param {string} defaultAssetType optional default asset type used to load assets with no extension
              * @returns {Promise} Promise object which resolves when all the files has loaded
              */
             load: function (pathArray, scope, defaultAssetType) {

From f54b6033ab722343156e228883d014f107de73ed Mon Sep 17 00:00:00 2001
From: Andy Butland 
Date: Wed, 29 Jan 2025 10:27:17 +0100
Subject: [PATCH 21/49] Added Resharper test assemblies to exclude list on
 TypeFinder. (#18145)

---
 src/Umbraco.Core/Composing/TypeFinder.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs
index e3b7ddef9b..5e02336ef5 100644
--- a/src/Umbraco.Core/Composing/TypeFinder.cs
+++ b/src/Umbraco.Core/Composing/TypeFinder.cs
@@ -34,7 +34,7 @@ public class TypeFinder : ITypeFinder
         "ServiceStack.", "SqlCE4Umbraco,", "Superpower,", // used by Serilog
         "System.", "TidyNet,", "TidyNet.", "WebDriver,", "itextsharp,", "mscorlib,", "NUnit,", "NUnit.", "NUnit3.",
         "Selenium.", "ImageProcessor", "MiniProfiler.", "Owin,", "SQLite",
-        "ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension
+        "ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", "ReSharperTestRunnerArm32", "ReSharperTestRunnerArm64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension
     };
 
     private static readonly ConcurrentDictionary TypeNamesCache = new();

From 59a46495284d4f056fb6dbbc56af2bf46004dc30 Mon Sep 17 00:00:00 2001
From: Kenn Jacobsen 
Date: Wed, 29 Jan 2025 10:29:58 +0100
Subject: [PATCH 22/49] Do not allow editing read-only properties by clicking
 their labels (#18152)

* Do not allow editing read-only properties by clicking their labels

* Simplify the fix :)

* Fix linting issue
---
 .../components/content/umbtabbedcontent.directive.js   |  4 ++++
 .../components/property/umbproperty.directive.js       |  7 ++++---
 .../views/components/content/umb-tabbed-content.html   | 10 ++++++----
 .../src/views/components/property/umb-property.html    |  2 +-
 4 files changed, 15 insertions(+), 8 deletions(-)

diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js
index e76da32a54..fe16d1cf9d 100644
--- a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js
+++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js
@@ -218,6 +218,10 @@
 
                 return !canEditCulture || !canEditSegment;
             }
+
+            $scope.isPreview = function(property) {
+              return ((property.readonly || !$scope.allowUpdate) && !property.supportsReadOnly) || ($scope.propertyEditorDisabled(property) && $scope.allowUpdate);
+            }
         }
 
         var directive = {
diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js
index 11efb4b811..073df54a7e 100644
--- a/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js
+++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js
@@ -25,11 +25,12 @@
                 propertyAlias: "@",
                 showInherit: "<",
                 inheritsFrom: "<",
-                hideLabel: " 1 && property.variation !== 'CultureAndSegment'"
-                    inherits-from="defaultVariant.displayName">
+                    inherits-from="defaultVariant.displayName"
+                    preview="isPreview(property)">
 
                     
@@ -49,12 +50,13 @@
                     property="property"
                     node="contentNodeModel"
                     show-inherit="contentNodeModel.variants.length > 1 && property.variation !== 'CultureAndSegment'"
-                    inherits-from="defaultVariant.displayName">
+                    inherits-from="defaultVariant.displayName"
+                    preview="isPreview(property)">
 
                     
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html b/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html
index e3a65a215b..e4166e8e98 100644
--- a/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html
+++ b/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html
@@ -9,7 +9,7 @@
 
                 
- + From 5d48bc7371345ece8e98d52537e3181c3ab30721 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 29 Jan 2025 13:33:49 +0100 Subject: [PATCH 23/49] Provides an option to remove the inessential version number from the generated models (#18081) * Provides an option to remove the inessential version number from the generated models. * Clarified comment. --- .../Configuration/Models/ModelsBuilderSettings.cs | 13 +++++++++++++ .../ModelsBuilder/Building/TextBuilder.cs | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs index be86cf1f2b..127b7d9330 100644 --- a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs @@ -16,6 +16,7 @@ public class ModelsBuilderSettings internal const string StaticModelsDirectory = "~/umbraco/models"; internal const bool StaticAcceptUnsafeModelsDirectory = false; internal const int StaticDebugLevel = 0; + internal const bool StaticIncludeVersionNumberInGeneratedModels = true; private bool _flagOutOfDateModels = true; /// @@ -78,4 +79,16 @@ public class ModelsBuilderSettings /// 0 means minimal (safe on live site), anything else means more and more details (maybe not safe). [DefaultValue(StaticDebugLevel)] public int DebugLevel { get; set; } = StaticDebugLevel; + + /// + /// Gets or sets a value indicating whether the version number should be included in generated models. + /// + /// + /// By default this is written to the output in + /// generated code for each property of the model. This can be useful for debugging purposes but isn't essential, + /// and it has the causes the generated code to change every time Umbraco is upgraded. In turn, this leads + /// to unnecessary code file changes that need to be checked into source control. Default is true. + /// + [DefaultValue(StaticIncludeVersionNumberInGeneratedModels)] + public bool IncludeVersionNumberInGeneratedModels { get; set; } = StaticIncludeVersionNumberInGeneratedModels; } diff --git a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs index 22160b0ef4..7484741b58 100644 --- a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs +++ b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs @@ -143,14 +143,17 @@ public class TextBuilder : Builder // // note that the blog post above clearly states that "Nor should it be applied at the type level if the type being generated is a partial class." // and since our models are partial classes, we have to apply the attribute against the individual members, not the class itself. - private static void WriteGeneratedCodeAttribute(StringBuilder sb, string tabs) => sb.AppendFormat( + private void WriteGeneratedCodeAttribute(StringBuilder sb, string tabs) => sb.AppendFormat( "{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"Umbraco.ModelsBuilder.Embedded\", \"{1}\")]\n", - tabs, ApiVersion.Current.Version); + tabs, + Config.IncludeVersionNumberInGeneratedModels ? ApiVersion.Current.Version : null); // writes an attribute that specifies that an output may be null. // (useful for consuming projects with nullable reference types enabled) private static void WriteMaybeNullAttribute(StringBuilder sb, string tabs, bool isReturn = false) => - sb.AppendFormat("{0}[{1}global::System.Diagnostics.CodeAnalysis.MaybeNull]\n", tabs, + sb.AppendFormat( + "{0}[{1}global::System.Diagnostics.CodeAnalysis.MaybeNull]\n", + tabs, isReturn ? "return: " : string.Empty); private static string MixinStaticGetterName(string clrName) => string.Format("Get{0}", clrName); From ee2d7bbb1b66ff5f0855fea6ef531e83af2795d2 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 29 Jan 2025 21:10:11 +0100 Subject: [PATCH 24/49] Excluded tags from trashed content (#18164) --- .../Repositories/Implement/TagRepository.cs | 7 +- .../Repositories/TagRepositoryTest.cs | 83 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs index ecc6600d4c..a722bea2a0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs @@ -387,7 +387,9 @@ WHERE r.tagId IS NULL"; }).ToList(); /// - public IEnumerable GetTagsForEntityType(TaggableObjectTypes objectType, string? group = null, + public IEnumerable GetTagsForEntityType( + TaggableObjectTypes objectType, + string? group = null, string? culture = null) { Sql sql = GetTagsSql(culture, true); @@ -401,6 +403,9 @@ WHERE r.tagId IS NULL"; .Where(dto => dto.NodeObjectType == nodeObjectType); } + sql = sql + .Where(dto => !dto.Trashed); + if (group.IsNullOrWhiteSpace() == false) { sql = sql diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs index 1ce5eeefd9..297bd50699 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs @@ -638,6 +638,89 @@ public class TagRepositoryTest : UmbracoIntegrationTest } } + [Test] + public void Can_Get_Tags_For_Entity_Type_Excluding_Trashed_Entity() + { + var provider = ScopeProvider; + using (ScopeProvider.CreateScope()) + { + var template = TemplateBuilder.CreateTextPageTemplate(); + FileService.SaveTemplate(template); + + var contentType = ContentTypeBuilder.CreateSimpleContentType("test", "Test", defaultTemplateId: template.Id); + ContentTypeRepository.Save(contentType); + + var content1 = ContentBuilder.CreateSimpleContent(contentType); + content1.PublishCulture(CultureImpact.Invariant); + content1.PublishedState = PublishedState.Publishing; + DocumentRepository.Save(content1); + + var content2 = ContentBuilder.CreateSimpleContent(contentType); + content2.PublishCulture(CultureImpact.Invariant); + content2.PublishedState = PublishedState.Publishing; + content2.Trashed = true; + DocumentRepository.Save(content2); + + var mediaType = MediaTypeBuilder.CreateImageMediaType("image2"); + MediaTypeRepository.Save(mediaType); + + var media1 = MediaBuilder.CreateMediaImage(mediaType, -1); + MediaRepository.Save(media1); + + var media2 = MediaBuilder.CreateMediaImage(mediaType, -1); + media2.Trashed = true; + MediaRepository.Save(media2); + + var repository = CreateRepository(provider); + Tag[] tags = + { + new Tag {Text = "tag1", Group = "test"}, + new Tag {Text = "tag2", Group = "test1"}, + new Tag {Text = "tag3", Group = "test"} + }; + + Tag[] tags2 = +{ + new Tag {Text = "tag4", Group = "test"}, + new Tag {Text = "tag5", Group = "test1"}, + new Tag {Text = "tag6", Group = "test"} + }; + + repository.Assign( + content1.Id, + contentType.PropertyTypes.First().Id, + tags, + false); + + repository.Assign( + content2.Id, + contentType.PropertyTypes.First().Id, + tags2, + false); + + repository.Assign( + media1.Id, + contentType.PropertyTypes.First().Id, + tags, + false); + + repository.Assign( + media2.Id, + contentType.PropertyTypes.First().Id, + tags2, + false); + + var result1 = repository.GetTagsForEntityType(TaggableObjectTypes.Content).ToArray(); + var result2 = repository.GetTagsForEntityType(TaggableObjectTypes.Media).ToArray(); + var result3 = repository.GetTagsForEntityType(TaggableObjectTypes.All).ToArray(); + + const string ExpectedTags = "tag1,tag2,tag3"; + Assert.AreEqual(ExpectedTags, string.Join(",", result1.Select(x => x.Text))); + Assert.AreEqual(ExpectedTags, string.Join(",", result2.Select(x => x.Text))); + Assert.AreEqual(ExpectedTags, string.Join(",", result3.Select(x => x.Text))); + } + } + [Test] public void Can_Get_Tags_For_Entity_Type() { From bb73ec6c68db1ed8de53288c4fd665cff4a8faf2 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Thu, 30 Jan 2025 09:52:50 +0100 Subject: [PATCH 25/49] Set release version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 54e67d46be..0d1b198869 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.6.0-rc3", + "version": "13.6.0", "assemblyVersion": { "precision": "build" }, From b9837ac77ccddef66ed37bbf9827167e0b88aad2 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 29 Nov 2024 12:20:19 +0100 Subject: [PATCH 26/49] Optimize Azure pipeline (#17674) * Only fetch single commit * Hopefully fixes Nerdbank.GitVersioning.GitException: Shallow clone lacks the objects required to calculate version height. Use full clones or clones with a history at least as deep as the last version height resetting change. * Do not checkout again * More test pipeline * Another attempt * yet another attempt * more attempts * Revert "more attempts" This reverts commit 5694d97ba620e90fdeea287936f58002f2a5ddba. * Test without building backoffice and login explicitly * Fix mem leak in integration tests * Fixes sqlserver lock test # Conflicts: # build/azure-pipelines.yml --- build/azure-pipelines.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index d5b02db6f2..8d3f9edc0f 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -73,6 +73,10 @@ stages: pool: vmImage: 'ubuntu-latest' steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 500 - task: NodeTool@0 displayName: Use Node.js $(nodeVersion) retryCountOnTaskFailure: 3 @@ -198,6 +202,11 @@ stages: pool: vmImage: 'ubuntu-latest' steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 1 + fetchFilter: tree:0 - task: NodeTool@0 displayName: Use Node.js 10.15.x retryCountOnTaskFailure: 3 @@ -249,6 +258,11 @@ stages: pool: vmImage: $(vmImage) steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 1 + fetchFilter: tree:0 - task: DownloadPipelineArtifact@2 displayName: Download build artifacts inputs: @@ -288,6 +302,11 @@ stages: variables: Tests__Database__DatabaseType: 'Sqlite' steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 1 + fetchFilter: tree:0 # Setup test environment - task: DownloadPipelineArtifact@2 displayName: Download build artifacts From 7a2d6b6c63bd1603e3b6bfbba1dfb32ce4d84ecd Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 31 Jan 2025 14:26:07 +0100 Subject: [PATCH 27/49] More robust resolving of Delivery API redirects (#18160) --- .../Services/RequestRedirectService.cs | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs index 882525c8d0..daf5e1b198 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs @@ -42,31 +42,42 @@ internal sealed class RequestRedirectService : RoutingServiceBase, IRequestRedir { requestedPath = requestedPath.EnsureStartsWith("/"); + IPublishedContent? startItem = GetStartItem(); + // must append the root content url segment if it is not hidden by config, because // the URL tracking is based on the actual URL, including the root content url segment - if (_globalSettings.HideTopLevelNodeFromPath == false) + if (_globalSettings.HideTopLevelNodeFromPath == false && startItem?.UrlSegment != null) { - IPublishedContent? startItem = GetStartItem(); - if (startItem?.UrlSegment != null) - { - requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}"; - } + requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}"; } var culture = _requestCultureService.GetRequestedCulture(); - // append the configured domain content ID to the path if we have a domain bound request, - // because URL tracking registers the tracked url like "{domain content ID}/{content path}" - Uri contentRoute = GetDefaultRequestUri(requestedPath); - DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); - if (domainAndUri != null) + // important: redirect URLs are always tracked without trailing slashes + requestedPath = requestedPath.TrimEnd("/"); + IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture); + + // if a redirect URL was not found, try by appending the start item ID because URL tracking might have tracked + // a redirect with "{root content ID}/{content path}" + if (redirectUrl is null && startItem is not null) { - requestedPath = GetContentRoute(domainAndUri, contentRoute); - culture ??= domainAndUri.Culture; + redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl($"{startItem.Id}{requestedPath}", culture); + } + + // still no redirect URL found - try looking for a configured domain if we have a domain bound request, + // because URL tracking might have tracked a redirect with "{domain content ID}/{content path}" + if (redirectUrl is null) + { + Uri contentRoute = GetDefaultRequestUri(requestedPath); + DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); + if (domainAndUri is not null) + { + requestedPath = GetContentRoute(domainAndUri, contentRoute); + culture ??= domainAndUri.Culture; + redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture); + } } - // important: redirect URLs are always tracked without trailing slashes - IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath.TrimEnd("/"), culture); IPublishedContent? content = redirectUrl != null ? _apiPublishedContentCache.GetById(redirectUrl.ContentKey) : null; From 17615f966b8658bc2322fb518d9ce8156a5d5ba7 Mon Sep 17 00:00:00 2001 From: TimBoonstra Date: Fri, 31 Jan 2025 14:46:56 +0100 Subject: [PATCH 28/49] Fix out of memory of 2gb+ (max 4gb) error introduced by #14657 SVG xss security fix (#17421) Co-authored-by: Andy Butland --- src/Umbraco.Web.BackOffice/Controllers/MediaController.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs index db47ba599f..f8e28989b1 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs @@ -788,8 +788,7 @@ public class MediaController : ContentControllerBase continue; } - using var stream = new MemoryStream(); - await formFile.CopyToAsync(stream); + await using var stream = formFile.OpenReadStream(); if (_fileStreamSecurityValidator != null && _fileStreamSecurityValidator.IsConsideredSafe(stream) == false) { tempFiles.Notifications.Add(new BackOfficeNotification( From 5dfff212d077a4fb0b8fd906e82a9af92c7aa555 Mon Sep 17 00:00:00 2001 From: Gareth Wright Date: Fri, 31 Jan 2025 13:59:25 +0000 Subject: [PATCH 29/49] Update auth.element.ts (#18192) auth_username => general_username --- src/Umbraco.Web.UI.Login/src/auth.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Login/src/auth.element.ts b/src/Umbraco.Web.UI.Login/src/auth.element.ts index 3f5bfd1428..e195f3986a 100644 --- a/src/Umbraco.Web.UI.Login/src/auth.element.ts +++ b/src/Umbraco.Web.UI.Login/src/auth.element.ts @@ -179,7 +179,7 @@ export default class UmbAuthElement extends LitElement { }); this._usernameLabel = createLabel({ forId: 'username-input', - localizeAlias: this.usernameIsEmail ? 'general_email' : 'auth_username', + localizeAlias: this.usernameIsEmail ? 'general_email' : 'general_username', localizeFallback: this.usernameIsEmail ? 'Email' : 'Username', }); this._passwordLabel = createLabel({forId: 'password-input', localizeAlias: 'general_password', localizeFallback: 'Password'}); From 9f357173c3d88dcd1e437dec0b2dcd2200b9a057 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Mon, 3 Feb 2025 12:50:23 +0100 Subject: [PATCH 30/49] Enforce user start nodes for media uploads through the RTE (#18204) --- .../RichTextEditorPastedImages.cs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs index 8dbe6ad5b3..64e349c245 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs @@ -8,12 +8,14 @@ using HtmlAgilityPack; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; @@ -38,6 +40,9 @@ public sealed class RichTextEditorPastedImages private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly string _tempFolderAbsolutePath; private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly IEntityService _entityService; + private readonly IUserService _userService; + private readonly AppCaches _appCaches; private readonly ContentSettings _contentSettings; private readonly Dictionary _uploadedImages = new(); @@ -67,6 +72,7 @@ public sealed class RichTextEditorPastedImages { } + [Obsolete("Use the non-obsolete constructor. Scheduled for removal in v14")] public RichTextEditorPastedImages( IUmbracoContextAccessor umbracoContextAccessor, ILogger logger, @@ -79,6 +85,39 @@ public sealed class RichTextEditorPastedImages IPublishedUrlProvider publishedUrlProvider, IImageUrlGenerator imageUrlGenerator, IOptions contentSettings) + : this( + umbracoContextAccessor, + logger, + hostingEnvironment, + mediaService, + contentTypeBaseServiceProvider, + mediaFileManager, + mediaUrlGenerators, + shortStringHelper, + publishedUrlProvider, + imageUrlGenerator, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + contentSettings) + { + } + + public RichTextEditorPastedImages( + IUmbracoContextAccessor umbracoContextAccessor, + ILogger logger, + IHostingEnvironment hostingEnvironment, + IMediaService mediaService, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + MediaFileManager mediaFileManager, + MediaUrlGeneratorCollection mediaUrlGenerators, + IShortStringHelper shortStringHelper, + IPublishedUrlProvider publishedUrlProvider, + IImageUrlGenerator imageUrlGenerator, + IEntityService entityService, + IUserService userService, + AppCaches appCaches, + IOptions contentSettings) { _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); @@ -92,6 +131,9 @@ public sealed class RichTextEditorPastedImages _shortStringHelper = shortStringHelper; _publishedUrlProvider = publishedUrlProvider; _imageUrlGenerator = imageUrlGenerator; + _entityService = entityService; + _userService = userService; + _appCaches = appCaches; _contentSettings = contentSettings.Value; _tempFolderAbsolutePath = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempImageUploads); @@ -270,7 +312,7 @@ public sealed class RichTextEditorPastedImages : Constants.Conventions.MediaTypes.Image; IMedia mediaFile = mediaParentFolder == Guid.Empty - ? _mediaService.CreateMedia(mediaItemName, Constants.System.Root, mediaType, userId) + ? _mediaService.CreateMedia(mediaItemName, GetDefaultMediaRoot(userId), mediaType, userId) : _mediaService.CreateMedia(mediaItemName, mediaParentFolder, mediaType, userId); var fileInfo = new FileInfo(absoluteTempImagePath); @@ -354,4 +396,11 @@ public sealed class RichTextEditorPastedImages } private bool IsValidPath(string imagePath) => imagePath.StartsWith(_tempFolderAbsolutePath); + + private int GetDefaultMediaRoot(int userId) + { + IUser user = _userService.GetUserById(userId) ?? throw new ArgumentException("User could not be found"); + var userStartNodes = user.CalculateMediaStartNodeIds(_entityService, _appCaches); + return userStartNodes?.FirstOrDefault() ?? Constants.System.Root; + } } From e7411244fde73cbc5d85a536a48caaff8f2b49fd Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 3 Feb 2025 13:24:58 +0100 Subject: [PATCH 31/49] Show notifications menu only to users with permission for the feature. (#18184) --- src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs index 4cdd8cef7c..0ef895e207 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs @@ -317,13 +317,7 @@ public class ContentTreeController : ContentTreeControllerBase, ISearchableTreeW if (_emailSender.CanSendRequiredEmail()) { - menu.Items.Add(new MenuItem("notify", LocalizedTextService) - { - Icon = "icon-megaphone", - SeparatorBefore = true, - OpensDialog = true, - UseLegacyIcon = false - }); + AddActionNode(item, menu, hasSeparator: true, opensDialog: true, useLegacyIcon: false); } if ((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false) From b4a9dc0770a389b7e92a362e86e1e7b3f8490114 Mon Sep 17 00:00:00 2001 From: Mole Date: Mon, 3 Feb 2025 19:48:08 +0100 Subject: [PATCH 32/49] V13: Fix members while using basic auth. (#18206) * Flow additional identities to new principal * Add extension to more easily get member identity * Ensure the member is used instead of the backoffice user in `MemberManager` * Update snippet * Fix the comment that I broke * Update src/Umbraco.Web.Common/Extensions/MemberClaimsPrincipalExtensions.cs Co-authored-by: Andy Butland --------- Co-authored-by: Andy Butland --- .../Snippets/LoginStatus.cshtml | 4 +-- .../Extensions/HttpContextExtensions.cs | 11 ++++++-- .../MemberClaimsPrincipalExtensions.cs | 18 ++++++++++++ .../Security/MemberManager.cs | 28 ++++++++++++------- 4 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 src/Umbraco.Web.Common/Extensions/MemberClaimsPrincipalExtensions.cs diff --git a/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml b/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml index 8f5477bca4..aa70da23c8 100644 --- a/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml +++ b/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml @@ -5,7 +5,7 @@ @using Umbraco.Extensions @{ - var isLoggedIn = Context.User?.Identity?.IsAuthenticated ?? false; + var isLoggedIn = Context.User.GetMemberIdentity()?.IsAuthenticated ?? false; var logoutModel = new PostRedirectModel(); // You can modify this to redirect to a different URL instead of the current one logoutModel.RedirectUrl = null; @@ -15,7 +15,7 @@ { public Func? PerformCount { get; set; } + /// + /// True if the Get method will cache null results so that the db is not hit for repeated lookups + /// + public bool CacheNullValues { get; set; } + /// /// True/false as to validate the total item count when all items are returned from cache, the default is true but this /// means that a db lookup will occur - though that lookup will probably be significantly less expensive than the diff --git a/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs b/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs index 7f7f8d6784..9494ed2eea 100644 --- a/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs +++ b/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs @@ -24,6 +24,8 @@ public class DefaultRepositoryCachePolicy : RepositoryCachePolicyB private static readonly TEntity[] _emptyEntities = new TEntity[0]; // const private readonly RepositoryCachePolicyOptions _options; + private const string NullRepresentationInCache = "*NULL*"; + public DefaultRepositoryCachePolicy(IAppPolicyCache cache, IScopeAccessor scopeAccessor, RepositoryCachePolicyOptions options) : base(cache, scopeAccessor) => _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -116,6 +118,7 @@ public class DefaultRepositoryCachePolicy : RepositoryCachePolicyB { // whatever happens, clear the cache var cacheKey = GetEntityCacheKey(entity.Id); + Cache.Clear(cacheKey); // if there's a GetAllCacheAllowZeroCount cache, ensure it is cleared @@ -127,20 +130,36 @@ public class DefaultRepositoryCachePolicy : RepositoryCachePolicyB public override TEntity? Get(TId? id, Func performGet, Func?> performGetAll) { var cacheKey = GetEntityCacheKey(id); + TEntity? fromCache = Cache.GetCacheItem(cacheKey); - // if found in cache then return else fetch and cache - if (fromCache != null) + // If found in cache then return immediately. + if (fromCache is not null) { return fromCache; } + // Because TEntity can never be a string, we will never be in a position where the proxy value collides withs a real value. + // Therefore this point can only be reached if there is a proxy null value => becomes null when cast to TEntity above OR the item simply does not exist. + // If we've cached a "null" value, return null. + if (_options.CacheNullValues && Cache.GetCacheItem(cacheKey) == NullRepresentationInCache) + { + return null; + } + + // Otherwise go to the database to retrieve. TEntity? entity = performGet(id); if (entity != null && entity.HasIdentity) { + // If we've found an identified entity, cache it for subsequent retrieval. InsertEntity(cacheKey, entity); } + else if (entity is null && _options.CacheNullValues) + { + // If we've not found an entity, and we're caching null values, cache a "null" value. + InsertNull(cacheKey); + } return entity; } @@ -248,6 +267,15 @@ public class DefaultRepositoryCachePolicy : RepositoryCachePolicyB protected virtual void InsertEntity(string cacheKey, TEntity entity) => Cache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true); + protected virtual void InsertNull(string cacheKey) + { + // We can't actually cache a null value, as in doing so wouldn't be able to distinguish between + // a value that does exist but isn't yet cached, or a value that has been explicitly cached with a null value. + // Both would return null when we retrieve from the cache and we couldn't distinguish between the two. + // So we cache a special value that represents null, and then we can check for that value when we retrieve from the cache. + Cache.Insert(cacheKey, () => NullRepresentationInCache, TimeSpan.FromMinutes(5), true); + } + protected virtual void InsertEntities(TId[]? ids, TEntity[]? entities) { if (ids?.Length == 0 && entities?.Length == 0 && _options.GetAllCacheAllowZeroCount) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs index 909c9cfec2..bf4799e938 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs @@ -102,11 +102,10 @@ internal class DictionaryRepository : EntityRepositoryBase var options = new RepositoryCachePolicyOptions { // allow zero to be cached - GetAllCacheAllowZeroCount = true, + GetAllCacheAllowZeroCount = true }; - return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, - options); + return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, options); } protected IDictionaryItem ConvertFromDto(DictionaryDto dto) @@ -190,11 +189,10 @@ internal class DictionaryRepository : EntityRepositoryBase var options = new RepositoryCachePolicyOptions { // allow zero to be cached - GetAllCacheAllowZeroCount = true, + GetAllCacheAllowZeroCount = true }; - return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, - options); + return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, options); } } @@ -228,12 +226,13 @@ internal class DictionaryRepository : EntityRepositoryBase { var options = new RepositoryCachePolicyOptions { + // allow null to be cached + CacheNullValues = true, // allow zero to be cached - GetAllCacheAllowZeroCount = true, + GetAllCacheAllowZeroCount = true }; - return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, - options); + return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, options); } } From 095a73132c028093b54487ca834eeb1f84b98b99 Mon Sep 17 00:00:00 2001 From: jasont0101 Date: Wed, 5 Feb 2025 03:38:40 -0800 Subject: [PATCH 35/49] Review: Allow Duplicate Email for Members (#16202) * init * Aligned default values on security settings. * Added validator for security settings. * Provide default implementation for get members by email. * Refactored constructor of MemberController. * Validate on unique member email only when configured to do so. * Further code tidy and use of DI in constructor. * Used new constructor in tests. * Add unit test for modified behaviour. * Removed validator for security settings (it's not necessary, I got confused with users and members). * Spelling. --------- Co-authored-by: Andy Butland --- .../Configuration/Models/SecuritySettings.cs | 11 +- src/Umbraco.Core/Services/IMemberService.cs | 15 +++ src/Umbraco.Core/Services/MemberService.cs | 13 ++- .../Controllers/MemberController.cs | 68 +++++++++-- .../Filters/MemberSaveModelValidator.cs | 27 +++-- .../Filters/MemberSaveValidationAttribute.cs | 24 ++-- .../ConfigureMemberIdentityOptions.cs | 2 +- .../Controllers/MemberControllerUnitTests.cs | 108 +++++++++++++++--- 8 files changed, 219 insertions(+), 49 deletions(-) diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index f1005e5d1b..e68162e6ef 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -19,6 +19,8 @@ public class SecuritySettings internal const bool StaticAllowEditInvariantFromNonDefault = false; internal const bool StaticAllowConcurrentLogins = false; internal const string StaticAuthCookieName = "UMB_UCONTEXT"; + internal const bool StaticUsernameIsEmail = true; + internal const bool StaticMemberRequireUniqueEmail = true; internal const string StaticAllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+\\"; @@ -58,7 +60,14 @@ public class SecuritySettings /// /// Gets or sets a value indicating whether the user's email address is to be considered as their username. /// - public bool UsernameIsEmail { get; set; } = true; + [DefaultValue(StaticUsernameIsEmail)] + public bool UsernameIsEmail { get; set; } = StaticUsernameIsEmail; + + /// + /// Gets or sets a value indicating whether the member's email address must be unique. + /// + [DefaultValue(StaticMemberRequireUniqueEmail)] + public bool MemberRequireUniqueEmail { get; set; } = StaticMemberRequireUniqueEmail; /// /// Gets or sets the set of allowed characters for a username diff --git a/src/Umbraco.Core/Services/IMemberService.cs b/src/Umbraco.Core/Services/IMemberService.cs index a1be0b4a4c..7d78a979c8 100644 --- a/src/Umbraco.Core/Services/IMemberService.cs +++ b/src/Umbraco.Core/Services/IMemberService.cs @@ -210,6 +210,21 @@ public interface IMemberService : IMembershipMemberService /// IMember? GetById(int id); + /// + /// Get an list of for all members with the specified email. + /// + //// Email to use for retrieval + /// + /// + /// + IEnumerable GetMembersByEmail(string email) + => + // TODO (V16): Remove this default implementation. + // The following is very inefficient, but will return the correct data, so probably better than throwing a NotImplementedException + // in the default implentation here, for, presumably rare, cases where a custom IMemberService implementation has been registered and + // does not override this method. + GetAllMembers().Where(x => x.Email.Equals(email)); + /// /// Gets all Members for the specified MemberType alias /// diff --git a/src/Umbraco.Core/Services/MemberService.cs b/src/Umbraco.Core/Services/MemberService.cs index 43b5b8f28b..493ab313a7 100644 --- a/src/Umbraco.Core/Services/MemberService.cs +++ b/src/Umbraco.Core/Services/MemberService.cs @@ -389,16 +389,23 @@ namespace Umbraco.Cms.Core.Services } /// - /// Get an by email + /// Get an by email. If RequireUniqueEmailForMembers is set to false, then the first member found with the specified email will be returned. /// /// Email to use for retrieval /// - public IMember? GetByEmail(string email) + public IMember? GetByEmail(string email) => GetMembersByEmail(email).FirstOrDefault(); + + /// + /// Get an list of for all members with the specified email. + /// + /// Email to use for retrieval + /// + public IEnumerable GetMembersByEmail(string email) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); scope.ReadLock(Constants.Locks.MemberTree); IQuery query = Query().Where(x => x.Email.Equals(email)); - return _memberRepository.Get(query)?.FirstOrDefault(); + return _memberRepository.Get(query); } /// diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs index 8851a73a2b..4a18bf4620 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs @@ -8,8 +8,11 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.ContentApps; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Dictionary; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Mapping; @@ -26,7 +29,6 @@ using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.BackOffice.ModelBinders; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; @@ -55,6 +57,7 @@ public class MemberController : ContentControllerBase private readonly ITwoFactorLoginService _twoFactorLoginService; private readonly IShortStringHelper _shortStringHelper; private readonly IUmbracoMapper _umbracoMapper; + private readonly SecuritySettings _securitySettings; /// /// Initializes a new instance of the class. @@ -75,6 +78,7 @@ public class MemberController : ContentControllerBase /// The password changer /// The core scope provider /// The two factor login service + /// The security settings [ActivatorUtilitiesConstructor] public MemberController( ICultureDictionary cultureDictionary, @@ -92,7 +96,8 @@ public class MemberController : ContentControllerBase IJsonSerializer jsonSerializer, IPasswordChanger passwordChanger, ICoreScopeProvider scopeProvider, - ITwoFactorLoginService twoFactorLoginService) + ITwoFactorLoginService twoFactorLoginService, + IOptions securitySettings) : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, jsonSerializer) { _propertyEditors = propertyEditors; @@ -108,9 +113,49 @@ public class MemberController : ContentControllerBase _passwordChanger = passwordChanger; _scopeProvider = scopeProvider; _twoFactorLoginService = twoFactorLoginService; + _securitySettings = securitySettings.Value; } - [Obsolete("Use constructor that also takes an ITwoFactorLoginService. Scheduled for removal in V13")] + [Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")] + public MemberController( + ICultureDictionary cultureDictionary, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper, + IEventMessagesFactory eventMessages, + ILocalizedTextService localizedTextService, + PropertyEditorCollection propertyEditors, + IUmbracoMapper umbracoMapper, + IMemberService memberService, + IMemberTypeService memberTypeService, + IMemberManager memberManager, + IDataTypeService dataTypeService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IJsonSerializer jsonSerializer, + IPasswordChanger passwordChanger, + ICoreScopeProvider scopeProvider, + ITwoFactorLoginService twoFactorLoginService) + : this( + cultureDictionary, + loggerFactory, + shortStringHelper, + eventMessages, + localizedTextService, + propertyEditors, + umbracoMapper, + memberService, + memberTypeService, + memberManager, + dataTypeService, + backOfficeSecurityAccessor, + jsonSerializer, + passwordChanger, + scopeProvider, + twoFactorLoginService, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + [Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")] public MemberController( ICultureDictionary cultureDictionary, ILoggerFactory loggerFactory, @@ -461,7 +506,7 @@ public class MemberController : ContentControllerBase } // now re-look up the member, which will now exist - IMember? member = _memberService.GetByEmail(contentItem.Email); + IMember? member = _memberService.GetByUsername(contentItem.Username); if (member is null) { @@ -699,13 +744,16 @@ public class MemberController : ContentControllerBase return false; } - IMember? byEmail = _memberService.GetByEmail(contentItem.Email); - if (byEmail != null && byEmail.Key != contentItem.Key) + if (_securitySettings.MemberRequireUniqueEmail) { - ModelState.AddPropertyError( - new ValidationResult("Email address is already in use", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); - return false; + IMember? byEmail = _memberService.GetByEmail(contentItem.Email); + if (byEmail != null && byEmail.Key != contentItem.Key) + { + ModelState.AddPropertyError( + new ValidationResult("Email address is already in use", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); + return false; + } } return true; diff --git a/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs b/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs index 6b29803e05..68d37bba3c 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs @@ -2,8 +2,12 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; @@ -16,27 +20,29 @@ namespace Umbraco.Cms.Web.BackOffice.Filters; /// /// Validator for /// -internal class - MemberSaveModelValidator : ContentModelValidator> +internal class MemberSaveModelValidator : ContentModelValidator> { private readonly IBackOfficeSecurity? _backofficeSecurity; private readonly IMemberService _memberService; private readonly IMemberTypeService _memberTypeService; private readonly IShortStringHelper _shortStringHelper; + private readonly SecuritySettings _securitySettings; public MemberSaveModelValidator( - ILogger logger, - IBackOfficeSecurity? backofficeSecurity, - IMemberTypeService memberTypeService, - IMemberService memberService, - IShortStringHelper shortStringHelper, - IPropertyValidationService propertyValidationService) - : base(logger, propertyValidationService) + ILogger logger, + IBackOfficeSecurity? backofficeSecurity, + IMemberTypeService memberTypeService, + IMemberService memberService, + IShortStringHelper shortStringHelper, + IPropertyValidationService propertyValidationService, + SecuritySettings securitySettings) + : base(logger, propertyValidationService) { _backofficeSecurity = backofficeSecurity; _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _securitySettings = securitySettings; } public override bool ValidatePropertiesData( @@ -64,8 +70,7 @@ internal class $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); } - var validEmail = ValidateUniqueEmail(model); - if (validEmail == false) + if (_securitySettings.MemberRequireUniqueEmail && ValidateUniqueEmail(model) is false) { modelState.AddPropertyError( new ValidationResult("Email address is already in use", new[] { "value" }), diff --git a/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs index 61e119b66a..568d1e8240 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -25,6 +27,7 @@ internal sealed class MemberSaveValidationAttribute : TypeFilterAttribute private readonly IMemberTypeService _memberTypeService; private readonly IPropertyValidationService _propertyValidationService; private readonly IShortStringHelper _shortStringHelper; + private readonly SecuritySettings _securitySettings; public MemberSaveValidationFilter( ILoggerFactory loggerFactory, @@ -32,16 +35,16 @@ internal sealed class MemberSaveValidationAttribute : TypeFilterAttribute IMemberTypeService memberTypeService, IMemberService memberService, IShortStringHelper shortStringHelper, - IPropertyValidationService propertyValidationService) + IPropertyValidationService propertyValidationService, + IOptions securitySettings) { - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? - throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); - _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _propertyValidationService = propertyValidationService ?? - throw new ArgumentNullException(nameof(propertyValidationService)); + _loggerFactory = loggerFactory; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _memberTypeService = memberTypeService; + _memberService = memberService; + _shortStringHelper = shortStringHelper; + _propertyValidationService = propertyValidationService; + _securitySettings = securitySettings.Value; } public void OnActionExecuting(ActionExecutingContext context) @@ -53,7 +56,8 @@ internal sealed class MemberSaveValidationAttribute : TypeFilterAttribute _memberTypeService, _memberService, _shortStringHelper, - _propertyValidationService); + _propertyValidationService, + _securitySettings); //now do each validation step if (contentItemValidator.ValidateExistingContent(model, context)) { diff --git a/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs index 0fcc41d9d0..1c9b88b6cf 100644 --- a/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs +++ b/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs @@ -24,7 +24,7 @@ public sealed class ConfigureMemberIdentityOptions : IConfigureOptions passwordChanger, IOptions globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange SetupMemberTestData(out var fakeMemberData, out _, ContentSaveAction.SaveNew); + + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -84,7 +86,8 @@ public class MemberControllerUnitTests backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); sut.ModelState.AddModelError("key", "Invalid model state"); Mock.Get(umbracoMembersUserManager) @@ -116,7 +119,6 @@ public class MemberControllerUnitTests IBackOfficeSecurity backOfficeSecurity, IPasswordChanger passwordChanger, IOptions globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange @@ -138,6 +140,8 @@ public class MemberControllerUnitTests .Returns(() => member); Mock.Get(memberService).Setup(x => x.GetByUsername(It.IsAny())).Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -147,7 +151,8 @@ public class MemberControllerUnitTests backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -170,7 +175,6 @@ public class MemberControllerUnitTests IBackOfficeSecurity backOfficeSecurity, IPasswordChanger passwordChanger, IOptions globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange @@ -192,6 +196,8 @@ public class MemberControllerUnitTests .Returns(() => member); Mock.Get(memberService).Setup(x => x.GetByUsername(It.IsAny())).Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -201,7 +207,8 @@ public class MemberControllerUnitTests backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -256,6 +263,8 @@ public class MemberControllerUnitTests .Returns(() => null) .Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -265,7 +274,8 @@ public class MemberControllerUnitTests backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -316,6 +326,8 @@ public class MemberControllerUnitTests .Returns(() => null) .Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -325,7 +337,8 @@ public class MemberControllerUnitTests backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -382,7 +395,6 @@ public class MemberControllerUnitTests IBackOfficeSecurity backOfficeSecurity, IPasswordChanger passwordChanger, IOptions globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange @@ -403,6 +415,8 @@ public class MemberControllerUnitTests x => x.GetByEmail(It.IsAny())) .Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -412,7 +426,8 @@ public class MemberControllerUnitTests backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = sut.PostSave(fakeMemberData).Result; @@ -424,6 +439,66 @@ public class MemberControllerUnitTests Assert.AreEqual(StatusCodes.Status400BadRequest, validation?.StatusCode); } + [Test] + [AutoMoqData] + public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_AndDuplicateEmailsAreAllowed_ExpectSuccessResponse( + [Frozen] IMemberManager umbracoMembersUserManager, + IMemberService memberService, + IMemberTypeService memberTypeService, + IMemberGroupService memberGroupService, + IDataTypeService dataTypeService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IBackOfficeSecurity backOfficeSecurity, + IPasswordChanger passwordChanger, + IOptions globalSettings, + ITwoFactorLoginService twoFactorLoginService) + { + // arrange + var member = SetupMemberTestData(out var fakeMemberData, out var memberDisplay, ContentSaveAction.SaveNew); + Mock.Get(umbracoMembersUserManager) + .Setup(x => x.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => IdentityResult.Success); + Mock.Get(umbracoMembersUserManager) + .Setup(x => x.ValidatePasswordAsync(It.IsAny())) + .ReturnsAsync(() => IdentityResult.Success); + Mock.Get(umbracoMembersUserManager) + .Setup(x => x.GetRolesAsync(It.IsAny())) + .ReturnsAsync(() => Array.Empty()); + Mock.Get(memberTypeService).Setup(x => x.GetDefault()).Returns("fakeAlias"); + Mock.Get(backOfficeSecurityAccessor).Setup(x => x.BackOfficeSecurity).Returns(backOfficeSecurity); + Mock.Get(memberService).SetupSequence( + x => x.GetByEmail(It.IsAny())) + .Returns(() => null) + .Returns(() => member); + Mock.Get(memberService).Setup(x => x.GetByUsername(It.IsAny())).Returns(() => member); + + Mock.Get(memberService).SetupSequence( + x => x.GetByEmail(It.IsAny())) + .Returns(() => member); + + var securitySettings = Options.Create(new SecuritySettings { MemberRequireUniqueEmail = false }); + + var sut = CreateSut( + memberService, + memberTypeService, + memberGroupService, + umbracoMembersUserManager, + dataTypeService, + backOfficeSecurityAccessor, + passwordChanger, + globalSettings, + twoFactorLoginService, + securitySettings); + + // act + var result = sut.PostSave(fakeMemberData).Result; + var validation = result.Result as ValidationErrorResult; + + // assert + Assert.IsNull(result.Result); + Assert.IsNotNull(result.Value); + } + [Test] [AutoMoqData] public async Task PostSaveMember_SaveExistingMember_WithNoRoles_Add1Role_ExpectSuccessResponse( @@ -472,6 +547,9 @@ public class MemberControllerUnitTests x => x.GetByEmail(It.IsAny())) .Returns(() => null) .Returns(() => member); + + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -481,7 +559,8 @@ public class MemberControllerUnitTests backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -512,6 +591,7 @@ public class MemberControllerUnitTests /// Password changer class /// The global settings /// The two factor login service + /// The security settings /// A member controller for the tests private MemberController CreateSut( IMemberService memberService, @@ -522,7 +602,8 @@ public class MemberControllerUnitTests IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IPasswordChanger passwordChanger, IOptions globalSettings, - ITwoFactorLoginService twoFactorLoginService) + ITwoFactorLoginService twoFactorLoginService, + IOptions securitySettings) { var httpContextAccessor = new HttpContextAccessor(); @@ -623,7 +704,8 @@ public class MemberControllerUnitTests new ConfigurationEditorJsonSerializer(), passwordChanger, scopeProvider, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); } /// From 280cb7f2b15723f9a1b9e9ad954d3ee8ce60b262 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Fri, 7 Feb 2025 07:00:34 +0100 Subject: [PATCH 36/49] Fix issues in newly added buttongroup localization (#18254) * Fix #18253 nullref exception * Fix #18239 by listening to broader scope changes --- .../buttons/umbbuttongroup.directive.js | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js index 8c7836a2e6..2a526ae2af 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js @@ -90,9 +90,7 @@ Use this directive to render a button with a dropdown of alternative actions. **/ (function () { 'use strict'; - function ButtonGroupDirective() { - function controller($scope, localizationService) { $scope.toggleStyle = null; $scope.blockElement = false; @@ -125,18 +123,24 @@ Use this directive to render a button with a dropdown of alternative actions. // As the directive doesn't support Angular expressions as fallback, we instead listen for changes // to the label key of the default button, and if detected, we update the button label with the localized value // received from the localization service - $scope.$watch("defaultButton.labelKey", function () { - if (!$scope.defaultButton.labelKey) return; + $scope.$watch("defaultButton", localizeDefaultButtonLabel); + $scope.$watch("defaultButton.labelKey", localizeDefaultButtonLabel); + + function localizeDefaultButtonLabel() { + if (!$scope.defaultButton?.labelKey) return; localizationService.localize($scope.defaultButton.labelKey).then(value => { if (value && value.indexOf("[") === 0) return; $scope.defaultButton.label = value; }); - }); + } // In a similar way, we must listen for changes to the sub buttons (or their label keys), and if detected, update // the label with the localized value received from the localization service - $scope.$watch("defaultButton.subButtons", function () { - if (!Array.isArray($scope.subButtons)) return; + $scope.$watch("subButtons", localizeSubButtons, true); + $scope.$watch("defaultButton.subButtons", localizeSubButtons, true); + + function localizeSubButtons() { + if (!$scope.subButtons || !Array.isArray($scope.subButtons)) return; $scope.subButtons.forEach(function (sub) { if (!sub.labelKey) return; localizationService.localize(sub.labelKey).then(value => { @@ -144,7 +148,7 @@ Use this directive to render a button with a dropdown of alternative actions. sub.label = value; }); }); - }, true); + } } From 4ca68d69951d685bce328a4266ef96317219e6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 7 Feb 2025 12:43:04 +0100 Subject: [PATCH 37/49] Add client-side validation for RTE (#18257) --- .../views/propertyeditors/rte/rte.component.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js index 2794f0bd16..bb9006779d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js @@ -83,6 +83,7 @@ } })); + vm.layout = []; // The layout object specific to this Block Editor, will be a direct reference from Property Model. vm.availableBlockTypes = []; // Available block entries of this property editor. vm.labels = {}; @@ -190,6 +191,9 @@ vm.containerHeight = "auto"; vm.containerOverflow = "inherit" + // Add client validation for the markup part. + unsubscribe.push($scope.$watch(() => vm.model?.value?.markup, validate)); + //queue file loading tinyMceAssets.forEach(function (tinyJsAsset) { assetPromises.push(assetsService.loadJs(tinyJsAsset, $scope)); @@ -337,6 +341,18 @@ } } + function validate() { + var isValid = !vm.model.validation.mandatory || ( + vm.model.value != null + && vm.model.value.markup != null + && vm.model.value.markup != "" + ); + vm.propertyForm.$setValidity("required", isValid); + if (vm.umbProperty) { + vm.umbProperty.setPropertyError(vm.model.validation.mandatoryMessage || "Value cannot be empty"); + } + }; + // Called when we save the value, the server may return an updated data and our value is re-synced // we need to deal with that here so that our model values are all in sync so we basically re-initialize. function onServerValueChanged(newVal, oldVal) { From a3b77cff63aa7597eb4eea1b07a780525bdfe6a2 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 10 Feb 2025 10:40:53 +0100 Subject: [PATCH 38/49] Add validation to prevent update of a user or member to an invalid username (13) (#18261) * Add validation to prevent update of a user or member to an invalid username. * Avoid password manager updates of user name field on user details screen. --- .../Controllers/MemberController.cs | 11 +++++++++++ .../Controllers/UsersController.cs | 9 +++++++++ .../src/views/users/views/user/details.html | 4 +++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs index 4a18bf4620..d03fa87a4a 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs @@ -723,6 +723,17 @@ public class MemberController : ContentControllerBase return false; } + // User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create + // as the setting is applied to the IdentityOptions, but we need to check ourselves for updates. + var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters; + if (contentItem.Username.Any(c => allowedUserNameCharacters.Contains(c) == false)) + { + ModelState.AddPropertyError( + new ValidationResult("Username contains invalid characters"), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); + return false; + } + if (contentItem.Password != null && !contentItem.Password.NewPassword.IsNullOrWhiteSpace()) { IdentityResult validPassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword); diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index c855a87ea4..2f128f1f09 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -714,6 +714,15 @@ public class UsersController : BackOfficeNotificationsController var hasErrors = false; + // User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create + // as the setting is applied to the BackOfficeIdentityOptions, but we need to check ourselves for updates. + var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters; + if (userSave.Username.Any(c => allowedUserNameCharacters.Contains(c) == false)) + { + ModelState.AddModelError("Username", "Username contains invalid characters"); + hasErrors = true; + } + // we need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed var hasDenyLocalLogin = _externalLogins.HasDenyLocalLogin(); if (hasDenyLocalLogin) diff --git a/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html b/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html index eaa92b7a6e..1eb6840fd3 100644 --- a/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html +++ b/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html @@ -1,4 +1,4 @@ -
+
@@ -45,6 +45,8 @@ ng-model="model.user.username" umb-auto-focus name="username" required + autocomplete="off" + no-password-manager val-server-field="Username" /> Required From 026e80e02662929943a9d0886598f66b49d98296 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 10 Feb 2025 12:51:35 +0100 Subject: [PATCH 39/49] Avoid an exception on sign out when the principal is populated from an incomplete external login (#18078) * Avoid an exception on signout when the principal is populated from an incomplete external login. * Tidied up comment. --- .../Security/BackOfficeUserStore.cs | 15 ++++++++++++++- .../Security/UmbracoUserStore.cs | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 0d2767dd25..21f8978e71 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -281,7 +281,20 @@ public class BackOfficeUserStore : UmbracoUserStore protected static int UserIdToInt(string? userId) { - if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + if (TryUserIdToInt(userId, out int result)) { return result; } + throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); + } + + protected static bool TryUserIdToInt(string? userId, out int result) + { + if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)) + { + return true; + } + if (Guid.TryParse(userId, out Guid key)) { // Reverse the IntExtensions.ToGuid - return BitConverter.ToInt32(key.ToByteArray(), 0); + result = BitConverter.ToInt32(key.ToByteArray(), 0); + return true; } - throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); + return false; } protected static string UserIdToString(int userId) => string.Intern(userId.ToString(CultureInfo.InvariantCulture)); From f30e6cfe7f207ebae942b10a6ea449bdc026a04a Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 11 Feb 2025 09:18:01 +0100 Subject: [PATCH 40/49] Prevents folder selection in media picker when used from the multi URL picker. (#18288) --- .../common/infiniteeditors/linkpicker/linkpicker.controller.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js index 673e1a5d3d..e3799def3c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js @@ -189,6 +189,7 @@ angular.module("umbraco").controller("Umbraco.Editors.LinkPickerController", startNodeId: startNodeId, startNodeIsVirtual: startNodeIsVirtual, dataTypeKey: dialogOptions.dataTypeKey, + disableFolderSelect: true, submit: function (model) { var media = model.selection[0]; From 7bcbc748d49e986b9478d184fb6052e82f1c7d1d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 11 Feb 2025 13:36:19 +0100 Subject: [PATCH 41/49] URL encodes member user names when passing information for public access setting such that those with user names as emails containing a plus will be included in the rule. (#18142) --- .../src/common/resources/publicaccess.resource.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js index d91924a2eb..3e0ac90620 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js @@ -79,7 +79,7 @@ function publicAccessResource($http, umbRequestHelper) { publicAccess.groups = groups; } else if (Utilities.isArray(usernames) && usernames.length) { - publicAccess.usernames = usernames; + publicAccess.usernames = usernames.map(u => encodeURIComponent(u)); } else { throw "must supply either userName/password or roles"; From 048f8bcdf91fd2b113085771b82abd528724ec1d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 11 Feb 2025 17:06:18 +0100 Subject: [PATCH 42/49] Fixed userResource request to get all users. (#18105) --- src/Umbraco.Web.BackOffice/Controllers/UsersController.cs | 2 +- .../DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs | 1 + .../src/common/resources/users.resource.js | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index 2f128f1f09..960afa365e 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -322,7 +322,7 @@ public class UsersController : BackOfficeNotificationsController /// [OutgoingEditorModelEvent] [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public ActionResult> GetByIds([FromJsonPath] int[] ids) + public ActionResult> GetByIds([FromQuery] int[] ids) { if (ids == null) { diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs index 0945d3459b..fd3bfe71bc 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs @@ -202,6 +202,7 @@ public static partial class UmbracoBuilderExtensions { policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); policy.Requirements.Add(new AdminUsersRequirement()); + policy.Requirements.Add(new AdminUsersRequirement("ids")); policy.Requirements.Add(new AdminUsersRequirement("userIds")); }); diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js index 0b69bec3f5..c514be49a0 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js @@ -318,12 +318,14 @@ */ function getUsers(userIds) { + var idQuery = ""; + userIds.forEach(id => idQuery += `ids=${id}&`); return umbRequestHelper.resourcePromise( $http.get( umbRequestHelper.getApiUrl( "userApiBaseUrl", "GetByIds", - { ids: userIds })), + idQuery)), "Failed to retrieve data for users " + userIds); } From a282cc5691c19ea39d4429e31626d1c5413c77e7 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 12 Feb 2025 08:06:50 +0100 Subject: [PATCH 43/49] Backport use of thread delay over sleep and handle dispose in FileSystemMainDomLock (#18151) * Backport use of thread delay over sleep and handle dispose in FileSystemMainDomLock (from PRs #18119 and #18147) * Applied suggestion from code review. --- .../Runtime/FileSystemMainDomLock.cs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs b/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs index 6dcd3ef9b0..27662f979a 100644 --- a/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs +++ b/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs @@ -15,6 +15,7 @@ internal class FileSystemMainDomLock : IMainDomLock private readonly string _lockFilePath; private readonly ILogger _logger; private readonly string _releaseSignalFilePath; + private bool _disposed; private Task? _listenForReleaseSignalFileTask; private FileStream? _lockFileStream; @@ -88,16 +89,14 @@ internal class FileSystemMainDomLock : IMainDomLock ListeningLoop, _cancellationTokenSource.Token, TaskCreationOptions.LongRunning, - TaskScheduler.Default); + TaskScheduler.Default) + .Unwrap(); // Because ListeningLoop is an async method, we need to use Unwrap to return the inner task. return _listenForReleaseSignalFileTask; } - public void Dispose() - { - _lockFileStream?.Close(); - _lockFileStream = null; - } + /// Releases the resources used by this . + public void Dispose() => Dispose(true); public void CreateLockReleaseSignalFile() => File.Open(_releaseSignalFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, @@ -107,7 +106,27 @@ internal class FileSystemMainDomLock : IMainDomLock public void DeleteLockReleaseSignalFile() => File.Delete(_releaseSignalFilePath); - private void ListeningLoop() + /// Releases the resources used by this . + /// true to release both managed resources. + protected virtual void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _logger.LogInformation($"{nameof(FileSystemMainDomLock)} Disposing..."); + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + ReleaseLock(); + _disposed = true; + } + } + + private void ReleaseLock() + { + _lockFileStream?.Close(); + _lockFileStream = null; + } + + private async Task ListeningLoop() { while (true) { @@ -126,12 +145,12 @@ internal class FileSystemMainDomLock : IMainDomLock { _logger.LogDebug("Found lock release signal file, releasing lock on {lockFilePath}", _lockFilePath); } - _lockFileStream?.Close(); - _lockFileStream = null; + + ReleaseLock(); break; } - Thread.Sleep(_globalSettings.CurrentValue.MainDomReleaseSignalPollingInterval); + await Task.Delay(_globalSettings.CurrentValue.MainDomReleaseSignalPollingInterval, _cancellationTokenSource.Token); } } } From 5322d0f7b597323ca32bb4c2ba7999aadfcac0da Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 12 Feb 2025 08:13:58 +0100 Subject: [PATCH 44/49] Bumped version to 13.8.0-rc. --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index ee882f912b..94daac6c7a 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.7.0-rc", + "version": "13.8.0-rc", "assemblyVersion": { "precision": "build" }, From 9227517a50031f116273582f73cf70167fb65d37 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 12 Feb 2025 12:30:27 +0100 Subject: [PATCH 45/49] Split force for publish descendants into separate options for publish unpublish and re-publish unedited (13) (#18249) * Split force for publish descendents into separate options for publish unpublish and re-publish unedited. * Added integration task verifying updated behaviour. * Variant integration test. * Update test data controller. * Remove usued function parameters. * Refactor to enum. * Fixed flags enum. * Variable name refactor. * Applied changes from code review. * Refactored method name. * Aligned js boolean checks. --- .../EmbeddedResources/Lang/en.xml | 1 + .../EmbeddedResources/Lang/en_us.xml | 1 + .../ContentEditing/ContentSaveAction.cs | 60 ++++-- .../Models/PublishBranchFilter.cs | 28 +++ src/Umbraco.Core/Services/ContentService.cs | 47 ++--- src/Umbraco.Core/Services/IContentService.cs | 40 ++++ .../Controllers/ContentController.cs | 70 +++++-- .../Filters/ContentSaveValidationAttribute.cs | 10 + .../components/content/edit.controller.js | 2 +- .../src/common/resources/content.resource.js | 10 +- .../overlays/publishdescendants.controller.js | 14 +- .../content/overlays/publishdescendants.html | 24 +++ .../UmbracoTestDataController.cs | 2 +- .../Services/ContentServiceTests.cs | 4 +- .../Services/ContentEventsTests.cs | 4 +- .../ContentServicePublishBranchTests.cs | 191 ++++++++++++++++-- .../Services/ContentServiceTagsTests.cs | 2 +- 17 files changed, 417 insertions(+), 93 deletions(-) create mode 100644 src/Umbraco.Core/Models/PublishBranchFilter.cs diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 5f48939766..cf79f426b7 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -309,6 +309,7 @@ Remove this text box Content root Include unpublished content items. + Publish unchanged items. This value is hidden. If you need access to view this value please contact your website administrator. diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index f86b082513..bd387b4a07 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -308,6 +308,7 @@ Remove this text box Content root Include unpublished content items. + Publish unchanged items. This value is hidden. If you need access to view this value please contact your website administrator. diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentSaveAction.cs b/src/Umbraco.Core/Models/ContentEditing/ContentSaveAction.cs index 889b03db6d..929ee7c097 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentSaveAction.cs +++ b/src/Umbraco.Core/Models/ContentEditing/ContentSaveAction.cs @@ -1,69 +1,101 @@ namespace Umbraco.Cms.Core.Models.ContentEditing; /// -/// The action associated with saving a content item +/// The action associated with saving a content item. /// public enum ContentSaveAction { /// - /// Saves the content item, no publish + /// Saves the content item, no publish. /// Save = 0, /// - /// Creates a new content item + /// Creates a new content item. /// SaveNew = 1, /// - /// Saves and publishes the content item + /// Saves and publishes the content item. /// Publish = 2, /// - /// Creates and publishes a new content item + /// Creates and publishes a new content item. /// PublishNew = 3, /// - /// Saves and sends publish notification + /// Saves and sends publish notification. /// SendPublish = 4, /// - /// Creates and sends publish notification + /// Creates and sends publish notification. /// SendPublishNew = 5, /// - /// Saves and schedules publishing + /// Saves and schedules publishing. /// Schedule = 6, /// - /// Creates and schedules publishing + /// Creates and schedules publishing. /// ScheduleNew = 7, /// - /// Saves and publishes the content item including all descendants that have a published version + /// Saves and publishes the content item including all descendants that have a published version. /// PublishWithDescendants = 8, /// - /// Creates and publishes the content item including all descendants that have a published version + /// Creates and publishes the new content item including all descendants that have a published version. /// PublishWithDescendantsNew = 9, /// /// Saves and publishes the content item including all descendants regardless of whether they have a published version - /// or not + /// or not. /// + [Obsolete("This option is no longer used as the 'force' aspect has been extended into options for publishing unpublished and re-publishing changed content. Please use one of those options instead.")] PublishWithDescendantsForce = 10, /// - /// Creates and publishes the content item including all descendants regardless of whether they have a published - /// version or not + /// Creates and publishes the new content item including all descendants regardless of whether they have a published + /// version or not. /// + [Obsolete("This option is no longer used as the 'force' aspect has been extended into options for publishing unpublished and re-publishing changed content. Please use one of those options instead.")] PublishWithDescendantsForceNew = 11, + + /// + /// Saves and publishes the content item including all descendants including publishing previously unpublished content. + /// + PublishWithDescendantsIncludeUnpublished = 12, + + /// + /// Saves and publishes the new content item including all descendants including publishing previously unpublished content. + /// + PublishWithDescendantsIncludeUnpublishedNew = 13, + + /// + /// Saves and publishes the content item including all descendants irrespective of whether there are any pending changes. + /// + PublishWithDescendantsForceRepublish = 14, + + /// + /// Saves and publishes the new content item including all descendants including publishing previously unpublished content. + /// + PublishWithDescendantsForceRepublishNew = 15, + + /// + /// Saves and publishes the content item including all descendants including publishing previously unpublished content and irrespective of whether there are any pending changes. + /// + PublishWithDescendantsIncludeUnpublishedAndForceRepublish = 16, + + /// + /// Saves and publishes the new content item including all descendants including publishing previously unpublished content and irrespective of whether there are any pending changes. + /// + PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew = 17, } diff --git a/src/Umbraco.Core/Models/PublishBranchFilter.cs b/src/Umbraco.Core/Models/PublishBranchFilter.cs new file mode 100644 index 0000000000..e47a07f677 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishBranchFilter.cs @@ -0,0 +1,28 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Describes the options available with publishing a content branch for force publishing. +/// +[Flags] +public enum PublishBranchFilter +{ + /// + /// The default behavior is to publish only the published content that has changed. + /// + Default = 0, + + /// + /// For publishing a branch, publish all changed content, including content that is not published. + /// + IncludeUnpublished = 1, + + /// + /// For publishing a branch, force republishing of all published content, including content that has not changed. + /// + ForceRepublish = 2, + + /// + /// For publishing a branch, publish all content, including content that is not published and content that has not changed. + /// + All = IncludeUnpublished | ForceRepublish, +} diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 5ff81a2165..45176629a2 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1963,17 +1963,14 @@ public class ContentService : RepositoryService, IContentService } // utility 'ShouldPublish' func used by SaveAndPublishBranch - private HashSet? SaveAndPublishBranch_ShouldPublish(ref HashSet? cultures, string c, bool published, bool edited, bool isRoot, bool force) + private HashSet? SaveAndPublishBranch_ShouldPublish(ref HashSet? cultures, string c, bool published, bool edited, bool isRoot, PublishBranchFilter publishBranchFilter) { // if published, republish if (published) { - if (cultures == null) - { - cultures = new HashSet(); // empty means 'already published' - } + cultures ??= []; // empty means 'already published' - if (edited) + if (edited || publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish)) { cultures.Add(c); // means 'republish this culture' } @@ -1982,15 +1979,12 @@ public class ContentService : RepositoryService, IContentService } // if not published, publish if force/root else do nothing - if (!force && !isRoot) + if (!publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) && !isRoot) { return cultures; // null means 'nothing to do' } - if (cultures == null) - { - cultures = new HashSet(); - } + cultures ??= []; cultures.Add(c); // means 'publish this culture' return cultures; @@ -1998,6 +1992,10 @@ public class ContentService : RepositoryService, IContentService /// public IEnumerable SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = Constants.Security.SuperUserId) + => SaveAndPublishBranch(content, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, culture, userId); + + /// + public IEnumerable SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string culture = "*", int userId = Constants.Security.SuperUserId) { // note: EditedValue and PublishedValue are objects here, so it is important to .Equals() // and not to == them, else we would be comparing references, and that is a bad thing @@ -2016,13 +2014,13 @@ public class ContentService : RepositoryService, IContentService // invariant content type if (!c.ContentType.VariesByCulture()) { - return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force); + return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter); } // variant content type, specific culture if (culture != "*") { - return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, force); + return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, publishBranchFilter); } // variant content type, all cultures @@ -2032,23 +2030,27 @@ public class ContentService : RepositoryService, IContentService // others will have to 'republish this culture' foreach (var x in c.AvailableCultures) { - SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force); + SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, publishBranchFilter); } return culturesToPublish; } - // if not published, publish if force/root else do nothing - return force || isRoot + // if not published, publish if forcing unpublished/root else do nothing + return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot ? new HashSet { "*" } // "*" means 'publish all' : null; // null means 'nothing to do' } - return SaveAndPublishBranch(content, force, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId); + return SaveAndPublishBranch(content, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId); } /// public IEnumerable SaveAndPublishBranch(IContent content, bool force, string[] cultures, int userId = Constants.Security.SuperUserId) + => SaveAndPublishBranch(content, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, cultures, userId); + + /// + public IEnumerable SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId) { // note: EditedValue and PublishedValue are objects here, so it is important to .Equals() // and not to == them, else we would be comparing references, and that is a bad thing @@ -2064,7 +2066,7 @@ public class ContentService : RepositoryService, IContentService // invariant content type if (!c.ContentType.VariesByCulture()) { - return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force); + return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter); } // variant content type, specific cultures @@ -2074,24 +2076,23 @@ public class ContentService : RepositoryService, IContentService // others will have to 'republish this culture' foreach (var x in cultures) { - SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force); + SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, publishBranchFilter); } return culturesToPublish; } - // if not published, publish if force/root else do nothing - return force || isRoot + // if not published, publish if forcing unpublished/root else do nothing + return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot ? new HashSet(cultures) // means 'publish specified cultures' : null; // null means 'nothing to do' } - return SaveAndPublishBranch(content, force, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId); + return SaveAndPublishBranch(content, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId); } internal IEnumerable SaveAndPublishBranch( IContent document, - bool force, Func?> shouldPublish, Func, IReadOnlyCollection, bool> publishCultures, int userId = Constants.Security.SuperUserId) diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index 1733a74142..c7b33589b3 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -431,6 +431,7 @@ public interface IContentService : IContentServiceBase /// published. The root of the branch is always published, regardless of . /// /// + [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead.")] IEnumerable SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = Constants.Security.SuperUserId); /// @@ -447,8 +448,47 @@ public interface IContentService : IContentServiceBase /// published. The root of the branch is always published, regardless of . /// /// + [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead.")] IEnumerable SaveAndPublishBranch(IContent content, bool force, string[] cultures, int userId = Constants.Security.SuperUserId); + /// + /// Saves and publishes a document branch. + /// + /// The root document. + /// A value indicating options for force publishing unpublished or re-publishing unchanged content. + /// A culture, or "*" for all cultures. + /// The identifier of the user performing the operation. + /// + /// + /// Unless specified, all cultures are re-published. Otherwise, one culture can be specified. To act on more + /// than one culture, see the other overloads of this method. + /// + /// + /// The root of the branch is always published, regardless of . + /// + /// + IEnumerable SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string culture = "*", int userId = Constants.Security.SuperUserId) +#pragma warning disable CS0618 // Type or member is obsolete + => SaveAndPublishBranch(content, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), culture, userId); +#pragma warning restore CS0618 // Type or member is obsolete + + /// + /// Saves and publishes a document branch. + /// + /// The root document. + /// A value indicating options for force publishing unpublished or re-publishing unchanged content. + /// The cultures to publish. + /// The identifier of the user performing the operation. + /// + /// + /// The root of the branch is always published, regardless of . + /// + /// + IEnumerable SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId) +#pragma warning disable CS0618 // Type or member is obsolete + => SaveAndPublishBranch(content, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), cultures, userId); +#pragma warning restore CS0618 // Type or member is obsolete + ///// ///// Saves and publishes a document branch. ///// diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index c676cee2ca..ef935b6b59 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -999,18 +999,29 @@ public class ContentController : ContentControllerBase // if there's only one variant and the model state is not valid we cannot publish so change it to save if (variantCount == 1) { + switch (contentItem.Action) { case ContentSaveAction.Publish: case ContentSaveAction.PublishWithDescendants: +#pragma warning disable CS0618 // Type or member is obsolete case ContentSaveAction.PublishWithDescendantsForce: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublished: + case ContentSaveAction.PublishWithDescendantsForceRepublish: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish: case ContentSaveAction.SendPublish: case ContentSaveAction.Schedule: contentItem.Action = ContentSaveAction.Save; break; case ContentSaveAction.PublishNew: case ContentSaveAction.PublishWithDescendantsNew: +#pragma warning disable CS0618 // Type or member is obsolete case ContentSaveAction.PublishWithDescendantsForceNew: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedNew: + case ContentSaveAction.PublishWithDescendantsForceRepublishNew: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew: case ContentSaveAction.SendPublishNew: case ContentSaveAction.ScheduleNew: contentItem.Action = ContentSaveAction.SaveNew; @@ -1144,6 +1155,16 @@ public class ContentController : ContentControllerBase break; case ContentSaveAction.PublishWithDescendants: case ContentSaveAction.PublishWithDescendantsNew: +#pragma warning disable CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsForce: + case ContentSaveAction.PublishWithDescendantsForceNew: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublished: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedNew: + case ContentSaveAction.PublishWithDescendantsForceRepublish: + case ContentSaveAction.PublishWithDescendantsForceRepublishNew: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew: { if (!await ValidatePublishBranchPermissionsAsync(contentItem)) { @@ -1154,7 +1175,7 @@ public class ContentController : ContentControllerBase break; } - var publishStatus = PublishBranchInternal(contentItem, false, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); + var publishStatus = PublishBranchInternal(contentItem, BuildPublishBranchFilter(contentItem.Action), cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); var addedDomainWarnings = AddDomainWarnings(publishStatus, successfulCultures, globalNotifications, defaultCulture); AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures); if (addedDomainWarnings is false) @@ -1163,22 +1184,6 @@ public class ContentController : ContentControllerBase } } break; - case ContentSaveAction.PublishWithDescendantsForce: - case ContentSaveAction.PublishWithDescendantsForceNew: - { - if (!await ValidatePublishBranchPermissionsAsync(contentItem)) - { - globalNotifications.AddErrorNotification( - _localizedTextService.Localize(null, "publish"), - _localizedTextService.Localize("publish", "invalidPublishBranchPermissions")); - wasCancelled = false; - break; - } - - var publishStatus = PublishBranchInternal(contentItem, true, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); - AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures); - } - break; default: throw new ArgumentOutOfRangeException(); } @@ -1228,6 +1233,31 @@ public class ContentController : ContentControllerBase return display; } + private static PublishBranchFilter BuildPublishBranchFilter(ContentSaveAction contentSaveAction) + { + var includeUnpublished = contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublished + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedNew + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew; + var forceRepublish = contentSaveAction == ContentSaveAction.PublishWithDescendantsForceRepublish + || contentSaveAction == ContentSaveAction.PublishWithDescendantsForceRepublishNew + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew; + + PublishBranchFilter publishBranchFilter = PublishBranchFilter.Default; + if (includeUnpublished) + { + publishBranchFilter |= PublishBranchFilter.IncludeUnpublished; + } + + if (forceRepublish) + { + publishBranchFilter |= PublishBranchFilter.ForceRepublish; + } + + return publishBranchFilter; + } + private void AddPublishStatusNotifications( IReadOnlyCollection publishStatus, SimpleNotificationModel globalNotifications, @@ -1668,12 +1698,12 @@ public class ContentController : ContentControllerBase return authorizationResult.Succeeded; } - private IEnumerable PublishBranchInternal(ContentItemSave contentItem, bool force, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures) + private IEnumerable PublishBranchInternal(ContentItemSave contentItem, PublishBranchFilter publishBranchFilter, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures) { if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false) { //its invariant, proceed normally - IEnumerable publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, force, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + IEnumerable publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, publishBranchFilter, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); // TODO: Deal with multiple cancellations wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent); successfulCultures = null; //must be null! this implies invariant @@ -1709,7 +1739,7 @@ public class ContentController : ContentControllerBase { //proceed to publish if all validation still succeeds IEnumerable publishStatus = _contentService.SaveAndPublishBranch( - contentItem.PersistedContent!, force, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + contentItem.PersistedContent!, publishBranchFilter, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); // TODO: Deal with multiple cancellations wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent); successfulCultures = contentItem.Variants.Where(x => x.Publish).Select(x => x.Culture).WhereNotNull() diff --git a/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs index c75bbd5a80..45948b5960 100644 --- a/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs @@ -182,7 +182,12 @@ public sealed class ContentSaveValidationAttribute : TypeFilterAttribute break; case ContentSaveAction.Publish: case ContentSaveAction.PublishWithDescendants: +#pragma warning disable CS0618 // Type or member is obsolete case ContentSaveAction.PublishWithDescendantsForce: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublished: + case ContentSaveAction.PublishWithDescendantsForceRepublish: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish: permissionToCheck.Add(ActionPublish.ActionLetter); contentToCheck = contentItem.PersistedContent; contentIdToCheck = contentToCheck?.Id ?? default; @@ -232,7 +237,12 @@ public sealed class ContentSaveValidationAttribute : TypeFilterAttribute break; case ContentSaveAction.PublishNew: case ContentSaveAction.PublishWithDescendantsNew: +#pragma warning disable CS0618 // Type or member is obsolete case ContentSaveAction.PublishWithDescendantsForceNew: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedNew: + case ContentSaveAction.PublishWithDescendantsForceRepublishNew: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew: //Publish new requires both ActionNew AND ActionPublish // TODO: Shouldn't publish also require ActionUpdate since it will definitely perform an update to publish but maybe that's just implied diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js index 72c5f3fec1..6d5503301b 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js @@ -963,7 +963,7 @@ //we need to return this promise so that the dialog can handle the result and wire up the validation response return performSave({ saveMethod: function (content, create, files, showNotifications) { - return contentResource.publishWithDescendants(content, create, model.includeUnpublished, files, showNotifications); + return contentResource.publishWithDescendants(content, create, model.includeUnpublished, model.forceRepublish, files, showNotifications); }, action: "publishDescendants", showNotifications: false, diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js index b3218b2c7f..a86675916f 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js @@ -1003,14 +1003,18 @@ function contentResource($q, $http, umbDataFormatter, umbRequestHelper) { * @returns {Promise} resourcePromise object containing the saved content item. * */ - publishWithDescendants: function (content, isNew, force, files, showNotifications) { + publishWithDescendants: function (content, isNew, includeUnpublished, forceRepublish, files, showNotifications) { var endpoint = umbRequestHelper.getApiUrl( "contentApiBaseUrl", "PostSave"); var action = "publishWithDescendants"; - if (force === true) { - action += "Force"; + if (includeUnpublished === true && forceRepublish === true) { + action += "IncludeUnpublishedAndForceRepublish"; + } else if (includeUnpublished === true) { + action += "IncludeUnpublished"; + } else if (forceRepublish === true) { + action += "ForceRepublish"; } return saveContentItem(content, action + (isNew ? "New" : ""), files, endpoint, showNotifications); diff --git a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.controller.js index 1b4c16b28f..40e7e2d4ba 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.controller.js @@ -5,10 +5,12 @@ var vm = this; vm.includeUnpublished = $scope.model.includeUnpublished || false; + vm.forceRepublish = $scope.model.forceRepublish || false; vm.publishAll = false; vm.changeSelection = changeSelection; vm.toggleIncludeUnpublished = toggleIncludeUnpublished; + vm.toggleForceRepublish = toggleForceRepublish; vm.changePublishAllSelection = changePublishAllSelection; function onInit() { @@ -28,9 +30,9 @@ vm.labels.includeUnpublished = value; }); } - if (!vm.labels.includeUnpublished) { - localizationService.localize("content_includeUnpublished").then(value => { - vm.labels.includeUnpublished = value; + if (!vm.labels.forceRepublish) { + localizationService.localize("content_forceRepublish").then(value => { + vm.labels.forceRepublish = value; }); } @@ -69,10 +71,14 @@ function toggleIncludeUnpublished() { vm.includeUnpublished = !vm.includeUnpublished; - // make sure this value is pushed back to the scope $scope.model.includeUnpublished = vm.includeUnpublished; } + function toggleForceRepublish() { + vm.forceRepublish = !vm.forceRepublish; + $scope.model.forceRepublish = vm.forceRepublish; + } + /** Returns true if publishing is possible based on if there are un-published mandatory languages */ function canPublish() { var selected = []; diff --git a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.html b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.html index cbd78fb2d5..a4d337ac71 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.html +++ b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.html @@ -17,6 +17,18 @@
+
+ + +
+
@@ -36,6 +48,18 @@
+
+ + +
+
diff --git a/tests/Umbraco.TestData/UmbracoTestDataController.cs b/tests/Umbraco.TestData/UmbracoTestDataController.cs index 2e44764030..56d196f179 100644 --- a/tests/Umbraco.TestData/UmbracoTestDataController.cs +++ b/tests/Umbraco.TestData/UmbracoTestDataController.cs @@ -86,7 +86,7 @@ public class UmbracoTestDataController : SurfaceController var imageIds = CreateMediaTree(company, faker, count, depth).ToList(); var contentIds = CreateContentTree(company, faker, count, depth, imageIds, out var root).ToList(); - Services.ContentService.SaveAndPublishBranch(root, true); + Services.ContentService.SaveAndPublishBranch(root, PublishBranchFilter.IncludeUnpublished); scope.Complete(); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs index a624d6d885..55e98daa3a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs @@ -1303,7 +1303,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent // publish parent & its branch // only those that are not already published // only invariant/neutral values - var parentPublished = ContentService.SaveAndPublishBranch(parent, true); + var parentPublished = ContentService.SaveAndPublishBranch(parent, PublishBranchFilter.IncludeUnpublished); foreach (var result in parentPublished) { @@ -1522,7 +1522,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent ContentService.Save(content); // Act - var published = ContentService.SaveAndPublishBranch(content, true); + var published = ContentService.SaveAndPublishBranch(content, PublishBranchFilter.IncludeUnpublished); // Assert Assert.That(published.All(x => x.Success), Is.False); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEventsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEventsTests.cs index fda265c705..b0a241a9f5 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEventsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEventsTests.cs @@ -706,7 +706,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services // branch is: ResetEvents(); - ContentService.SaveAndPublishBranch(content1, force: false); // force = false, don't publish unpublished items + ContentService.SaveAndPublishBranch(content1, PublishBranchFilter.Default); // PublishBranchFilter.Default: don't publish unpublished items foreach (EventInstance e in _events) { @@ -743,7 +743,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services ContentService.Unpublish(content1); ResetEvents(); - ContentService.SaveAndPublishBranch(content1, force: true); // force = true, also publish unpublished items + ContentService.SaveAndPublishBranch(content1, PublishBranchFilter.IncludeUnpublished); // PublishBranchFilter.IncludeUnpublished: also publish unpublished items foreach (EventInstance e in _events) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePublishBranchTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePublishBranchTests.cs index 48cc197be6..e9e673d501 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePublishBranchTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePublishBranchTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; @@ -16,8 +14,7 @@ using Umbraco.Cms.Tests.Integration.Testing; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; [TestFixture] -[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, PublishedRepositoryEvents = true, - WithApplication = true)] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, PublishedRepositoryEvents = true, WithApplication = true)] public class ContentServicePublishBranchTests : UmbracoIntegrationTest { private IContentService ContentService => GetRequiredService(); @@ -47,9 +44,9 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest // ii1 !published !edited // ii2 !published !edited - // !force = publishes those that are actually published, and have changes + // PublishBranchFilter.None = publishes those that are actually published, and have changes // here: root (root is always published) - var r = SaveAndPublishInvariantBranch(iRoot, false, method).ToArray(); + var r = SaveAndPublishInvariantBranch(iRoot, PublishBranchFilter.Default, method).ToArray(); // not forcing, ii1 and ii2 not published yet: only root got published AssertPublishResults(r, x => x.Content.Name, "iroot"); @@ -83,9 +80,9 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest // ii21 (published) !edited // ii22 !published !edited - // !force = publishes those that are actually published, and have changes + // PublishBranchFilter.None = publishes those that are actually published, and have changes // here: nothing - r = SaveAndPublishInvariantBranch(iRoot, false, method).ToArray(); + r = SaveAndPublishInvariantBranch(iRoot, PublishBranchFilter.Default, method).ToArray(); // not forcing, ii12 and ii2, ii21, ii22 not published yet: only root, ii1, ii11 got published AssertPublishResults(r, x => x.Content.Name, "iroot", "ii1", "ii11"); @@ -110,11 +107,11 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest // ii21 (published) !edited // ii22 !published !edited - // !force = publishes those that are actually published, and have changes + // PublishBranchFilter.None = publishes those that are actually published, and have changes // here: iroot and ii11 // not forcing, ii12 and ii2, ii21, ii22 not published yet: only root, ii1, ii11 got published - r = SaveAndPublishInvariantBranch(iRoot, false, method).ToArray(); + r = SaveAndPublishInvariantBranch(iRoot, PublishBranchFilter.Default, method).ToArray(); AssertPublishResults(r, x => x.Content.Name, "iroot", "ii1", "ii11"); AssertPublishResults( r, @@ -123,9 +120,9 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest PublishResultType.SuccessPublishAlready, PublishResultType.SuccessPublish); - // force = publishes everything that has changes + // PublishBranchFilter.IncludeUnpublished = publishes everything that has changes // here: ii12, ii2, ii22 - ii21 was published already but masked - r = SaveAndPublishInvariantBranch(iRoot, true, method).ToArray(); + r = SaveAndPublishInvariantBranch(iRoot, PublishBranchFilter.IncludeUnpublished, method).ToArray(); AssertPublishResults( r, x => x.Content.Name, @@ -182,7 +179,7 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest iv1.SetValue("vp", "UPDATED-iv1.de", "de"); ContentService.Save(iv1); - var r = ContentService.SaveAndPublishBranch(vRoot, false) + var r = ContentService.SaveAndPublishBranch(vRoot, PublishBranchFilter.Default) .ToArray(); // no culture specified so "*" is used, so all cultures Assert.AreEqual(PublishResultType.SuccessPublishAlready, r[0].Result); Assert.AreEqual(PublishResultType.SuccessPublishCulture, r[1].Result); @@ -219,7 +216,7 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest iv1.SetValue("vp", "UPDATED-iv1.de", "de"); var saveResult = ContentService.Save(iv1); - var r = ContentService.SaveAndPublishBranch(vRoot, false, "de").ToArray(); + var r = ContentService.SaveAndPublishBranch(vRoot, PublishBranchFilter.Default, "de").ToArray(); Assert.AreEqual(PublishResultType.SuccessPublishAlready, r[0].Result); Assert.AreEqual(PublishResultType.SuccessPublishCulture, r[1].Result); } @@ -263,9 +260,9 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest // iv1 !published !edited // iv2 !published !edited - // !force = publishes those that are actually published, and have changes + // PublishBranchFilter.None = publishes those that are actually published, and have changes // here: nothing - var r = ContentService.SaveAndPublishBranch(vRoot, false).ToArray(); // no culture specified = all cultures + var r = ContentService.SaveAndPublishBranch(vRoot, PublishBranchFilter.Default).ToArray(); // no culture specified = all cultures // not forcing, iv1 and iv2 not published yet: only root got published AssertPublishResults(r, x => x.Content.Name, "vroot.de"); @@ -298,7 +295,7 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest Assert.IsTrue(iv1.IsCulturePublished("ru")); Assert.IsFalse(iv1.IsCulturePublished("es")); - r = ContentService.SaveAndPublishBranch(vRoot, false, "de").ToArray(); + r = ContentService.SaveAndPublishBranch(vRoot, PublishBranchFilter.Default, "de").ToArray(); // not forcing, iv2 not published yet: only root and iv1 got published AssertPublishResults(r, x => x.Content.Name, "vroot.de", "iv1.de"); @@ -375,7 +372,7 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest { Can_Publish_Mixed_Branch(out var iRoot, out var ii1, out var iv11); - var r = ContentService.SaveAndPublishBranch(iRoot, false, "de").ToArray(); + var r = ContentService.SaveAndPublishBranch(iRoot, PublishBranchFilter.Default, "de").ToArray(); AssertPublishResults(r, x => x.Content.Name, "iroot", "ii1", "iv11.de"); AssertPublishResults( r, @@ -401,7 +398,7 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest { Can_Publish_Mixed_Branch(out var iRoot, out var ii1, out var iv11); - var r = ContentService.SaveAndPublishBranch(iRoot, false, new[] { "de", "ru" }).ToArray(); + var r = ContentService.SaveAndPublishBranch(iRoot, PublishBranchFilter.Default, ["de", "ru"]).ToArray(); AssertPublishResults(r, x => x.Content.Name, "iroot", "ii1", "iv11.de"); AssertPublishResults( r, @@ -422,6 +419,156 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest Assert.AreEqual("changed.ru", iv11.GetValue("vp", "ru", published: true)); } + [TestCase(PublishBranchFilter.Default)] + [TestCase(PublishBranchFilter.IncludeUnpublished)] + [TestCase(PublishBranchFilter.ForceRepublish)] + [TestCase(PublishBranchFilter.All)] + public void Can_Publish_Invariant_Branch_With_Force_Options(PublishBranchFilter publishBranchFilter) + { + CreateTypes(out var iContentType, out _); + + // Create content (published root, published child, unpublished child, changed child). + IContent iRoot = new Content("iroot", -1, iContentType); + iRoot.SetValue("ip", "iroot"); + ContentService.SaveAndPublish(iRoot); + + IContent ii1 = new Content("ii1", iRoot, iContentType); + ii1.SetValue("ip", "vii1"); + ContentService.SaveAndPublish(ii1); + + IContent ii2 = new Content("ii2", iRoot, iContentType); + ii2.SetValue("ip", "vii2"); + ContentService.Save(ii2); + + IContent ii3 = new Content("ii3", iRoot, iContentType); + ii3.SetValue("ip", "vii3"); + ContentService.SaveAndPublish(ii3); + ii3.SetValue("ip", "vii3a"); + ContentService.Save(ii3); + + var result = ContentService.SaveAndPublishBranch(iRoot, publishBranchFilter).ToArray(); + + var expectedContentNames = GetExpectedContentNamesForForceOptions(publishBranchFilter); + var expectedPublishResultTypes = GetExpectedPublishResultTypesForForceOptions(publishBranchFilter); + AssertPublishResults(result, x => x.Content.Name, expectedContentNames); + AssertPublishResults( + result, + x => x.Result, + expectedPublishResultTypes); + } + + [TestCase("*", PublishBranchFilter.Default)] + [TestCase("*", PublishBranchFilter.IncludeUnpublished)] + [TestCase("*", PublishBranchFilter.ForceRepublish)] + [TestCase("*", PublishBranchFilter.All)] + [TestCase("de", PublishBranchFilter.Default)] + [TestCase("de", PublishBranchFilter.IncludeUnpublished)] + [TestCase("de", PublishBranchFilter.ForceRepublish)] + [TestCase("de", PublishBranchFilter.All)] + public void Can_Publish_Variant_Branch_With_Force_Options(string culture, PublishBranchFilter publishBranchFilter) + { + CreateTypes(out _, out var vContentType); + + // Create content (published root, published child, unpublished child, changed child). + IContent vRoot = new Content("vroot", -1, vContentType); + vRoot.SetCultureName("vroot.de", "de"); + vRoot.SetCultureName("vroot.ru", "ru"); + vRoot.SetValue("ip", "vroot"); + vRoot.SetValue("vp", "vroot.de", "de"); + vRoot.SetValue("vp", "vroot.ru", "ru"); + ContentService.SaveAndPublish(vRoot); + + IContent iv1 = new Content("iv1", vRoot, vContentType, "de"); + iv1.SetCultureName("iv1.de", "de"); + iv1.SetCultureName("iv1.ru", "ru"); + iv1.SetValue("ip", "iv1"); + iv1.SetValue("vp", "iv1.de", "de"); + iv1.SetValue("vp", "iv1.ru", "ru"); + ContentService.SaveAndPublish(iv1); + + IContent iv2 = new Content("iv2", vRoot, vContentType, "de"); + iv2.SetCultureName("iv2.de", "de"); + iv2.SetCultureName("iv2.ru", "ru"); + iv2.SetValue("ip", "iv2"); + iv2.SetValue("vp", "iv2.de", "de"); + iv2.SetValue("vp", "iv2.ru", "ru"); + ContentService.Save(iv2); + + // When testing with a specific culture, publish the other one, so we can test that + // the specified unpublished culture is handled correctly. + if (culture != "*") + { + ContentService.SaveAndPublish(iv2, "ru"); + } + + IContent iv3 = new Content("iv3", vRoot, vContentType, "de"); + iv3.SetCultureName("iv3.de", "de"); + iv3.SetCultureName("iv3.ru", "ru"); + iv3.SetValue("ip", "iv3"); + iv3.SetValue("vp", "iv3.de", "de"); + iv3.SetValue("vp", "iv3.ru", "ru"); + ContentService.SaveAndPublish(iv3); + iv3.SetValue("ip", "iv3a"); + iv3.SetValue("vp", "iv3a.de", "de"); + iv3.SetValue("vp", "iv3a.ru", "ru"); + ContentService.Save(iv3); + + var result = ContentService.SaveAndPublishBranch(vRoot, publishBranchFilter, culture).ToArray(); + + var expectedContentNames = GetExpectedContentNamesForForceOptions(publishBranchFilter, true); + var expectedPublishResultTypes = GetExpectedPublishResultTypesForForceOptions(publishBranchFilter, true); + AssertPublishResults(result, x => x.Content.Name, expectedContentNames); + AssertPublishResults( + result, + x => x.Result, + expectedPublishResultTypes); + } + + private static string[] GetExpectedContentNamesForForceOptions(PublishBranchFilter publishBranchFilter, bool isVariant = false) + { + var rootName = isVariant ? "vroot.de" : "iroot"; + var childPrefix = isVariant ? "iv" : "ii"; + var childSuffix = isVariant ? ".de" : string.Empty; + if (publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished)) + { + return [rootName, $"{childPrefix}1{childSuffix}", $"{childPrefix}2{childSuffix}", $"{childPrefix}3{childSuffix}"]; + } + + return [rootName, $"{childPrefix}1{childSuffix}", $"{childPrefix}3{childSuffix}"]; + } + + private static PublishResultType[] GetExpectedPublishResultTypesForForceOptions(PublishBranchFilter publishBranchFilter, bool isVariant = false) + { + var successPublish = isVariant ? PublishResultType.SuccessPublishCulture : PublishResultType.SuccessPublish; + if (publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) && publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish)) + { + return [successPublish, + successPublish, + successPublish, + successPublish]; + } + + if (publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished)) + { + return [PublishResultType.SuccessPublishAlready, + PublishResultType.SuccessPublishAlready, + successPublish, + successPublish]; + } + + if (publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish)) + { + return [successPublish, + successPublish, + + successPublish]; + } + + return [PublishResultType.SuccessPublishAlready, + PublishResultType.SuccessPublishAlready, + successPublish]; + } + private void AssertPublishResults(PublishResult[] values, Func getter, params T[] expected) { if (expected.Length != values.Length) @@ -479,16 +626,16 @@ public class ContentServicePublishBranchTests : UmbracoIntegrationTest ContentTypeService.Save(vContentType); } - private IEnumerable SaveAndPublishInvariantBranch(IContent content, bool force, int method) + private IEnumerable SaveAndPublishInvariantBranch(IContent content, PublishBranchFilter publishBranchFilter, int method) { // ReSharper disable RedundantArgumentDefaultValue // ReSharper disable ArgumentsStyleOther switch (method) { case 1: - return ContentService.SaveAndPublishBranch(content, force, "*"); + return ContentService.SaveAndPublishBranch(content, publishBranchFilter, "*"); case 2: - return ContentService.SaveAndPublishBranch(content, force, cultures: new[] { "*" }); + return ContentService.SaveAndPublishBranch(content, publishBranchFilter, cultures: new[] { "*" }); default: throw new ArgumentOutOfRangeException(nameof(method)); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTagsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTagsTests.cs index 15005f2e08..cdc3ebf4bf 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTagsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTagsTests.cs @@ -669,7 +669,7 @@ public class ContentServiceTagsTests : UmbracoIntegrationTest ContentService.Save(child2); // Act - ContentService.SaveAndPublishBranch(content, true); + ContentService.SaveAndPublishBranch(content, PublishBranchFilter.IncludeUnpublished); // Assert var propertyTypeId = contentType.PropertyTypes.Single(x => x.Alias == "tags").Id; From 8c2b1ebdc513526a026e0c8a1eec9f920e78aa88 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:30:41 +0100 Subject: [PATCH 46/49] V13: Introduce publishNotifications method on IMembershipMemberService (#18207) * Introduce publishNotifications method on IMembershipMemberService.cs * Fix test * Add PublishNotificationSaveOptions * Fix up according to comments * Use numeric values for flag enum * Update src/Umbraco.Core/Services/MemberService.cs Co-authored-by: Andy Butland * Update src/Umbraco.Core/Services/MemberService.cs Co-authored-by: Andy Butland --------- Co-authored-by: Andy Butland --- .../Models/PublishNotificationSaveOptions.cs | 28 +++++++++++++++++++ .../Services/IMembershipMemberService.cs | 8 ++++++ src/Umbraco.Core/Services/MemberService.cs | 24 ++++++++++++---- .../Security/MemberUserStore.cs | 2 +- .../Security/MemberUserStoreTests.cs | 4 +-- 5 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 src/Umbraco.Core/Models/PublishNotificationSaveOptions.cs diff --git a/src/Umbraco.Core/Models/PublishNotificationSaveOptions.cs b/src/Umbraco.Core/Models/PublishNotificationSaveOptions.cs new file mode 100644 index 0000000000..f49e7f30d6 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishNotificationSaveOptions.cs @@ -0,0 +1,28 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Specifies options for publishing notifcations when saving. +/// +[Flags] +public enum PublishNotificationSaveOptions +{ + /// + /// Do not publish any notifications. + /// + None = 0, + + /// + /// Only publish the saving notification. + /// + Saving = 1, + + /// + /// Only publish the saved notification. + /// + Saved = 2, + + /// + /// Publish all the notifications. + /// + All = Saving | Saved, +} diff --git a/src/Umbraco.Core/Services/IMembershipMemberService.cs b/src/Umbraco.Core/Services/IMembershipMemberService.cs index 99e64a3686..ee77aedcf7 100644 --- a/src/Umbraco.Core/Services/IMembershipMemberService.cs +++ b/src/Umbraco.Core/Services/IMembershipMemberService.cs @@ -135,6 +135,14 @@ public interface IMembershipMemberService : IService /// or to Save void Save(T entity); + /// + /// Saves an + /// + /// An can be of type or + /// or to Save + /// Enum for deciding which notifications to publish. + void Save(T entity, PublishNotificationSaveOptions publishNotificationSaveOptions) => Save(entity); + /// /// Saves a list of objects /// diff --git a/src/Umbraco.Core/Services/MemberService.cs b/src/Umbraco.Core/Services/MemberService.cs index 493ab313a7..b405519616 100644 --- a/src/Umbraco.Core/Services/MemberService.cs +++ b/src/Umbraco.Core/Services/MemberService.cs @@ -743,7 +743,9 @@ namespace Umbraco.Cms.Core.Services public void SetLastLogin(string username, DateTime date) => throw new NotImplementedException(); /// - public void Save(IMember member) + public void Save(IMember member) => Save(member, PublishNotificationSaveOptions.All); + + public void Save(IMember member, PublishNotificationSaveOptions publishNotificationSaveOptions) { // trimming username and email to make sure we have no trailing space member.Username = member.Username.Trim(); @@ -752,11 +754,15 @@ namespace Umbraco.Cms.Core.Services EventMessages evtMsgs = EventMessagesFactory.Get(); using ICoreScope scope = ScopeProvider.CreateCoreScope(); - var savingNotification = new MemberSavingNotification(member, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) + MemberSavingNotification? savingNotification = null; + if (publishNotificationSaveOptions.HasFlag(PublishNotificationSaveOptions.Saving)) { - scope.Complete(); - return; + savingNotification = new MemberSavingNotification(member, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return; + } } if (string.IsNullOrWhiteSpace(member.Name)) @@ -768,7 +774,13 @@ namespace Umbraco.Cms.Core.Services _memberRepository.Save(member); - scope.Notifications.Publish(new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification)); + if (publishNotificationSaveOptions.HasFlag(PublishNotificationSaveOptions.Saved)) + { + scope.Notifications.Publish( + savingNotification is null + ? new MemberSavedNotification(member, evtMsgs) + : new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification)); + } Audit(AuditType.Save, 0, member.Id); diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index 4e4b43f509..a9c7dc5f2a 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -110,7 +110,7 @@ public class MemberUserStore : UmbracoUserStore x.CreateMember(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(mockMember); - _mockMemberService.Setup(x => x.Save(mockMember)); + _mockMemberService.Setup(x => x.Save(mockMember, PublishNotificationSaveOptions.Saving)); // act var identityResult = await sut.CreateAsync(fakeUser, CancellationToken.None); @@ -132,7 +132,7 @@ public class MemberUserStoreTests Assert.IsTrue(!identityResult.Errors.Any()); _mockMemberService.Verify(x => x.CreateMember(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); - _mockMemberService.Verify(x => x.Save(mockMember)); + _mockMemberService.Verify(x => x.Save(mockMember, PublishNotificationSaveOptions.Saving)); } [Test] From def7ebd48c1999de26def8707fab1aad859ef30c Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 17 Feb 2025 12:25:12 +0100 Subject: [PATCH 47/49] Html encodes the user's name in the invite email. (#18343) --- src/Umbraco.Web.BackOffice/Controllers/UsersController.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index 960afa365e..f5486ff859 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -664,10 +664,11 @@ public class UsersController : BackOfficeNotificationsController var emailSubject = _localizedTextService.Localize("user", "inviteEmailCopySubject", // Ensure the culture of the found user is used for the email! UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings)); + var name = userDisplay is null ? string.Empty : System.Web.HttpUtility.HtmlEncode(userDisplay.Name); var emailBody = _localizedTextService.Localize("user", "inviteEmailCopyFormat", // Ensure the culture of the found user is used for the email! UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings), - new[] { userDisplay?.Name, from, WebUtility.HtmlEncode(message)!.ReplaceLineEndings("
"), inviteUri.ToString(), senderEmail }); + new[] { name, from, WebUtility.HtmlEncode(message)!.ReplaceLineEndings("
"), inviteUri.ToString(), senderEmail }); // This needs to be in the correct mailto format including the name, else // the name cannot be captured in the email sending notification. From 18047a7cfbae21ad618e9c9933601e54dc3768e7 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 17 Feb 2025 12:45:51 +0100 Subject: [PATCH 48/49] Only filter post retrieval of entities for start nodes if working with entities that support start nodes. (#18287) --- src/Umbraco.Web.BackOffice/Controllers/EntityController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs index c54344a239..f4ef16e041 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs @@ -895,6 +895,7 @@ public class EntityController : UmbracoAuthorizedJsonController // Filtering out child nodes after getting a paged result is an active choice here, even though the pagination might get off. // This has been the case with this functionality in Umbraco for a long time. .Where(entity => ignoreUserStartNodes || + (objectType == UmbracoObjectTypes.Document || objectType == UmbracoObjectTypes.Media) is false || (ContentPermissions.IsInBranchOfStartNode(entity.Path, startNodeIds, startNodePaths, out var hasPathAccess) && hasPathAccess)) .Select(source => From db1d9997212387d4e278f56b778e0b6ad85ade92 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 17 Feb 2025 12:47:52 +0100 Subject: [PATCH 49/49] Avoid exception when attempting to find member by Id when Id is not an expected Guid or integer, as can be the case with external member providers. (#18320) --- .../Security/MemberUserStore.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index a9c7dc5f2a..82f456bef0 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -321,9 +321,20 @@ public class MemberUserStore : UmbracoUserStore