From bbc8ade227d99e1d6dce86e59aeb6ff631b71e15 Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Thu, 9 Feb 2023 08:25:01 +0100 Subject: [PATCH 01/32] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 925296d897..06b868d719 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "11.2.0-rc", + "version": "11.3.0-rc", "assemblyVersion": { "precision": "build" }, From 7d9e07ff7cf20f08c66d774f8da51781dc0f5c3c Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 10 Feb 2023 10:45:19 +0100 Subject: [PATCH 02/32] Don't show DB details on install screen when using a pre-configured connection string (#13809) --- src/Umbraco.Web.UI.Client/src/installer/steps/user.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/installer/steps/user.html b/src/Umbraco.Web.UI.Client/src/installer/steps/user.html index 20c406aefe..ea487e046a 100644 --- a/src/Umbraco.Web.UI.Client/src/installer/steps/user.html +++ b/src/Umbraco.Web.UI.Client/src/installer/steps/user.html @@ -101,12 +101,15 @@
-
+
Provider: {{installer.current.model.quickInstallSettings.displayName}}
Name: {{installer.current.model.quickInstallSettings.defaultDatabaseName}}
+
+ A database has been pre-configured for your installation. +
From 45f8ffcd0b9e9b03f127c173762ef202c87a4c93 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 10 Feb 2023 10:49:39 +0100 Subject: [PATCH 03/32] Support notifications for multiple root nodes (#13805) --- .../Services/NotificationService.cs | 56 +++++++------------ 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/src/Umbraco.Core/Services/NotificationService.cs b/src/Umbraco.Core/Services/NotificationService.cs index 822ba89079..ff857986b0 100644 --- a/src/Umbraco.Core/Services/NotificationService.cs +++ b/src/Umbraco.Core/Services/NotificationService.cs @@ -76,7 +76,8 @@ public class NotificationService : INotificationService Func<(IUser user, NotificationEmailSubjectParams subject), string> createSubject, Func<(IUser user, NotificationEmailBodyParams body, bool isHtml), string> createBody) { - var entitiesL = entities.ToList(); + // sort the entities explicitly by path to handle notification inheritance (see comment below) + var entitiesL = entities.OrderBy(entity => entity.Path).ToList(); // exit if there are no entities if (entitiesL.Count == 0) @@ -84,11 +85,11 @@ public class NotificationService : INotificationService return; } - // put all entity's paths into a list with the same indices - var paths = entitiesL.Select(x => - x.Path.Split(Constants.CharArrays.Comma).Select(s => int.Parse(s, CultureInfo.InvariantCulture)) - .ToArray()) - .ToArray(); + // create a dictionary of entity paths by entity ID + var pathsByEntityId = entitiesL.ToDictionary( + entity => entity.Id, + entity => entity.Path.Split(Constants.CharArrays.Comma) + .Select(s => int.Parse(s, CultureInfo.InvariantCulture)).ToArray()); // lazily get versions var prevVersionDictionary = new Dictionary(); @@ -106,47 +107,32 @@ public class NotificationService : INotificationService break; } - var i = 0; foreach (IUser user in users) { - // continue if there's no notification for this user - if (notifications[i].UserId != user.Id) + Notification[] userNotifications = notifications.Where(n => n.UserId == user.Id).ToArray(); + foreach (Notification notification in userNotifications) { - continue; // next user - } + // notifications are inherited down the tree - find the topmost entity + // relevant to this notification (entity list is sorted by path) + IContent? entityForNotification = entitiesL + .FirstOrDefault(entity => + pathsByEntityId.TryGetValue(entity.Id, out var path) && + path.Contains(notification.EntityId)); - for (var j = 0; j < entitiesL.Count; j++) - { - IContent content = entitiesL[j]; - var path = paths[j]; - - // test if the notification applies to the path ie to this entity - if (path.Contains(notifications[i].EntityId) == false) + if (entityForNotification == null) { - continue; // next entity + continue; } - if (prevVersionDictionary.ContainsKey(content.Id) == false) + if (prevVersionDictionary.ContainsKey(entityForNotification.Id) == false) { - prevVersionDictionary[content.Id] = GetPreviousVersion(content.Id); + prevVersionDictionary[entityForNotification.Id] = GetPreviousVersion(entityForNotification.Id); } // queue notification - NotificationRequest req = CreateNotificationRequest(operatingUser, user, content, prevVersionDictionary[content.Id], actionName, siteUri, createSubject, createBody); + NotificationRequest req = CreateNotificationRequest(operatingUser, user, entityForNotification, prevVersionDictionary[entityForNotification.Id], actionName, siteUri, createSubject, createBody); Enqueue(req); - } - - // skip other notifications for this user, essentially this means moving i to the next index of notifications - // for the next user. - do - { - i++; - } - while (i < notifications.Count && notifications[i].UserId == user.Id); - - if (i >= notifications.Count) - { - break; // break if no more notifications + break; } } From e529818560a1a3dc3c91cbbe708f74dd202a53e8 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 10 Feb 2023 10:56:55 +0100 Subject: [PATCH 04/32] Make the default lockout time configurable for users and members (#13808) * Make the default lock timeout configurable for users and members * Update obsoletion to V13 --- .../Configuration/Models/SecuritySettings.cs | 15 +++++++++++++++ .../ConfigureBackOfficeIdentityOptions.cs | 19 ++++++++++++++++--- .../ConfigureMemberIdentityOptions.cs | 3 +-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index 708f9b98c2..eca2501a63 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -22,6 +22,9 @@ public class SecuritySettings internal const string StaticAllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+\\"; + internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60; + internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60; + /// /// Gets or sets a value indicating whether to keep the user logged in. /// @@ -86,6 +89,18 @@ public class SecuritySettings [DefaultValue(StaticUserBypassTwoFactorForExternalLogins)] public bool UserBypassTwoFactorForExternalLogins { get; set; } = StaticUserBypassTwoFactorForExternalLogins; + /// + /// Gets or sets a value for how long (in minutes) a member is locked out when a lockout occurs. + /// + [DefaultValue(StaticMemberDefaultLockoutTimeInMinutes)] + public int MemberDefaultLockoutTimeInMinutes { get; set; } = StaticMemberDefaultLockoutTimeInMinutes; + + /// + /// Gets or sets a value for how long (in minutes) a user is locked out when a lockout occurs. + /// + [DefaultValue(StaticUserDefaultLockoutTimeInMinutes)] + public int UserDefaultLockoutTimeInMinutes { get; set; } = StaticUserDefaultLockoutTimeInMinutes; + /// /// Gets or sets a value indicating whether to allow editing invariant properties from a non-default language variation. /// diff --git a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeIdentityOptions.cs b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeIdentityOptions.cs index a480991648..e3f897018a 100644 --- a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeIdentityOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeIdentityOptions.cs @@ -1,8 +1,10 @@ using System.Security.Claims; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Web.BackOffice.Security; @@ -13,9 +15,21 @@ namespace Umbraco.Cms.Web.BackOffice.Security; public sealed class ConfigureBackOfficeIdentityOptions : IConfigureOptions { private readonly UserPasswordConfigurationSettings _userPasswordConfiguration; + private readonly SecuritySettings _securitySettings; - public ConfigureBackOfficeIdentityOptions(IOptions userPasswordConfiguration) => + [Obsolete("Use the constructor that accepts SecuritySettings. Will be removed in V13.")] + public ConfigureBackOfficeIdentityOptions(IOptions userPasswordConfiguration) + : this(userPasswordConfiguration, StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public ConfigureBackOfficeIdentityOptions( + IOptions userPasswordConfiguration, + IOptions securitySettings) + { _userPasswordConfiguration = userPasswordConfiguration.Value; + _securitySettings = securitySettings.Value; + } public void Configure(BackOfficeIdentityOptions options) { @@ -31,8 +45,7 @@ public sealed class ConfigureBackOfficeIdentityOptions : IConfigureOptions Date: Mon, 13 Feb 2023 14:47:26 +0100 Subject: [PATCH 05/32] Added missing property index value factories (#13819) * Added missing PropertyIndexValueFactories, for Block Grid, Block List, Nested Content, Tags and added No-op for image picker, image cropper etc, where the content dont make any sense anyway. Made it replace for block grid/list, nested content and tags * Fixed tests * Ensure raw-fields are the prefix * Code clean up * build fix * Minor cleanup * Fixed issue with published values / external index --------- Co-authored-by: nikolajlauridsen --- .../IBlockValuePropertyIndexValueFactory.cs | 5 + ...INestedContentPropertyIndexValueFactory.cs | 5 + .../ITagPropertyIndexValueFactory.cs | 5 + .../JsonPropertyIndexValueFactoryBase.cs | 84 ++++++++ .../NoopPropertyIndexValueFactory.cs | 12 ++ .../TagPropertyIndexValueFactory.cs | 21 ++ .../UmbracoBuilder.CoreServices.cs | 13 ++ .../BlockEditorPropertyEditor.cs | 20 +- .../BlockGridPropertyEditor.cs | 18 +- .../BlockGridPropertyEditorBase.cs | 19 +- .../BlockListPropertyEditor.cs | 21 +- .../BlockListPropertyEditorBase.cs | 20 +- .../BlockValuePropertyIndexValueFactory.cs | 35 ++++ .../ColorPickerPropertyEditor.cs | 3 + .../ImageCropperPropertyEditor.cs | 2 + .../MediaPicker3PropertyEditor.cs | 4 + .../NestedContentPropertyEditor.cs | 22 ++- .../NestedContentPropertyIndexValueFactory.cs | 37 ++++ .../NestedPropertyIndexValueFactoryBase.cs | 181 ++++++++++++++++++ .../PropertyEditors/TagsPropertyEditor.cs | 31 ++- .../DataValueEditorReuseTests.cs | 6 +- .../Published/NestedContentTests.cs | 6 +- 22 files changed, 556 insertions(+), 14 deletions(-) create mode 100644 src/Umbraco.Core/PropertyEditors/IBlockValuePropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Core/PropertyEditors/INestedContentPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Core/PropertyEditors/ITagPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs create mode 100644 src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Core/PropertyEditors/TagPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyIndexValueFactory.cs create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs diff --git a/src/Umbraco.Core/PropertyEditors/IBlockValuePropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/IBlockValuePropertyIndexValueFactory.cs new file mode 100644 index 0000000000..8556b993f0 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IBlockValuePropertyIndexValueFactory.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +public interface IBlockValuePropertyIndexValueFactory : IPropertyIndexValueFactory +{ +} diff --git a/src/Umbraco.Core/PropertyEditors/INestedContentPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/INestedContentPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..eb87a390d2 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/INestedContentPropertyIndexValueFactory.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +public interface INestedContentPropertyIndexValueFactory : IPropertyIndexValueFactory +{ +} diff --git a/src/Umbraco.Core/PropertyEditors/ITagPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/ITagPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..33eabb314c --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ITagPropertyIndexValueFactory.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +public interface ITagPropertyIndexValueFactory : IPropertyIndexValueFactory +{ +} diff --git a/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs b/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs new file mode 100644 index 0000000000..e639ff7ca8 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs @@ -0,0 +1,84 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Abstract base for property index value factories where the value is json. +/// +/// The type to deserialize the json to. +public abstract class JsonPropertyIndexValueFactoryBase : IPropertyIndexValueFactory +{ + private readonly IJsonSerializer _jsonSerializer; + + /// + /// Constructor for the JsonPropertyIndexValueFactoryBase. + /// + protected JsonPropertyIndexValueFactoryBase(IJsonSerializer jsonSerializer) + { + _jsonSerializer = jsonSerializer; + } + + /// + public IEnumerable>> GetIndexValues( + IProperty property, + string? culture, + string? segment, + bool published) + { + var result = new List>>(); + + var propertyValue = property.GetValue(culture, segment, published); + + // If there is a value, it's a string and it's detected as json. + if (propertyValue is string rawValue && rawValue.DetectIsJson()) + { + try + { + TSerialized? deserializedPropertyValue = _jsonSerializer.Deserialize(rawValue); + + if (deserializedPropertyValue is null) + { + return result; + } + + result.AddRange(Handle(deserializedPropertyValue, property, culture, segment, published)); + } + catch (InvalidCastException) + { + // Swallow...on purpose, there's a chance that this isn't the json format we are looking for + // and we don't want that to affect the website. + } + catch (ArgumentException) + { + // Swallow on purpose to prevent this error: + // Can not add Newtonsoft.Json.Linq.JValue to Newtonsoft.Json.Linq.JObject. + } + } + + result.AddRange(HandleResume(result, property, culture, segment, published)); + + return result; + } + + /// + /// Method to return a list of resume of the content. By default this returns an empty list + /// + protected virtual IEnumerable>> HandleResume( + List>> result, + IProperty property, + string? culture, + string? segment, + bool published) => Array.Empty>>(); + + /// + /// Method that handle the deserialized object. + /// + protected abstract IEnumerable>> Handle( + TSerialized deserializedPropertyValue, + IProperty property, + string? culture, + string? segment, + bool published); +} diff --git a/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..7e64b368c4 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Property Index Valye Factory that do not index anything. +/// +public class NoopPropertyIndexValueFactory : IPropertyIndexValueFactory +{ + /// + public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published) => Array.Empty>>(); +} diff --git a/src/Umbraco.Core/PropertyEditors/TagPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/TagPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..83a327e0ef --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/TagPropertyIndexValueFactory.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.PropertyEditors; + +public class TagPropertyIndexValueFactory : JsonPropertyIndexValueFactoryBase, ITagPropertyIndexValueFactory +{ + public TagPropertyIndexValueFactory(IJsonSerializer jsonSerializer) : base(jsonSerializer) + { + } + + protected override IEnumerable>> Handle( + string[] deserializedPropertyValue, + IProperty property, + string? culture, + string? segment, + bool published) + { + yield return new KeyValuePair>(property.Alias, deserializedPropertyValue); + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index e529902487..c65e50024c 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -222,6 +222,19 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddTransient(); + + + builder.AddPropertyIndexValueFactories(); + + return builder; + } + + public static IUmbracoBuilder AddPropertyIndexValueFactories(this IUmbracoBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + return builder; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs index b939a8c06e..acfe6659c5 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs @@ -1,6 +1,9 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Web.Common.DependencyInjection; + namespace Umbraco.Cms.Core.PropertyEditors; // Scheduled for removal in v12 @@ -11,11 +14,26 @@ public abstract class BlockEditorPropertyEditor : BlockListPropertyEditorBase public const string ContentTypeKeyPropertyKey = "contentTypeKey"; public const string UdiPropertyKey = "udi"; + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] protected BlockEditorPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, PropertyEditorCollection propertyEditors) - : base(dataValueEditorFactory) => + : this( + dataValueEditorFactory, + propertyEditors, + StaticServiceProvider.Instance.GetRequiredService()) + { + + } + + protected BlockEditorPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + PropertyEditorCollection propertyEditors, + IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory) + : base(dataValueEditorFactory, blockValuePropertyIndexValueFactory) + { PropertyEditors = propertyEditors; + } private PropertyEditorCollection PropertyEditors { get; } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditor.cs index 8881ce82a9..361b8c86f0 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditor.cs @@ -1,7 +1,9 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Web.Common.DependencyInjection; namespace Umbraco.Cms.Core.PropertyEditors; @@ -19,11 +21,25 @@ public class BlockGridPropertyEditor : BlockGridPropertyEditorBase { private readonly IIOHelper _ioHelper; + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] public BlockGridPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper) - : base(dataValueEditorFactory) => + : this(dataValueEditorFactory, ioHelper, StaticServiceProvider.Instance.GetRequiredService()) + { + + } + + + public BlockGridPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory) + : base(dataValueEditorFactory, blockValuePropertyIndexValueFactory) + { _ioHelper = ioHelper; + } + #region Pre Value Editor diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs index 42a5931a2b..73b767bd4f 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -9,6 +10,7 @@ using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; using BlockGridAreaConfiguration = Umbraco.Cms.Core.PropertyEditors.BlockGridConfiguration.BlockGridAreaConfiguration; @@ -19,9 +21,24 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// public abstract class BlockGridPropertyEditorBase : DataEditor { + private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory; + + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory) - : base(dataValueEditorFactory) => + : this(dataValueEditorFactory, StaticServiceProvider.Instance.GetRequiredService()) + { + + } + + protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory) + : base(dataValueEditorFactory) + { + _blockValuePropertyIndexValueFactory = blockValuePropertyIndexValueFactory; SupportsReadOnly = true; + } + + public override IPropertyIndexValueFactory PropertyIndexValueFactory => _blockValuePropertyIndexValueFactory; + #region Value Editor diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditor.cs index f36d7b67ff..2f5147cf1a 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditor.cs @@ -25,7 +25,7 @@ public class BlockListPropertyEditor : BlockEditorPropertyEditor private readonly IIOHelper _ioHelper; // Scheduled for removal in v12 - [Obsolete("Please use constructor that takes an IEditorConfigurationParser instead")] + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] public BlockListPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, PropertyEditorCollection propertyEditors, @@ -34,12 +34,29 @@ public class BlockListPropertyEditor : BlockEditorPropertyEditor { } + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] public BlockListPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, PropertyEditorCollection propertyEditors, IIOHelper ioHelper, IEditorConfigurationParser editorConfigurationParser) - : base(dataValueEditorFactory, propertyEditors) + : this( + dataValueEditorFactory, + propertyEditors, + ioHelper, + editorConfigurationParser, + StaticServiceProvider.Instance.GetRequiredService()) + { + + } + + public BlockListPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + PropertyEditorCollection propertyEditors, + IIOHelper ioHelper, + IEditorConfigurationParser editorConfigurationParser, + IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory) + : base(dataValueEditorFactory, propertyEditors, blockValuePropertyIndexValueFactory) { _ioHelper = ioHelper; _editorConfigurationParser = editorConfigurationParser; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs index eb09f405a0..194383560e 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -6,6 +7,7 @@ using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Web.Common.DependencyInjection; namespace Umbraco.Cms.Core.PropertyEditors; @@ -14,9 +16,25 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// public abstract class BlockListPropertyEditorBase : DataEditor { + + private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory; + + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory) - : base(dataValueEditorFactory) => + : this(dataValueEditorFactory, StaticServiceProvider.Instance.GetRequiredService()) + { + + } + + protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory) + : base(dataValueEditorFactory) + { + _blockValuePropertyIndexValueFactory = blockValuePropertyIndexValueFactory; SupportsReadOnly = true; + } + + public override IPropertyIndexValueFactory PropertyIndexValueFactory => _blockValuePropertyIndexValueFactory; + #region Value Editor diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs new file mode 100644 index 0000000000..dfedeedc3f --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs @@ -0,0 +1,35 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal sealed class BlockValuePropertyIndexValueFactory : + NestedPropertyIndexValueFactoryBase, + IBlockValuePropertyIndexValueFactory +{ + private readonly IContentTypeService _contentTypeService; + + + public BlockValuePropertyIndexValueFactory( + PropertyEditorCollection propertyEditorCollection, + IContentTypeService contentTypeService, + IJsonSerializer jsonSerializer) + : base(propertyEditorCollection, jsonSerializer) + { + _contentTypeService = contentTypeService; + } + + + protected override IContentType? GetContentTypeOfNestedItem(BlockItemData input) => + _contentTypeService.Get(input.ContentTypeKey); + + protected override IDictionary GetRawProperty(BlockItemData blockItemData) => + blockItemData.RawPropertyValues; + + protected override IEnumerable GetDataItems(BlockValue input) => input.ContentData; +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerPropertyEditor.cs index 1ce8ae4930..00d432070f 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerPropertyEditor.cs @@ -45,6 +45,9 @@ public class ColorPickerPropertyEditor : DataEditor SupportsReadOnly = true; } + public override IPropertyIndexValueFactory PropertyIndexValueFactory { get; } = new NoopPropertyIndexValueFactory(); + + /// protected override IConfigurationEditor CreateConfigurationEditor() => new ColorPickerConfigurationEditor(_ioHelper, _jsonSerializer, _editorConfigurationParser); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs index c3390b3fc5..ab1e3f976e 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs @@ -97,6 +97,8 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator, SupportsReadOnly = true; } + public override IPropertyIndexValueFactory PropertyIndexValueFactory { get; } = new NoopPropertyIndexValueFactory(); + public bool TryGetMediaPath(string? propertyEditorAlias, object? value, out string? mediaPath) { if (propertyEditorAlias == Alias && diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index 09eb6a1f47..f67273a041 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -59,6 +59,8 @@ public class MediaPicker3PropertyEditor : DataEditor SupportsReadOnly = true; } + public override IPropertyIndexValueFactory PropertyIndexValueFactory { get; } = new NoopPropertyIndexValueFactory(); + /// protected override IConfigurationEditor CreateConfigurationEditor() => new MediaPicker3ConfigurationEditor(_ioHelper, _editorConfigurationParser); @@ -67,6 +69,8 @@ public class MediaPicker3PropertyEditor : DataEditor protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); + + internal class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference { private readonly IDataTypeService _dataTypeService; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs index f38c88c4bc..d64df34aa4 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs @@ -32,9 +32,9 @@ public class NestedContentPropertyEditor : DataEditor public const string ContentTypeAliasPropertyKey = "ncContentTypeAlias"; private readonly IEditorConfigurationParser _editorConfigurationParser; private readonly IIOHelper _ioHelper; + private readonly INestedContentPropertyIndexValueFactory _nestedContentPropertyIndexValueFactory; - // Scheduled for removal in v12 - [Obsolete("Please use constructor that takes an IEditorConfigurationParser instead")] + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 12.")] public NestedContentPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper) @@ -42,17 +42,35 @@ public class NestedContentPropertyEditor : DataEditor { } + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] public NestedContentPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper, IEditorConfigurationParser editorConfigurationParser) + : this( + dataValueEditorFactory, + ioHelper, + editorConfigurationParser, + StaticServiceProvider.Instance.GetRequiredService()) + { + + } + + public NestedContentPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + IEditorConfigurationParser editorConfigurationParser, + INestedContentPropertyIndexValueFactory nestedContentPropertyIndexValueFactory) : base(dataValueEditorFactory) { _ioHelper = ioHelper; _editorConfigurationParser = editorConfigurationParser; + _nestedContentPropertyIndexValueFactory = nestedContentPropertyIndexValueFactory; SupportsReadOnly = true; } + public override IPropertyIndexValueFactory PropertyIndexValueFactory => _nestedContentPropertyIndexValueFactory; + #region Pre Value Editor protected override IConfigurationEditor CreateConfigurationEditor() => diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyIndexValueFactory.cs new file mode 100644 index 0000000000..445e1cc361 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyIndexValueFactory.cs @@ -0,0 +1,37 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal sealed class NestedContentPropertyIndexValueFactory + : NestedPropertyIndexValueFactoryBase< + NestedContentPropertyEditor.NestedContentValues.NestedContentRowValue[], + NestedContentPropertyEditor.NestedContentValues.NestedContentRowValue>, + INestedContentPropertyIndexValueFactory +{ + private readonly IContentTypeService _contentTypeService; + + + public NestedContentPropertyIndexValueFactory( + PropertyEditorCollection propertyEditorCollection, + IContentTypeService contentTypeService, + IJsonSerializer jsonSerializer) : base(propertyEditorCollection, jsonSerializer) + { + _contentTypeService = contentTypeService; + } + + protected override IContentType? GetContentTypeOfNestedItem( + NestedContentPropertyEditor.NestedContentValues.NestedContentRowValue input) + => _contentTypeService.Get(input.ContentTypeAlias); + + protected override IDictionary GetRawProperty( + NestedContentPropertyEditor.NestedContentValues.NestedContentRowValue nestedContentRowValue) => + nestedContentRowValue.RawPropertyValues; + + protected override IEnumerable GetDataItems( + NestedContentPropertyEditor.NestedContentValues.NestedContentRowValue[] input) => input; +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs new file mode 100644 index 0000000000..cc2f8143b8 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs @@ -0,0 +1,181 @@ +using System.Text; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Examine; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal abstract class NestedPropertyIndexValueFactoryBase : JsonPropertyIndexValueFactoryBase +{ + private readonly PropertyEditorCollection _propertyEditorCollection; + + protected NestedPropertyIndexValueFactoryBase( + PropertyEditorCollection propertyEditorCollection, + IJsonSerializer jsonSerializer) + : base(jsonSerializer) + { + _propertyEditorCollection = propertyEditorCollection; + } + + protected override IEnumerable>> Handle( + TSerialized deserializedPropertyValue, + IProperty property, + string? culture, + string? segment, + bool published) + { + var result = new List>>(); + + foreach (TItem nestedContentRowValue in GetDataItems(deserializedPropertyValue)) + { + IContentType? contentType = GetContentTypeOfNestedItem(nestedContentRowValue); + + if (contentType is null) + { + continue; + } + + var propertyTypeDictionary = + contentType + .PropertyGroups + .SelectMany(x => x.PropertyTypes!) + .ToDictionary(x => x.Alias); + + result.AddRange(GetNestedResults( + property.Alias, + culture, + segment, + published, + propertyTypeDictionary, + nestedContentRowValue)); + } + + return RenameKeysToEnsureRawSegmentsIsAPrefix(result); + } + + /// + /// Rename keys that count the RAW-constant, to ensure the RAW-constant is a prefix. + /// + private IEnumerable>> RenameKeysToEnsureRawSegmentsIsAPrefix( + List>> indexContent) + { + foreach (KeyValuePair> indexedKeyValuePair in indexContent) + { + // Tests if key includes the RawFieldPrefix and it is not in the start + if (indexedKeyValuePair.Key.Substring(1).Contains(UmbracoExamineFieldNames.RawFieldPrefix)) + { + var newKey = UmbracoExamineFieldNames.RawFieldPrefix + + indexedKeyValuePair.Key.Replace(UmbracoExamineFieldNames.RawFieldPrefix, string.Empty); + yield return new KeyValuePair>(newKey, indexedKeyValuePair.Value); + } + else + { + yield return indexedKeyValuePair; + } + } + } + + /// + /// Gets the content type using the nested item. + /// + protected abstract IContentType? GetContentTypeOfNestedItem(TItem nestedItem); + + /// + /// Gets the raw data from a nested item. + /// + protected abstract IDictionary GetRawProperty(TItem nestedItem); + + /// + /// Get the data times of a parent item. E.g. block list have contentData. + /// + protected abstract IEnumerable GetDataItems(TSerialized input); + + /// + /// Index a key with the name of the property, using the relevant content of all the children. + /// + protected override IEnumerable>> HandleResume( + List>> indexedContent, + IProperty property, + string? culture, + string? segment, + bool published) + { + yield return new KeyValuePair>( + property.Alias, + GetResumeFromAllContent(indexedContent).Yield()); + } + + /// + /// Gets a resume as string of all the content in this nested type. + /// + /// All the indexed content for this property. + /// the string with all relevant content from + private static string GetResumeFromAllContent(List>> indexedContent) + { + var stringBuilder = new StringBuilder(); + foreach ((var indexKey, IEnumerable? indexedValue) in indexedContent) + { + // Ignore Raw fields + if (indexKey.Contains(UmbracoExamineFieldNames.RawFieldPrefix)) + { + continue; + } + + foreach (var value in indexedValue) + { + if (value is not null) + { + stringBuilder.AppendLine(value.ToString()); + } + } + } + + return stringBuilder.ToString(); + } + + /// + /// Gets the content to index for the nested type. E.g. Block list, Nested Content, etc.. + /// + private IEnumerable>> GetNestedResults( + string keyPrefix, + string? culture, + string? segment, + bool published, + IDictionary propertyTypeDictionary, + TItem nestedContentRowValue) + { + var blockIndex = 0; + + foreach ((var propertyAlias, var propertyValue) in GetRawProperty(nestedContentRowValue)) + { + if (propertyTypeDictionary.TryGetValue(propertyAlias, out IPropertyType? propertyType)) + { + IProperty subProperty = new Property(propertyType); + subProperty.SetValue(propertyValue, culture, segment); + + if (published) + { + subProperty.PublishValues(culture, segment ?? "*"); + } + + IDataEditor? editor = _propertyEditorCollection[propertyType.PropertyEditorAlias]; + if (editor is null) + { + continue; + } + + IEnumerable>> indexValues = + editor.PropertyIndexValueFactory.GetIndexValues(subProperty, culture, segment, published); + + foreach ((var nestedAlias, IEnumerable nestedValue) in indexValues) + { + yield return new KeyValuePair>( + $"{keyPrefix}.items[{blockIndex}].{nestedAlias}", nestedValue!); + } + } + + blockIndex++; + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs index ff646a039d..6f82c8ab3c 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs @@ -29,12 +29,13 @@ namespace Umbraco.Cms.Core.PropertyEditors; public class TagsPropertyEditor : DataEditor { private readonly IEditorConfigurationParser _editorConfigurationParser; + private readonly ITagPropertyIndexValueFactory _tagPropertyIndexValueFactory; private readonly IIOHelper _ioHelper; private readonly ILocalizedTextService _localizedTextService; private readonly ManifestValueValidatorCollection _validators; // Scheduled for removal in v12 - [Obsolete("Please use constructor that takes an IEditorConfigurationParser instead")] + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] public TagsPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, ManifestValueValidatorCollection validators, @@ -45,24 +46,48 @@ public class TagsPropertyEditor : DataEditor validators, ioHelper, localizedTextService, - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } + [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] + public TagsPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + ManifestValueValidatorCollection validators, + IIOHelper ioHelper, + ILocalizedTextService localizedTextService, + IEditorConfigurationParser editorConfigurationParser) + : this( + dataValueEditorFactory, + validators, + ioHelper, + localizedTextService, + editorConfigurationParser, + StaticServiceProvider.Instance.GetRequiredService()) + { + + } + public TagsPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, ManifestValueValidatorCollection validators, IIOHelper ioHelper, ILocalizedTextService localizedTextService, - IEditorConfigurationParser editorConfigurationParser) + IEditorConfigurationParser editorConfigurationParser, + ITagPropertyIndexValueFactory tagPropertyIndexValueFactory) : base(dataValueEditorFactory) { _validators = validators; _ioHelper = ioHelper; _localizedTextService = localizedTextService; _editorConfigurationParser = editorConfigurationParser; + _tagPropertyIndexValueFactory = tagPropertyIndexValueFactory; } + public override IPropertyIndexValueFactory PropertyIndexValueFactory => _tagPropertyIndexValueFactory; + + protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs index 947c3beecd..d88a9689ab 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs @@ -95,7 +95,8 @@ public class DataValueEditorReuseTests _dataValueEditorFactoryMock.Object, new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty)), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); // block list is *not* set to reuse its data value editor var dataValueEditor1 = blockListPropertyEditor.GetValueEditor(); @@ -115,7 +116,8 @@ public class DataValueEditorReuseTests _dataValueEditorFactoryMock.Object, new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty)), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); // no matter what, a property editor should never reuse its data value editor when created *with* configuration var dataValueEditor1 = blockListPropertyEditor.GetValueEditor("config"); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/NestedContentTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/NestedContentTests.cs index 7109b9cbcc..1fa01615ed 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/NestedContentTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/NestedContentTests.cs @@ -34,7 +34,11 @@ public class NestedContentTests var localizationService = Mock.Of(); PropertyEditorCollection editors = null; - var editor = new NestedContentPropertyEditor(Mock.Of(), Mock.Of(), Mock.Of()); + var editor = new NestedContentPropertyEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); editors = new PropertyEditorCollection(new DataEditorCollection(() => new DataEditor[] { editor })); var serializer = new ConfigurationEditorJsonSerializer(); From 7348171c0135f2d04d3ab8f3f5dfa863e5b93f29 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Mon, 13 Feb 2023 14:50:41 +0100 Subject: [PATCH 06/32] Add DefaultLanguage fallback policy for published values (#13814) * Add DefaultLanguage fallback * Implement DefaultLanguage fallback * Add DefaultLanguage fallback test --- .../Models/PublishedContent/Fallback.cs | 48 +++++++++++---- .../PublishedValueFallback.cs | 59 +++++++++++++++++++ .../PublishedContentLanguageVariantTests.cs | 9 +++ 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Core/Models/PublishedContent/Fallback.cs b/src/Umbraco.Core/Models/PublishedContent/Fallback.cs index 2c665f1710..e6df70621a 100644 --- a/src/Umbraco.Core/Models/PublishedContent/Fallback.cs +++ b/src/Umbraco.Core/Models/PublishedContent/Fallback.cs @@ -3,56 +3,82 @@ using System.Collections; namespace Umbraco.Cms.Core.Models.PublishedContent; /// -/// Manages the built-in fallback policies. +/// Manages the built-in fallback policies. /// public struct Fallback : IEnumerable { /// - /// Do not fallback. + /// Do not fallback. /// public const int None = 0; private readonly int[] _values; /// - /// Initializes a new instance of the struct with values. + /// Initializes a new instance of the struct with values. /// + /// The values. private Fallback(int[] values) => _values = values; /// - /// Gets an ordered set of fallback policies. + /// Gets an ordered set of fallback policies. /// - /// + /// The values. + /// + /// The fallback policy. + /// public static Fallback To(params int[] values) => new(values); /// - /// Fallback to default value. + /// Fallback to the default value. /// public const int DefaultValue = 1; /// - /// Fallback to other languages. + /// Fallback to other languages. /// public const int Language = 2; /// - /// Fallback to tree ancestors. + /// Fallback to tree ancestors. /// public const int Ancestors = 3; /// - /// Gets the fallback to default value policy. + /// Fallback to the default language. /// + public const int DefaultLanguage = 4; + + /// + /// Gets the fallback to the default language policy. + /// + /// + /// The default language fallback policy. + /// + public static Fallback ToDefaultLanguage => new Fallback(new[] { DefaultLanguage }); + + /// + /// Gets the fallback to the default value policy. + /// + /// + /// The default value fallback policy. + /// public static Fallback ToDefaultValue => new(new[] { DefaultValue }); /// - /// Gets the fallback to language policy. + /// Gets the fallback to language policy. /// + /// + /// The language fallback policy. + /// public static Fallback ToLanguage => new(new[] { Language }); /// - /// Gets the fallback to tree ancestors policy. + /// Gets the fallback to tree ancestors policy. /// + /// + /// The tree ancestors fallback policy. + /// public static Fallback ToAncestors => new(new[] { Ancestors }); /// diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs index 64f0160383..0e43a2617f 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs @@ -44,6 +44,13 @@ public class PublishedValueFallback : IPublishedValueFallback return true; } + break; + case Fallback.DefaultLanguage: + if (TryGetValueWithDefaultLanguageFallback(property, culture, segment, out value)) + { + return true; + } + break; default: throw NotSupportedFallbackMethod(f, "property"); @@ -85,6 +92,13 @@ public class PublishedValueFallback : IPublishedValueFallback return true; } + break; + case Fallback.DefaultLanguage: + if (TryGetValueWithDefaultLanguageFallback(content, alias, culture, segment, out value)) + { + return true; + } + break; default: throw NotSupportedFallbackMethod(f, "element"); @@ -141,6 +155,13 @@ public class PublishedValueFallback : IPublishedValueFallback return true; } + break; + case Fallback.DefaultLanguage: + if (TryGetValueWithDefaultLanguageFallback(content, alias, culture, segment, out value)) + { + return true; + } + break; default: throw NotSupportedFallbackMethod(f, "content"); @@ -347,4 +368,42 @@ public class PublishedValueFallback : IPublishedValueFallback language = language2; } } + + private bool TryGetValueWithDefaultLanguageFallback(IPublishedProperty property, string? culture, string? segment, out T? value) + { + value = default; + + if (culture.IsNullOrWhiteSpace()) + { + return false; + } + + string? defaultCulture = _localizationService?.GetDefaultLanguageIsoCode(); + if (culture.InvariantEquals(defaultCulture) == false && property.HasValue(defaultCulture, segment)) + { + value = property.Value(this, defaultCulture, segment); + return true; + } + + return false; + } + + private bool TryGetValueWithDefaultLanguageFallback(IPublishedElement element, string alias, string? culture, string? segment, out T? value) + { + value = default; + + if (culture.IsNullOrWhiteSpace()) + { + return false; + } + + string? defaultCulture = _localizationService?.GetDefaultLanguageIsoCode(); + if (culture.InvariantEquals(defaultCulture) == false && element.HasValue(alias, defaultCulture, segment)) + { + value = element.Value(this, alias, defaultCulture, segment); + return true; + } + + return false; + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PublishedCache/PublishedContentLanguageVariantTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PublishedCache/PublishedContentLanguageVariantTests.cs index 342e08acbe..37a5f08286 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PublishedCache/PublishedContentLanguageVariantTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PublishedCache/PublishedContentLanguageVariantTests.cs @@ -242,6 +242,15 @@ public class PublishedContentLanguageVariantTests : PublishedSnapshotServiceTest Assert.IsNull(value); } + [Test] + public void Can_Get_Content_For_Unpopulated_Requested_DefaultLanguage_With_Fallback() + { + var snapshot = GetPublishedSnapshot(); + var content = snapshot.Content.GetAtRoot().First(); + var value = content.Value(PublishedValueFallback, "welcomeText", "fr", fallback: Fallback.ToDefaultLanguage); + Assert.AreEqual("Welcome", value); + } + [Test] public void Do_Not_Get_Content_Recursively_Unless_Requested() { From f9ed9c0ae86e90c4925413f5514ed7b4d05900cb Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 14 Feb 2023 08:01:08 +0100 Subject: [PATCH 07/32] Bumped dependencies on Forms and Deploy to latest releases in JSON schema project. (#13827) --- src/JsonSchema/JsonSchema.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/JsonSchema.csproj b/src/JsonSchema/JsonSchema.csproj index 79930266aa..901f89e8b2 100644 --- a/src/JsonSchema/JsonSchema.csproj +++ b/src/JsonSchema/JsonSchema.csproj @@ -12,7 +12,7 @@ - - + + From 45036f54dde7aa35780fd373644062ca51668e97 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Tue, 14 Feb 2023 10:35:45 +0100 Subject: [PATCH 08/32] Sort domains (Culture and Hostnames) (#13797) * Add sort order to IDomain, UmbracoDomain and DomainDto * Add migration to create domain sort order column * Add Sort method to domain service * Set sort order when persisting new domain and order results * Add multiple and block style support to umb-button-group * Allow sorting domains in back-office, improve UI and rewrite PostSaveLanguageAndDomains for correctly sorting domains * Ensure routing and cache keeps the domain sort order * Update test to assert correct domain order * Move migration to target 11.3 and cleanup plan * Fix formatting/styling and make SelectDomains private Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> --------- Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> --- src/Umbraco.Core/Models/IDomain.cs | 50 ++- src/Umbraco.Core/Models/UmbracoDomain.cs | 60 ++-- src/Umbraco.Core/Routing/Domain.cs | 55 ++- src/Umbraco.Core/Routing/DomainUtilities.cs | 75 ++--- src/Umbraco.Core/Services/DomainService.cs | 53 ++- src/Umbraco.Core/Services/IDomainService.cs | 4 + .../Migrations/Upgrade/UmbracoPlan.cs | 67 ++-- .../Upgrade/V_11_3_0/AddDomainSortOrder.cs | 39 +++ .../Persistence/Dtos/DomainDto.cs | 9 +- .../Persistence/Factories/DomainFactory.cs | 42 +++ .../Persistence/Mappers/DomainMapper.cs | 1 + .../Implement/DomainRepository.cs | 139 +++----- .../DomainCache.cs | 4 +- .../PublishedSnapshotService.cs | 8 +- .../Controllers/ContentController.cs | 182 +++++----- .../buttons/umbbuttongroup.directive.js | 113 ++++--- .../src/less/button-groups.less | 16 + src/Umbraco.Web.UI.Client/src/less/forms.less | 5 - .../components/buttons/umb-button-group.html | 4 +- .../src/views/content/assigndomain.html | 166 ++++----- .../content.assigndomain.controller.js | 314 +++++++++--------- .../Routing/ContentFinderByUrlTests.cs | 4 +- .../Routing/SiteDomainMapperTests.cs | 96 +++--- .../Routing/UrlsProviderWithDomainsTests.cs | 38 +-- .../Routing/PublishedRequestBuilderTests.cs | 4 +- 25 files changed, 858 insertions(+), 690 deletions(-) create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_11_3_0/AddDomainSortOrder.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Factories/DomainFactory.cs diff --git a/src/Umbraco.Core/Models/IDomain.cs b/src/Umbraco.Core/Models/IDomain.cs index 2d4845c9a6..edfff6512e 100644 --- a/src/Umbraco.Core/Models/IDomain.cs +++ b/src/Umbraco.Core/Models/IDomain.cs @@ -2,18 +2,58 @@ using Umbraco.Cms.Core.Models.Entities; namespace Umbraco.Cms.Core.Models; +/// +/// Represents a domain name, optionally assigned to a content and/or language ID. +/// +/// +/// public interface IDomain : IEntity, IRememberBeingDirty { - int? LanguageId { get; set; } - + /// + /// Gets or sets the name of the domain. + /// + /// + /// The name of the domain. + /// string DomainName { get; set; } - int? RootContentId { get; set; } - + /// + /// Gets a value indicating whether this is a wildcard domain (only specifying the language of a content node). + /// + /// + /// true if this is a wildcard domain; otherwise, false. + /// bool IsWildcard { get; } /// - /// Readonly value of the language ISO code for the domain + /// Gets or sets the language ID assigned to the domain. /// + /// + /// The language ID assigned to the domain. + /// + int? LanguageId { get; set; } + + /// + /// Gets the language ISO code. + /// + /// + /// The language ISO code. + /// string? LanguageIsoCode { get; } + + /// + /// Gets or sets the root content ID assigned to the domain. + /// + /// + /// The root content ID assigned to the domain. + /// + int? RootContentId { get; set; } + + /// + /// Gets or sets the sort order. + /// + /// + /// The sort order. + /// + int SortOrder { get => IsWildcard ? -1 : 0; set { } } // TODO Remove default implementation in a future version } diff --git a/src/Umbraco.Core/Models/UmbracoDomain.cs b/src/Umbraco.Core/Models/UmbracoDomain.cs index c883e14770..f9c3cd753d 100644 --- a/src/Umbraco.Core/Models/UmbracoDomain.cs +++ b/src/Umbraco.Core/Models/UmbracoDomain.cs @@ -3,27 +3,33 @@ using Umbraco.Cms.Core.Models.Entities; namespace Umbraco.Cms.Core.Models; +/// [Serializable] [DataContract(IsReference = true)] public class UmbracoDomain : EntityBase, IDomain { - private int? _contentId; private string _domainName; private int? _languageId; + private int? _rootContentId; + private int _sortOrder; - public UmbracoDomain(string domainName) => _domainName = domainName; + /// + /// Initializes a new instance of the class. + /// + /// The name of the domain. + public UmbracoDomain(string domainName) + => _domainName = domainName; + /// + /// Initializes a new instance of the class. + /// + /// The name of the domain. + /// The language ISO code. public UmbracoDomain(string domainName, string languageIsoCode) - : this(domainName) => - LanguageIsoCode = languageIsoCode; - - [DataMember] - public int? LanguageId - { - get => _languageId; - set => SetPropertyValueAndDetectChanges(value, ref _languageId, nameof(LanguageId)); - } + : this(domainName) + => LanguageIsoCode = languageIsoCode; + /// [DataMember] public string DomainName { @@ -31,17 +37,33 @@ public class UmbracoDomain : EntityBase, IDomain set => SetPropertyValueAndDetectChanges(value, ref _domainName!, nameof(DomainName)); } + /// + public bool IsWildcard => string.IsNullOrWhiteSpace(DomainName) || DomainName.StartsWith("*"); + + /// + [DataMember] + public int? LanguageId + { + get => _languageId; + set => SetPropertyValueAndDetectChanges(value, ref _languageId, nameof(LanguageId)); + } + + /// + public string? LanguageIsoCode { get; set; } + + /// [DataMember] public int? RootContentId { - get => _contentId; - set => SetPropertyValueAndDetectChanges(value, ref _contentId, nameof(RootContentId)); + get => _rootContentId; + set => SetPropertyValueAndDetectChanges(value, ref _rootContentId, nameof(RootContentId)); } - public bool IsWildcard => string.IsNullOrWhiteSpace(DomainName) || DomainName.StartsWith("*"); - - /// - /// Readonly value of the language ISO code for the domain - /// - public string? LanguageIsoCode { get; set; } + /// + [DataMember] + public int SortOrder + { + get => _sortOrder; + set => SetPropertyValueAndDetectChanges(value, ref _sortOrder, nameof(SortOrder)); + } } diff --git a/src/Umbraco.Core/Routing/Domain.cs b/src/Umbraco.Core/Routing/Domain.cs index 291d7beed9..8c77b5661b 100644 --- a/src/Umbraco.Core/Routing/Domain.cs +++ b/src/Umbraco.Core/Routing/Domain.cs @@ -1,29 +1,44 @@ namespace Umbraco.Cms.Core.Routing; /// -/// Represents a published snapshot domain. +/// Represents a published snapshot domain. /// public class Domain { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The unique identifier of the domain. /// The name of the domain. /// The identifier of the content which supports the domain. /// The culture of the domain. /// A value indicating whether the domain is a wildcard domain. + [Obsolete("Use the constructor specifying all properties instead. This constructor will be removed in a future version.")] public Domain(int id, string name, int contentId, string? culture, bool isWildcard) + : this(id, name, contentId, culture, isWildcard, -1) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier of the domain. + /// The name of the domain. + /// The identifier of the content which supports the domain. + /// The culture of the domain. + /// A value indicating whether the domain is a wildcard domain. + /// The sort order. + public Domain(int id, string name, int contentId, string? culture, bool isWildcard, int sortOrder) { Id = id; Name = name; ContentId = contentId; Culture = culture; IsWildcard = isWildcard; + SortOrder = sortOrder; } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// An origin domain. protected Domain(Domain domain) @@ -33,30 +48,54 @@ public class Domain ContentId = domain.ContentId; Culture = domain.Culture; IsWildcard = domain.IsWildcard; + SortOrder = domain.SortOrder; } /// - /// Gets the unique identifier of the domain. + /// Gets the unique identifier of the domain. /// + /// + /// The unique identifier of the domain. + /// public int Id { get; } /// - /// Gets the name of the domain. + /// Gets the name of the domain. /// + /// + /// The name of the domain. + /// public string Name { get; } /// - /// Gets the identifier of the content which supports the domain. + /// Gets the identifier of the content which supports the domain. /// + /// + /// The identifier of the content which supports the domain. + /// public int ContentId { get; } /// - /// Gets the culture of the domain. + /// Gets the culture of the domain. /// + /// + /// The culture of the domain. + /// public string? Culture { get; } /// - /// Gets a value indicating whether the domain is a wildcard domain. + /// Gets a value indicating whether the domain is a wildcard domain. /// + /// + /// true if this is a wildcard domain; otherwise, false. + /// public bool IsWildcard { get; } + + /// + /// Gets the sort order. + /// + /// + /// The sort order. + /// + public int SortOrder { get; } } diff --git a/src/Umbraco.Core/Routing/DomainUtilities.cs b/src/Umbraco.Core/Routing/DomainUtilities.cs index f31244d2ac..7fe5017948 100644 --- a/src/Umbraco.Core/Routing/DomainUtilities.cs +++ b/src/Umbraco.Core/Routing/DomainUtilities.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Web; @@ -172,14 +173,10 @@ namespace Umbraco.Cms.Core.Routing // sanitize the list to have proper uris for comparison (scheme, path end with /) // we need to end with / because example.com/foo cannot match example.com/foobar // we need to order so example.com/foo matches before example.com/ - var domainsAndUris = domains? - .Where(d => d.IsWildcard == false) - .Select(d => new DomainAndUri(d, uri)) - .OrderByDescending(d => d.Uri.ToString()) - .ToList(); + DomainAndUri[]? domainsAndUris = SelectDomains(domains, uri)?.ToArray(); // nothing = no magic, return null - if (domainsAndUris is null || domainsAndUris.Count == 0) + if (domainsAndUris is null || domainsAndUris.Length == 0) { return null; } @@ -204,8 +201,9 @@ namespace Umbraco.Cms.Core.Routing IReadOnlyCollection considerForBaseDomains = domainsAndUris; if (cultureDomains != null) { - if (cultureDomains.Count == 1) // only 1, return + if (cultureDomains.Count == 1) { + // only 1, return return cultureDomains.First(); } @@ -214,9 +212,11 @@ namespace Umbraco.Cms.Core.Routing } // look for domains that would be the base of the uri - IReadOnlyCollection baseDomains = SelectByBase(considerForBaseDomains, uri, culture); - if (baseDomains.Count > 0) // found, return + // we need to order so example.com/foo matches before example.com/ + IReadOnlyCollection baseDomains = SelectByBase(considerForBaseDomains.OrderByDescending(d => d.Uri.ToString()).ToList(), uri, culture); + if (baseDomains.Count > 0) { + // found, return return baseDomains.First(); } @@ -246,9 +246,9 @@ namespace Umbraco.Cms.Core.Routing // if none matches, try again without the port // ie current is www.example.com:1234/foo/bar, look for domain www.example.com - Uri currentWithoutPort = currentWithSlash.WithoutPort(); if (baseDomains.Count == 0) { + Uri currentWithoutPort = currentWithSlash.WithoutPort(); baseDomains = domainsAndUris.Where(d => IsBaseOf(d, currentWithoutPort)).ToList(); } @@ -258,9 +258,9 @@ namespace Umbraco.Cms.Core.Routing private static IReadOnlyCollection? SelectByCulture(IReadOnlyCollection domainsAndUris, string? culture, string? defaultCulture) { // we try our best to match cultures, but may end with a bogus domain - - if (culture != null) // try the supplied culture + if (culture is not null) { + // try the supplied culture var cultureDomains = domainsAndUris.Where(x => x.Culture.InvariantEquals(culture)).ToList(); if (cultureDomains.Count > 0) { @@ -268,8 +268,9 @@ namespace Umbraco.Cms.Core.Routing } } - if (defaultCulture != null) // try the defaultCulture culture + if (defaultCulture is not null) { + // try the defaultCulture culture var cultureDomains = domainsAndUris.Where(x => x.Culture.InvariantEquals(defaultCulture)).ToList(); if (cultureDomains.Count > 0) { @@ -280,31 +281,32 @@ namespace Umbraco.Cms.Core.Routing return null; } - private static DomainAndUri GetByCulture(IReadOnlyCollection domainsAndUris, string? culture, string? defaultCulture) + private static DomainAndUri? GetByCulture(IReadOnlyCollection domainsAndUris, string? culture, string? defaultCulture) { DomainAndUri? domainAndUri; // we try our best to match cultures, but may end with a bogus domain - - if (culture != null) // try the supplied culture + if (culture is not null) { + // try the supplied culture domainAndUri = domainsAndUris.FirstOrDefault(x => x.Culture.InvariantEquals(culture)); - if (domainAndUri != null) + if (domainAndUri is not null) { return domainAndUri; } } - if (defaultCulture != null) // try the defaultCulture culture + if (defaultCulture is not null) { + // try the defaultCulture culture domainAndUri = domainsAndUris.FirstOrDefault(x => x.Culture.InvariantEquals(defaultCulture)); - if (domainAndUri != null) + if (domainAndUri is not null) { return domainAndUri; } } - return domainsAndUris.First(); // what else? + return domainsAndUris.FirstOrDefault(); } /// @@ -313,14 +315,10 @@ namespace Umbraco.Cms.Core.Routing /// The domains. /// The uri, or null. /// The domains and their normalized uris, that match the specified uri. - internal static IEnumerable SelectDomains(IEnumerable domains, Uri uri) - { + [return: NotNullIfNotNull(nameof(domains))] + private static IEnumerable? SelectDomains(IEnumerable? domains, Uri uri) // TODO: where are we matching ?!!? - return domains - .Where(d => d.IsWildcard == false) - .Select(d => new DomainAndUri(d, uri)) - .OrderByDescending(d => d.Uri.ToString()); - } + => domains?.Where(d => d.IsWildcard == false).Select(d => new DomainAndUri(d, uri)); /// /// Parses a domain name into a URI. @@ -351,9 +349,7 @@ namespace Umbraco.Cms.Core.Routing /// A value indicating if there is another domain defined down in the path. /// Looks _under_ rootNodeId but not _at_ rootNodeId. internal static bool ExistsDomainInPath(IEnumerable domains, string path, int? rootNodeId) - { - return FindDomainInPath(domains, path, rootNodeId) != null; - } + => FindDomainInPath(domains, path, rootNodeId) is not null; /// /// Gets the deepest non-wildcard Domain, if any, from a group of Domains, in a node path. @@ -364,17 +360,7 @@ namespace Umbraco.Cms.Core.Routing /// The deepest non-wildcard Domain in the path, or null. /// Looks _under_ rootNodeId but not _at_ rootNodeId. internal static Domain? FindDomainInPath(IEnumerable domains, string path, int? rootNodeId) - { - var stopNodeId = rootNodeId ?? -1; - - return path.Split(Constants.CharArrays.Comma) - .Reverse() - .Select(s => int.Parse(s, CultureInfo.InvariantCulture)) - .TakeWhile(id => id != stopNodeId) - .Select(id => domains.FirstOrDefault(d => d.ContentId == id && d.IsWildcard == false)) - .SkipWhile(domain => domain == null) - .FirstOrDefault(); - } + => FindDomainInPath(domains, path, rootNodeId, false); /// /// Gets the deepest wildcard Domain, if any, from a group of Domains, in a node path. @@ -385,6 +371,9 @@ namespace Umbraco.Cms.Core.Routing /// The deepest wildcard Domain in the path, or null. /// Looks _under_ rootNodeId but not _at_ rootNodeId. public static Domain? FindWildcardDomainInPath(IEnumerable? domains, string path, int? rootNodeId) + => FindDomainInPath(domains, path, rootNodeId, true); + + private static Domain? FindDomainInPath(IEnumerable? domains, string path, int? rootNodeId, bool isWildcard) { var stopNodeId = rootNodeId ?? -1; @@ -392,8 +381,8 @@ namespace Umbraco.Cms.Core.Routing .Reverse() .Select(s => int.Parse(s, CultureInfo.InvariantCulture)) .TakeWhile(id => id != stopNodeId) - .Select(id => domains?.FirstOrDefault(d => d.ContentId == id && d.IsWildcard)) - .FirstOrDefault(domain => domain != null); + .Select(id => domains?.FirstOrDefault(d => d.ContentId == id && d.IsWildcard == isWildcard)) + .FirstOrDefault(domain => domain is not null); } /// diff --git a/src/Umbraco.Core/Services/DomainService.cs b/src/Umbraco.Core/Services/DomainService.cs index 38f27bb94c..202d51d648 100644 --- a/src/Umbraco.Core/Services/DomainService.cs +++ b/src/Umbraco.Core/Services/DomainService.cs @@ -16,8 +16,8 @@ public class DomainService : RepositoryService, IDomainService ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, IDomainRepository domainRepository) - : base(provider, loggerFactory, eventMessagesFactory) => - _domainRepository = domainRepository; + : base(provider, loggerFactory, eventMessagesFactory) + => _domainRepository = domainRepository; public bool Exists(string domainName) { @@ -43,8 +43,7 @@ public class DomainService : RepositoryService, IDomainService _domainRepository.Delete(domain); scope.Complete(); - scope.Notifications.Publish( - new DomainDeletedNotification(domain, eventMessages).WithStateFrom(deletingNotification)); + scope.Notifications.Publish(new DomainDeletedNotification(domain, eventMessages).WithStateFrom(deletingNotification)); } return OperationResult.Attempt.Succeed(eventMessages); @@ -97,8 +96,50 @@ public class DomainService : RepositoryService, IDomainService _domainRepository.Save(domainEntity); scope.Complete(); - scope.Notifications.Publish( - new DomainSavedNotification(domainEntity, eventMessages).WithStateFrom(savingNotification)); + + scope.Notifications.Publish(new DomainSavedNotification(domainEntity, eventMessages).WithStateFrom(savingNotification)); + } + + return OperationResult.Attempt.Succeed(eventMessages); + } + + public Attempt Sort(IEnumerable items) + { + EventMessages eventMessages = EventMessagesFactory.Get(); + + IDomain[] domains = items.ToArray(); + if (domains.Length == 0) + { + return OperationResult.Attempt.NoOperation(eventMessages); + } + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + var savingNotification = new DomainSavingNotification(domains, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return OperationResult.Attempt.Cancel(eventMessages); + } + + scope.WriteLock(Constants.Locks.Domains); + + int sortOrder = 0; + foreach (IDomain domain in domains) + { + // If the current sort order equals that of the domain we don't need to update it, so just increment the sort order and continue + if (domain.SortOrder == sortOrder) + { + sortOrder++; + continue; + } + + domain.SortOrder = sortOrder++; + _domainRepository.Save(domain); + } + + scope.Complete(); + scope.Notifications.Publish(new DomainSavedNotification(domains, eventMessages).WithStateFrom(savingNotification)); } return OperationResult.Attempt.Succeed(eventMessages); diff --git a/src/Umbraco.Core/Services/IDomainService.cs b/src/Umbraco.Core/Services/IDomainService.cs index 54a006ecb1..3b7cd29f80 100644 --- a/src/Umbraco.Core/Services/IDomainService.cs +++ b/src/Umbraco.Core/Services/IDomainService.cs @@ -1,3 +1,4 @@ +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Core.Services; @@ -17,4 +18,7 @@ public interface IDomainService : IService IEnumerable GetAssignedDomains(int contentId, bool includeWildcards); Attempt Save(IDomain domainEntity); + + Attempt Sort(IEnumerable items) + => Attempt.Fail(new OperationResult(OperationResultType.Failed, new EventMessages())); // TODO Remove default implmentation in a future version } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 5b13349dbd..0206442897 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -1,80 +1,60 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; -using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_10_0_0; -using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_10_2_0; -using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_10_5_0; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade; /// -/// Represents the Umbraco CMS migration plan. +/// Represents the Umbraco CMS migration plan. /// -/// +/// public class UmbracoPlan : MigrationPlan { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The Umbraco version. - public UmbracoPlan(IUmbracoVersion umbracoVersion) + public UmbracoPlan(IUmbracoVersion umbracoVersion) // TODO (V12): Remove unused parameter : base(Constants.Conventions.Migrations.UmbracoUpgradePlanName) - { - DefinePlan(); - } + => DefinePlan(); /// /// - /// The default initial state in plans is string.Empty. - /// - /// When upgrading from version 7, we want to use specific initial states - /// that are e.g. "{init-7.9.3}", "{init-7.11.1}", etc. so we can chain the proper - /// migrations. - /// - /// - /// This is also where we detect the current version, and reject invalid - /// upgrades (from a tool old version, or going back in time, etc). - /// + /// This is set to the final migration state of 9.4, making that the lowest supported version to upgrade from. /// public override string InitialState => "{DED98755-4059-41BB-ADBD-3FEAB12D1D7B}"; - - /// - /// Defines the plan. + /// Defines the plan. /// protected void DefinePlan() { - // MODIFYING THE PLAN - // // Please take great care when modifying the plan! // - // * Creating a migration for version 8: - // Append the migration to the main chain, using a new guid, before the "//FINAL" comment - // + // Creating a migration: append the migration to the main chain, using a new GUID. // // If the new migration causes a merge conflict, because someone else also added another // new migration, you NEED to fix the conflict by providing one default path, and paths // out of the conflict states, eg: // - // .From("state-1") - // .To("state-a") - // .To("state-b") // Some might already be in this state, without having applied ChangeA + // From("state-1") + // To("state-a") + // To("state-b") // Some might already be in this state, without having applied ChangeA // - // .From("state-1") - // .Merge() + // From("state-1") + // .Merge() // .To("state-a") - // .With() + // .With() // .To("state-b") - // .As("state-2"); + // .As("state-2"); From(InitialState); - // TO 10.0.0 - To("{B7E0D53C-2B0E-418B-AB07-2DDE486E225F}"); + // To 10.0.0 + To("{B7E0D53C-2B0E-418B-AB07-2DDE486E225F}"); - // TO 10.2.0 - To("{D0B3D29D-F4D5-43E3-BA67-9D49256F3266}"); - To("{79D8217B-5920-4C0E-8E9A-3CF8FA021882}"); + // To 10.2.0 + To("{D0B3D29D-F4D5-43E3-BA67-9D49256F3266}"); + To("{79D8217B-5920-4C0E-8E9A-3CF8FA021882}"); // To 10.3.0 To("{56833770-3B7E-4FD5-A3B6-3416A26A7A3F}"); @@ -82,7 +62,10 @@ public class UmbracoPlan : MigrationPlan // To 10.4.0 To("{3F5D492A-A3DB-43F9-A73E-9FEE3B180E6C}"); - // to 10.5.0 / 11.2.0 - To("{83AF7945-DADE-4A02-9041-F3F6EBFAC319}"); + // To 10.5.0 / 11.2.0 + To("{83AF7945-DADE-4A02-9041-F3F6EBFAC319}"); + + // To 11.3.0 + To("{BB3889ED-E2DE-49F2-8F71-5FD8616A2661}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_11_3_0/AddDomainSortOrder.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_11_3_0/AddDomainSortOrder.cs new file mode 100644 index 0000000000..b8668de31f --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_11_3_0/AddDomainSortOrder.cs @@ -0,0 +1,39 @@ +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_11_3_0; + +public class AddDomainSortOrder : MigrationBase +{ + public AddDomainSortOrder(IMigrationContext context) + : base(context) + { } + + protected override void Migrate() + { + if (ColumnExists(DomainDto.TableName, "sortOrder") == false) + { + // Use a custom SQL query to prevent selecting explicit columns (sortOrder doesn't exist yet) + List domainDtos = Database.Fetch($"SELECT * FROM {DomainDto.TableName}"); + + Delete.Table(DomainDto.TableName).Do(); + Create.Table().Do(); + + foreach (DomainDto domainDto in domainDtos) + { + bool isWildcard = string.IsNullOrWhiteSpace(domainDto.DomainName) || domainDto.DomainName.StartsWith("*"); + if (isWildcard) + { + // Set sort order of wildcard domains to -1 + domainDto.SortOrder = -1; + } + else + { + // Keep exising sort order by setting it to the id + domainDto.SortOrder = domainDto.Id; + } + } + + Database.InsertBatch(domainDtos); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DomainDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DomainDto.cs index 31a04fd664..da5a8ad665 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/DomainDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DomainDto.cs @@ -4,11 +4,13 @@ using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; -[TableName(Constants.DatabaseSchema.Tables.Domain)] +[TableName(TableName)] [PrimaryKey("id")] [ExplicitColumns] internal class DomainDto { + public const string TableName = Constants.DatabaseSchema.Tables.Domain; + [Column("id")] [PrimaryKeyColumn] public int Id { get; set; } @@ -26,8 +28,11 @@ internal class DomainDto public string DomainName { get; set; } = null!; /// - /// Used for a result on the query to get the associated language for a domain if there is one + /// Used for a result on the query to get the associated language for a domain, if there is one. /// [ResultColumn("languageISOCode")] public string IsoCode { get; set; } = null!; + + [Column("sortOrder")] + public int SortOrder { get; set; } } diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/DomainFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/DomainFactory.cs new file mode 100644 index 0000000000..fc95c03dcd --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Factories/DomainFactory.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Persistence.Factories; + +internal static class DomainFactory +{ + public static IDomain BuildEntity(DomainDto dto) + { + var domain = new UmbracoDomain(dto.DomainName, dto.IsoCode) + { + Id = dto.Id, + LanguageId = dto.DefaultLanguage, + RootContentId = dto.RootStructureId, + SortOrder = dto.SortOrder, + }; + + // Reset dirty initial properties (U4-1946) + domain.ResetDirtyProperties(false); + + return domain; + } + + public static DomainDto BuildDto(IDomain entity) + { + var dto = new DomainDto + { + Id = entity.Id, + DefaultLanguage = entity.LanguageId, + RootStructureId = entity.RootContentId, + DomainName = entity.DomainName, + SortOrder = entity.SortOrder, + }; + + return dto; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/DomainMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/DomainMapper.cs index 2f7b3991d2..1efc496a91 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/DomainMapper.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/DomainMapper.cs @@ -18,5 +18,6 @@ public sealed class DomainMapper : BaseMapper DefineMap(nameof(UmbracoDomain.RootContentId), nameof(DomainDto.RootStructureId)); DefineMap(nameof(UmbracoDomain.LanguageId), nameof(DomainDto.DefaultLanguage)); DefineMap(nameof(UmbracoDomain.DomainName), nameof(DomainDto.DomainName)); + DefineMap(nameof(UmbracoDomain.SortOrder), nameof(DomainDto.SortOrder)); } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DomainRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DomainRepository.cs index 9304d27b84..b1ab5f9437 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DomainRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DomainRepository.cs @@ -7,40 +7,36 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Factories; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; -// TODO: We need to get a readonly ISO code for the domain assigned internal class DomainRepository : EntityRepositoryBase, IDomainRepository { public DomainRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) : base(scopeAccessor, cache, logger) - { - } + { } - public IDomain? GetByName(string domainName) => - GetMany().FirstOrDefault(x => x.DomainName.InvariantEquals(domainName)); + public IDomain? GetByName(string domainName) + => GetMany().FirstOrDefault(x => x.DomainName.InvariantEquals(domainName)); - public bool Exists(string domainName) => GetMany().Any(x => x.DomainName.InvariantEquals(domainName)); + public bool Exists(string domainName) + => GetMany().Any(x => x.DomainName.InvariantEquals(domainName)); - public IEnumerable GetAll(bool includeWildcards) => - GetMany().Where(x => includeWildcards || x.IsWildcard == false); + public IEnumerable GetAll(bool includeWildcards) + => GetMany().Where(x => includeWildcards || x.IsWildcard == false); - public IEnumerable GetAssignedDomains(int contentId, bool includeWildcards) => - GetMany() - .Where(x => x.RootContentId == contentId) - .Where(x => includeWildcards || x.IsWildcard == false); + public IEnumerable GetAssignedDomains(int contentId, bool includeWildcards) + => GetMany().Where(x => x.RootContentId == contentId).Where(x => includeWildcards || x.IsWildcard == false); - protected override IRepositoryCachePolicy CreateCachePolicy() => - new FullDataSetRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, GetEntityId, /*expires:*/ - false); + protected override IRepositoryCachePolicy CreateCachePolicy() + => new FullDataSetRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, GetEntityId, false); - protected override IDomain? PerformGet(int id) => - - // use the underlying GetAll which will force cache all domains - GetMany().FirstOrDefault(x => x.Id == id); + protected override IDomain? PerformGet(int id) + // Use the underlying GetAll which will force cache all domains + => GetMany().FirstOrDefault(x => x.Id == id); protected override IEnumerable PerformGetAll(params int[]? ids) { @@ -49,12 +45,13 @@ internal class DomainRepository : EntityRepositoryBase, IDomainRep { sql.WhereIn(x => x.Id, ids); } + sql.OrderBy(dto => dto.SortOrder); - return Database.Fetch(sql).Select(ConvertFromDto); + return Database.Fetch(sql).Select(DomainFactory.BuildEntity); } - protected override IEnumerable PerformGetByQuery(IQuery query) => - throw new NotSupportedException("This repository does not support this method"); + protected override IEnumerable PerformGetByQuery(IQuery query) + => throw new NotSupportedException("This repository does not support this method"); protected override Sql GetBaseQuery(bool isCount) { @@ -65,7 +62,7 @@ internal class DomainRepository : EntityRepositoryBase, IDomainRep } else { - sql.Select("umbracoDomain.*, umbracoLanguage.languageISOCode") + sql.Select($"{Constants.DatabaseSchema.Tables.Domain}.*, {Constants.DatabaseSchema.Tables.Language}.languageISOCode") .From() .LeftJoin() .On(dto => dto.DefaultLanguage, dto => dto.Id); @@ -74,23 +71,23 @@ internal class DomainRepository : EntityRepositoryBase, IDomainRep return sql; } - protected override string GetBaseWhereClause() => $"{Constants.DatabaseSchema.Tables.Domain}.id = @id"; + protected override string GetBaseWhereClause() + => $"{Constants.DatabaseSchema.Tables.Domain}.id = @id"; protected override IEnumerable GetDeleteClauses() - { - var list = new List { "DELETE FROM umbracoDomain WHERE id = @id" }; - return list; - } + => new [] + { + $"DELETE FROM {Constants.DatabaseSchema.Tables.Domain} WHERE id = @id", + }; protected override void PersistNewItem(IDomain entity) { var exists = Database.ExecuteScalar( - "SELECT COUNT(*) FROM umbracoDomain WHERE domainName = @domainName", + $"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.Domain} WHERE domainName = @domainName", new { domainName = entity.DomainName }); if (exists > 0) { - throw new DuplicateNameException( - string.Format("The domain name {0} is already assigned", entity.DomainName)); + throw new DuplicateNameException($"The domain name {entity.DomainName} is already assigned."); } if (entity.RootContentId.HasValue) @@ -100,34 +97,37 @@ internal class DomainRepository : EntityRepositoryBase, IDomainRep new { id = entity.RootContentId.Value }); if (contentExists == 0) { - throw new NullReferenceException("No content exists with id " + entity.RootContentId.Value); + throw new NullReferenceException($"No content exists with id {entity.RootContentId.Value}."); } } if (entity.LanguageId.HasValue) { var languageExists = Database.ExecuteScalar( - "SELECT COUNT(*) FROM umbracoLanguage WHERE id = @id", + $"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.Language} WHERE id = @id", new { id = entity.LanguageId.Value }); if (languageExists == 0) { - throw new NullReferenceException("No language exists with id " + entity.LanguageId.Value); + throw new NullReferenceException($"No language exists with id {entity.LanguageId.Value}."); } } entity.AddingEntity(); - var factory = new DomainModelFactory(); - DomainDto dto = factory.BuildDto(entity); + // Get sort order + entity.SortOrder = GetNewSortOrder(entity.RootContentId, entity.IsWildcard); + + DomainDto dto = DomainFactory.BuildDto(entity); var id = Convert.ToInt32(Database.Insert(dto)); entity.Id = id; - // if the language changed, we need to resolve the ISO code! + // If the language changed, we need to resolve the ISO code if (entity.LanguageId.HasValue) { ((UmbracoDomain)entity).LanguageIsoCode = Database.ExecuteScalar( - "SELECT languageISOCode FROM umbracoLanguage WHERE id=@langId", new { langId = entity.LanguageId }); + $"SELECT languageISOCode FROM {Constants.DatabaseSchema.Tables.Language} WHERE id = @langId", + new { langId = entity.LanguageId }); } entity.ResetDirtyProperties(); @@ -137,15 +137,13 @@ internal class DomainRepository : EntityRepositoryBase, IDomainRep { entity.UpdatingEntity(); + // Ensure there is no other domain with the same name on another entity var exists = Database.ExecuteScalar( - "SELECT COUNT(*) FROM umbracoDomain WHERE domainName = @domainName AND umbracoDomain.id <> @id", + $"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.Domain} WHERE domainName = @domainName AND umbracoDomain.id <> @id", new { domainName = entity.DomainName, id = entity.Id }); - - // ensure there is no other domain with the same name on another entity if (exists > 0) { - throw new DuplicateNameException( - string.Format("The domain name {0} is already assigned", entity.DomainName)); + throw new DuplicateNameException($"The domain name {entity.DomainName} is already assigned."); } if (entity.RootContentId.HasValue) @@ -155,69 +153,40 @@ internal class DomainRepository : EntityRepositoryBase, IDomainRep new { id = entity.RootContentId.Value }); if (contentExists == 0) { - throw new NullReferenceException("No content exists with id " + entity.RootContentId.Value); + throw new NullReferenceException($"No content exists with id {entity.RootContentId.Value}."); } } if (entity.LanguageId.HasValue) { var languageExists = Database.ExecuteScalar( - "SELECT COUNT(*) FROM umbracoLanguage WHERE id = @id", + $"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.Language} WHERE id = @id", new { id = entity.LanguageId.Value }); if (languageExists == 0) { - throw new NullReferenceException("No language exists with id " + entity.LanguageId.Value); + throw new NullReferenceException($"No language exists with id {entity.LanguageId.Value}."); } } - var factory = new DomainModelFactory(); - DomainDto dto = factory.BuildDto(entity); + DomainDto dto = DomainFactory.BuildDto(entity); Database.Update(dto); - // if the language changed, we need to resolve the ISO code! + // If the language changed, we need to resolve the ISO code if (entity.WasPropertyDirty("LanguageId")) { ((UmbracoDomain)entity).LanguageIsoCode = Database.ExecuteScalar( - "SELECT languageISOCode FROM umbracoLanguage WHERE id=@langId", new { langId = entity.LanguageId }); + $"SELECT languageISOCode FROM {Constants.DatabaseSchema.Tables.Language} WHERE id = @langId", + new { langId = entity.LanguageId }); } entity.ResetDirtyProperties(); } - private IDomain ConvertFromDto(DomainDto dto) - { - var factory = new DomainModelFactory(); - IDomain entity = factory.BuildEntity(dto); - return entity; - } - - internal class DomainModelFactory - { - public IDomain BuildEntity(DomainDto dto) - { - var domain = new UmbracoDomain(dto.DomainName, dto.IsoCode) - { - Id = dto.Id, - LanguageId = dto.DefaultLanguage, - RootContentId = dto.RootStructureId, - }; - - // reset dirty initial properties (U4-1946) - domain.ResetDirtyProperties(false); - return domain; - } - - public DomainDto BuildDto(IDomain entity) - { - var dto = new DomainDto - { - DefaultLanguage = entity.LanguageId, - DomainName = entity.DomainName, - Id = entity.Id, - RootStructureId = entity.RootContentId, - }; - return dto; - } - } + protected int GetNewSortOrder(int? rootContentId, bool isWildcard) + => isWildcard + ? -1 + : Database.ExecuteScalar( + $"SELECT COALESCE(MAX(sortOrder), -1) + 1 FROM {Constants.DatabaseSchema.Tables.Domain} WHERE domainRootStructureID = @rootContentId AND NOT (domainName = '' OR domainName LIKE '*%')", + new { rootContentId }); } diff --git a/src/Umbraco.PublishedCache.NuCache/DomainCache.cs b/src/Umbraco.PublishedCache.NuCache/DomainCache.cs index c4b0162833..27d9cd35c8 100644 --- a/src/Umbraco.PublishedCache.NuCache/DomainCache.cs +++ b/src/Umbraco.PublishedCache.NuCache/DomainCache.cs @@ -31,7 +31,7 @@ public class DomainCache : IDomainCache list = list.Where(x => x.IsWildcard == false); } - return list; + return list.OrderBy(x => x.SortOrder); } /// @@ -46,7 +46,7 @@ public class DomainCache : IDomainCache list = list.Where(x => x.IsWildcard == false); } - return list; + return list.OrderBy(x => x.SortOrder); } /// diff --git a/src/Umbraco.PublishedCache.NuCache/PublishedSnapshotService.cs b/src/Umbraco.PublishedCache.NuCache/PublishedSnapshotService.cs index 42e17b603a..fe3c2836c5 100644 --- a/src/Umbraco.PublishedCache.NuCache/PublishedSnapshotService.cs +++ b/src/Umbraco.PublishedCache.NuCache/PublishedSnapshotService.cs @@ -298,15 +298,15 @@ internal class PublishedSnapshotService : IPublishedSnapshotService continue; // anomaly } - if (domain.LanguageIsoCode.IsNullOrWhiteSpace()) + var culture = domain.LanguageIsoCode; + if (string.IsNullOrWhiteSpace(culture)) { continue; // anomaly } - var culture = domain.LanguageIsoCode; _domainStore.SetLocked( domain.Id, - new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard)); + new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder)); break; } } @@ -832,7 +832,7 @@ internal class PublishedSnapshotService : IPublishedSnapshotService { foreach (Domain domain in domains .Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false) - .Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard))) + .Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder))) { _domainStore.SetLocked(domain.Id, domain); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index 99a4ea262d..642db289a0 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -2246,16 +2246,14 @@ public class ContentController : ContentControllerBase public ContentDomainsAndCulture GetCultureAndDomains(int id) { - IDomain[]? nodeDomains = _domainService.GetAssignedDomains(id, true)?.ToArray(); - IDomain? wildcard = nodeDomains?.FirstOrDefault(d => d.IsWildcard); - IEnumerable? domains = nodeDomains?.Where(d => !d.IsWildcard) - .Select(d => new DomainDisplay(d.DomainName, d.LanguageId.GetValueOrDefault(0))); + IDomain[] assignedDomains = _domainService.GetAssignedDomains(id, true).ToArray(); + IDomain? wildcard = assignedDomains.FirstOrDefault(d => d.IsWildcard); + IEnumerable domains = assignedDomains.Where(d => !d.IsWildcard).Select(d => new DomainDisplay(d.DomainName, d.LanguageId.GetValueOrDefault(0))); + return new ContentDomainsAndCulture { + Language = wildcard == null || !wildcard.LanguageId.HasValue ? "undefined" : wildcard.LanguageId.ToString(), Domains = domains, - Language = wildcard == null || !wildcard.LanguageId.HasValue - ? "undefined" - : wildcard.LanguageId.ToString() }; } @@ -2264,11 +2262,11 @@ public class ContentController : ContentControllerBase { if (model.Domains is not null) { - foreach (DomainDisplay domain in model.Domains) + foreach (DomainDisplay domainDisplay in model.Domains) { try { - Uri uri = DomainUtilities.ParseUriFromDomainName(domain.Name, new Uri(Request.GetEncodedUrl())); + DomainUtilities.ParseUriFromDomainName(domainDisplay.Name, new Uri(Request.GetEncodedUrl())); } catch (UriFormatException) { @@ -2277,18 +2275,16 @@ public class ContentController : ContentControllerBase } } + // Validate node IContent? node = _contentService.GetById(model.NodeId); - if (node == null) { HttpContext.SetReasonPhrase("Node Not Found."); return NotFound("There is no content node with id {model.NodeId}."); } - EntityPermission? permission = - _userService.GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, node.Path); - - + // Validate permissions on node + EntityPermission? permission = _userService.GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, node.Path); if (permission?.AssignedPermissions.Contains(ActionAssignDomain.ActionLetter.ToString(), StringComparer.Ordinal) == false) { HttpContext.SetReasonPhrase("Permission Denied."); @@ -2296,120 +2292,118 @@ public class ContentController : ContentControllerBase } model.Valid = true; - IDomain[]? domains = _domainService.GetAssignedDomains(model.NodeId, true)?.ToArray(); - ILanguage[] languages = _localizationService.GetAllLanguages().ToArray(); - ILanguage? language = model.Language > 0 ? languages.FirstOrDefault(l => l.Id == model.Language) : null; - // process wildcard - if (language != null) + IDomain[] assignedDomains = _domainService.GetAssignedDomains(model.NodeId, true).ToArray(); + ILanguage[] languages = _localizationService.GetAllLanguages().ToArray(); + + // Process language + ILanguage? language = model.Language > 0 ? languages.FirstOrDefault(l => l.Id == model.Language) : null; + if (language is not null) { - // yet there is a race condition here... - IDomain? wildcard = domains?.FirstOrDefault(d => d.IsWildcard); - if (wildcard != null) + // Update or create language on wildcard domain + IDomain? assignedWildcardDomain = assignedDomains.FirstOrDefault(d => d.IsWildcard); + if (assignedWildcardDomain is not null) { - wildcard.LanguageId = language.Id; + assignedWildcardDomain.LanguageId = language.Id; } else { - wildcard = new UmbracoDomain("*" + model.NodeId) + assignedWildcardDomain = new UmbracoDomain("*" + model.NodeId) { LanguageId = model.Language, RootContentId = model.NodeId }; } - Attempt saveAttempt = _domainService.Save(wildcard); - if (saveAttempt == false) + Attempt saveAttempt = _domainService.Save(assignedWildcardDomain); + if (saveAttempt.Success == false) { HttpContext.SetReasonPhrase(saveAttempt.Result?.Result.ToString()); return BadRequest("Saving domain failed"); } } - else + + // Delete every domain that's in the database, but not in the model + foreach (IDomain? assignedDomain in assignedDomains.Where(d => (d.IsWildcard && language is null) || (d.IsWildcard == false && (model.Domains is null || model.Domains.All(m => m.Name.InvariantEquals(d.DomainName) == false))))) { - IDomain? wildcard = domains?.FirstOrDefault(d => d.IsWildcard); - if (wildcard != null) - { - _domainService.Delete(wildcard); - } + _domainService.Delete(assignedDomain); } - // process domains - // delete every (non-wildcard) domain, that exists in the DB yet is not in the model - foreach (IDomain domain in domains?.Where(d => - d.IsWildcard == false && - (model.Domains?.All(m => m.Name.InvariantEquals(d.DomainName) == false) ?? - false)) ?? - Array.Empty()) + // Process domains + if (model.Domains is not null) { - _domainService.Delete(domain); - } - - var names = new List(); - - // create or update domains in the model - foreach (DomainDisplay domainModel in model.Domains?.Where(m => string.IsNullOrWhiteSpace(m.Name) == false) ?? - Array.Empty()) - { - language = languages.FirstOrDefault(l => l.Id == domainModel.Lang); - if (language == null) + var savedDomains = new List(); + foreach (DomainDisplay domainDisplay in model.Domains.Where(m => string.IsNullOrWhiteSpace(m.Name) == false)) { - continue; - } - - var name = domainModel.Name.ToLowerInvariant(); - if (names.Contains(name)) - { - domainModel.Duplicate = true; - continue; - } - - names.Add(name); - IDomain? domain = domains?.FirstOrDefault(d => d.DomainName.InvariantEquals(domainModel.Name)); - if (domain != null) - { - domain.LanguageId = language.Id; - _domainService.Save(domain); - } - else if (_domainService.Exists(domainModel.Name)) - { - domainModel.Duplicate = true; - IDomain? xdomain = _domainService.GetByName(domainModel.Name); - var xrcid = xdomain?.RootContentId; - if (xrcid.HasValue) + language = languages.FirstOrDefault(l => l.Id == domainDisplay.Lang); + if (language == null) { - IContent? xcontent = _contentService.GetById(xrcid.Value); - var xnames = new List(); - while (xcontent != null) + continue; + } + + var domainName = domainDisplay.Name.ToLowerInvariant(); + if (savedDomains.Any(d => d.DomainName == domainName)) + { + domainDisplay.Duplicate = true; + continue; + } + + IDomain? domain = assignedDomains.FirstOrDefault(d => d.DomainName.InvariantEquals(domainName)); + if (domain is null && _domainService.GetByName(domainName) is IDomain existingDomain) + { + // Domain name already exists on another node + domainDisplay.Duplicate = true; + + // Add node breadcrumbs + if (existingDomain.RootContentId is int rootContentId) { - if (xcontent.Name is not null) + var breadcrumbs = new List(); + + IContent? content = _contentService.GetById(rootContentId); + while (content is not null) { - xnames.Add(xcontent.Name); + breadcrumbs.Add(content.Name); + if (content.ParentId < -1) + { + breadcrumbs.Add("Recycle Bin"); + } + + content = _contentService.GetParent(content); } - if (xcontent.ParentId < -1) - { - xnames.Add("Recycle Bin"); - } - - xcontent = _contentService.GetParent(xcontent); + breadcrumbs.Reverse(); + domainDisplay.Other = "/" + string.Join("/", breadcrumbs); } - xnames.Reverse(); - domainModel.Other = "/" + string.Join("/", xnames); + continue; } - } - else - { - // yet there is a race condition here... - var newDomain = new UmbracoDomain(name) { LanguageId = domainModel.Lang, RootContentId = model.NodeId }; - Attempt saveAttempt = _domainService.Save(newDomain); - if (saveAttempt == false) + + // Update or create domain + if (domain != null) + { + domain.LanguageId = language.Id; + } + else + { + domain = new UmbracoDomain(domainName) + { + LanguageId = language.Id, + RootContentId = model.NodeId, + }; + } + + Attempt saveAttempt = _domainService.Save(domain); + if (saveAttempt.Success == false) { HttpContext.SetReasonPhrase(saveAttempt.Result?.Result.ToString()); - return BadRequest("Saving new domain failed"); + return BadRequest("Saving domain failed"); } + + savedDomains.Add(domain); } + + // Sort saved domains + _domainService.Sort(savedDomains); } model.Valid = model.Domains?.All(m => m.Duplicate == false) ?? false; 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 989c051e03..d9c18fac01 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 @@ -88,56 +88,83 @@ Use this directive to render a button with a dropdown of alternative actions. @param {string=} direction Set the direction of the dropdown ("up", "down"). @param {string=} float Set the float of the dropdown. ("left", "right"). **/ - (function () { - 'use strict'; + 'use strict'; - function ButtonGroupDirective() { + function ButtonGroupDirective() { - function link(scope) { + function controller($scope) { + $scope.toggleStyle = null; + $scope.blockElement = false; - scope.dropdown = { - isOpen: false - }; - - scope.toggleDropdown = function() { - scope.dropdown.isOpen = !scope.dropdown.isOpen; - }; - - scope.closeDropdown = function() { - scope.dropdown.isOpen = false; - }; - - scope.executeMenuItem = function(subButton) { - subButton.handler(); - scope.closeDropdown(); - }; + var buttonStyle = $scope.buttonStyle; + if (buttonStyle) { + // Make it possible to pass in multiple styles + if (buttonStyle.startsWith("[") && buttonStyle.endsWith("]")) { + // when using an attr it will always be a string so we need to remove square brackets and turn it into and array + var withoutBrackets = buttonStyle.replace(/[\[\]']+/g, ''); + // split array by , + make sure to catch whitespaces + var array = withoutBrackets.split(/\s?,\s?/g); + Utilities.forEach(array, item => { + if (item === "block") { + $scope.blockElement = true; + } else { + $scope.toggleStyle = ($scope.toggleStyle ? $scope.toggleStyle + " " : "") + "btn-" + item; + } + }); + } else { + if (buttonStyle === "block") { + $scope.blockElement = true; + } else { + $scope.toggleStyle = "btn-" + buttonStyle; + } } - - var directive = { - restrict: 'E', - replace: true, - templateUrl: 'views/components/buttons/umb-button-group.html', - scope: { - defaultButton: "=", - subButtons: "=", - state: "=?", - direction: "@?", - float: "@?", - buttonStyle: "@?", - size: "@?", - icon: "@?", - label: "@?", - labelKey: "@?", - disabled: " .btn-large:last-child { .border-radius(0 0 @borderRadiusLarge @borderRadiusLarge); } + +.btn-group-justified { + display: flex; + + .umb-button { + margin-left: 0; + } + + > * { + flex-grow: 1; + } + + > .dropdown-toggle { + flex-grow: 0; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/forms.less b/src/Umbraco.Web.UI.Client/src/less/forms.less index e0916a070f..7faf746022 100644 --- a/src/Umbraco.Web.UI.Client/src/less/forms.less +++ b/src/Umbraco.Web.UI.Client/src/less/forms.less @@ -577,11 +577,6 @@ div.help { margin-top: 5px; } - -table.domains .help-inline { - color:@red; -} - // INPUT GROUPS // ------------ diff --git a/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html b/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html index 2a4a43769d..0462482112 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html @@ -1,4 +1,4 @@ -
+
+ - - -
- -
- - -
Culture
- - -
- - - -
-
-
{{vm.error.errorMsg}}
-
{{vm.error.data.Message}}
-
-
- -
Domains
- - Valid domain names are: "example.com", "www.example.com", "example.com:8080", or "https://www.example.com/". - Furthermore also one-level paths in domains are supported, eg. "example.com/en" or "/en". - -
- - - - - - - - - - - - - - - -
- Domain - * - - Language - * -
- - - - - Value cannot be empty - - Domain has already been assigned.({{domain.other}}) - - - - - -
-
- - - - - - - -
+ +
+ +
Culture
+ + +
+ +
+
+
{{vm.error.errorMsg}}
+
{{vm.error.data.Message}}
+
- - - + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/content/content.assigndomain.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/content.assigndomain.controller.js index 2cec5fb17a..61d710427e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/content.assigndomain.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/content.assigndomain.controller.js @@ -1,166 +1,178 @@ (function () { - "use strict"; + "use strict"; - function AssignDomainController($scope, localizationService, languageResource, contentResource, navigationService, notificationsService, $location) { - var vm = this; - - vm.closeDialog = closeDialog; - vm.addDomain = addDomain; - vm.addCurrentDomain = addCurrentDomain; - vm.removeDomain = removeDomain; - vm.save = save; - vm.languages = []; - vm.domains = []; - vm.language = null; + function AssignDomainController($scope, localizationService, languageResource, contentResource, navigationService, notificationsService, $location) { + var vm = this; - function activate() { + vm.loading = true; + vm.closeDialog = closeDialog; + vm.removeDomain = removeDomain; + vm.save = save; + vm.languages = []; + vm.domains = []; + vm.language = null; - vm.loading = true; + vm.buttonGroup = { + defaultButton: { + labelKey: 'assignDomain_addNew', + buttonStyle: 'info', + handler: addDomain + }, + subButtons: [{ + labelKey: 'assignDomain_addCurrent', + buttonStyle: 'success', + handler: addCurrentDomain + }] + }; - languageResource.getAll().then(langs => { - vm.languages = langs; + $scope.sortableOptions = { + axis: 'y', + containment: 'parent', + cursor: 'move', + handle: ".handle", + placeholder: 'sortable-placeholder', + forcePlaceholderSize: true, + tolerance: 'pointer' + }; - var defLang = langs.filter(l => { - return l.isDefault; - }); + function activate() { + languageResource.getAll().then(langs => { + vm.languages = langs; - if (defLang.length > 0) { - vm.defaultLanguage = defLang[0]; - } - else { - vm.defaultLanguage = langs[0]; - } - getCultureAndDomains().then(() => { - vm.loading = false; - }); + var defLang = langs.filter(l => { + return l.isDefault; + }); + + if (defLang.length > 0) { + vm.defaultLanguage = defLang[0]; + } + else { + vm.defaultLanguage = langs[0]; + } + + contentResource.getCultureAndDomains($scope.currentNode.id).then(function (data) { + if (data.language !== "undefined") { + var lang = vm.languages.filter(function (l) { + return matchLanguageById(l, data.language); }); - localizationService.localize("assignDomain_inherit").then(function (value) { - vm.inherit = value; - }); - - } - - function getCultureAndDomains () { - return contentResource.getCultureAndDomains($scope.currentNode.id) - .then(function (data) { - - if (data.language !== "undefined") { - var lang = vm.languages.filter(function (l) { - return matchLanguageById(l, data.language); - }); - if (lang.length > 0) { - vm.language = lang[0]; - } - } - - vm.domains = data.domains.map(function (d) { - var matchedLangs = vm.languages.filter(function (lng) { - return matchLanguageById(lng, d.lang); - }); - return { - name: d.name, - lang: matchedLangs.length > 0 ? matchedLangs[0] : vm.defaultLanguage - } - }); - }); - } - - function matchLanguageById(language, id) { - var langId = parseInt(language.id); - var comparisonId = parseInt(id); - return langId === comparisonId; - } - - function closeDialog() { - navigationService.hideDialog(); - } - - function addDomain() { - vm.domains.push({ - name: '', - lang: vm.defaultLanguage - }); - } - - function addCurrentDomain() { - var domainToAdd = $location.host(); - var port = $location.port(); - if (port != 80 && port != 443) { - domainToAdd += ":" + port; + if (lang.length > 0) { + vm.language = lang[0]; + } } - vm.domains.push({ - name: domainToAdd, - lang: vm.defaultLanguage + + vm.domains = data.domains.map(function (d) { + var matchedLangs = vm.languages.filter(function (lng) { + return matchLanguageById(lng, d.lang); + }); + + return { + name: d.name, + lang: matchedLangs.length > 0 ? matchedLangs[0] : vm.defaultLanguage + } }); - } - function removeDomain(index) { - vm.domains.splice(index, 1); - } - - function save() { - - vm.error = null; - vm.submitButtonState = "busy"; - - if (vm.domainForm.$valid) { - - // clear validation messages - vm.domains.forEach(domain => { - domain.duplicate = null; - domain.other = null; - }); - - var data = { - nodeId: $scope.currentNode.id, - domains: vm.domains.map(function (d) { - return { - name: d.name, - lang: d.lang.id - }; - }), - language: vm.language != null ? vm.language.id : 0 - }; - - contentResource.saveLanguageAndDomains(data).then(function (response) { - - // validation is interesting. Check if response is valid - if(response.valid) { - vm.submitButtonState = "success"; - localizationService.localize('speechBubbles_editCulturesAndHostnamesSaved').then(function(value) { - notificationsService.success(value); - }); - closeDialog(); - - // show validation messages for each domain - } else { - response.domains.forEach(validation => { - vm.domains.forEach(domain => { - if(validation.name === domain.name) { - domain.duplicate = validation.duplicate; - domain.other = validation.other; - } - }); - }); - vm.submitButtonState = "error"; - localizationService.localize('speechBubbles_editCulturesAndHostnamesError').then(function(value) { - notificationsService.error(value); - }); - } - - }, function (e) { - vm.error = e; - vm.submitButtonState = "error"; - }); - } - else { - vm.submitButtonState = "error"; - } - } - - activate(); + vm.loading = false; + }); + }); } - angular.module("umbraco").controller("Umbraco.Editors.Content.AssignDomainController", AssignDomainController); + + function matchLanguageById(language, id) { + var langId = parseInt(language.id); + var comparisonId = parseInt(id); + + return langId === comparisonId; + } + + function closeDialog() { + navigationService.hideDialog(); + } + + function addDomain() { + vm.domains.push({ + name: '', + lang: vm.defaultLanguage + }); + } + + function addCurrentDomain() { + var domainToAdd = $location.host(); + var port = $location.port(); + if (port != 80 && port != 443) { + domainToAdd += ":" + port; + } + + vm.domains.push({ + name: domainToAdd, + lang: vm.defaultLanguage + }); + } + + function removeDomain(index) { + vm.domains.splice(index, 1); + } + + function save() { + vm.error = null; + vm.submitButtonState = "busy"; + + if (vm.domainForm.$valid) { + // clear validation messages + vm.domains.forEach(domain => { + domain.duplicate = null; + domain.other = null; + }); + + var data = { + nodeId: $scope.currentNode.id, + domains: vm.domains.map(function (d) { + return { + name: d.name, + lang: d.lang.id + }; + }), + language: vm.language != null ? vm.language.id : 0 + }; + + contentResource.saveLanguageAndDomains(data).then(function (response) { + // validation is interesting. Check if response is valid + if (response.valid) { + vm.submitButtonState = "success"; + + localizationService.localize('speechBubbles_editCulturesAndHostnamesSaved').then(function (value) { + notificationsService.success(value); + }); + + closeDialog(); + } else { + // show validation messages for each domain + response.domains.forEach(validation => { + vm.domains.forEach(domain => { + if (validation.name === domain.name) { + domain.duplicate = validation.duplicate; + domain.other = validation.other; + } + }); + }); + + vm.submitButtonState = "error"; + + localizationService.localize('speechBubbles_editCulturesAndHostnamesError').then(function (value) { + notificationsService.error(value); + }); + } + }, function (e) { + vm.error = e; + vm.submitButtonState = "error"; + }); + } else { + vm.submitButtonState = "error"; + } + } + + activate(); + } + + angular.module("umbraco").controller("Umbraco.Editors.Content.AssignDomainController", AssignDomainController); })(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByUrlTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByUrlTests.cs index e877ace137..f0417b663e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByUrlTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/ContentFinderByUrlTests.cs @@ -129,7 +129,7 @@ public class ContentFinderByUrlTests : PublishedSnapshotServiceTestBase var (finder, frequest) = await GetContentFinder(urlString); - frequest.SetDomain(new DomainAndUri(new Domain(1, "mysite", -1, "en-US", false), new Uri("http://mysite/"))); + frequest.SetDomain(new DomainAndUri(new Domain(1, "mysite", -1, "en-US", false, 0), new Uri("http://mysite/"))); var result = await finder.TryFindContent(frequest); @@ -155,7 +155,7 @@ public class ContentFinderByUrlTests : PublishedSnapshotServiceTestBase var (finder, frequest) = await GetContentFinder(urlString); - frequest.SetDomain(new DomainAndUri(new Domain(1, "mysite/æøå", -1, "en-US", false), new Uri("http://mysite/æøå"))); + frequest.SetDomain(new DomainAndUri(new Domain(1, "mysite/æøå", -1, "en-US", false, 0), new Uri("http://mysite/æøå"))); var result = await finder.TryFindContent(frequest); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/SiteDomainMapperTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/SiteDomainMapperTests.cs index 35bc5c4b62..a0361f0222 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/SiteDomainMapperTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/SiteDomainMapperTests.cs @@ -192,8 +192,8 @@ public class SiteDomainMapperTests var current = new Uri("https://domain1.com/foo/bar"); Domain[] domains = { - new Domain(1, "domain2.com", -1, s_cultureFr, false), - new Domain(1, "domain1.com", -1, s_cultureGb, false), + new Domain(1, "domain2.com", -1, s_cultureFr, false, 0), + new Domain(1, "domain1.com", -1, s_cultureGb, false, 1), }; var domainAndUris = DomainAndUris(current, domains); var output = siteDomainMapper.MapDomain(domainAndUris, current, s_cultureFr, s_cultureFr).Uri.ToString(); @@ -203,8 +203,8 @@ public class SiteDomainMapperTests current = new Uri("https://domain1.com/foo/bar"); domains = new[] { - new Domain(1, "https://domain1.com", -1, s_cultureFr, false), - new Domain(1, "https://domain2.com", -1, s_cultureGb, false), + new Domain(1, "https://domain1.com", -1, s_cultureFr, false, 0), + new Domain(1, "https://domain2.com", -1, s_cultureGb, false, 1), }; domainAndUris = DomainAndUris(current, domains); output = siteDomainMapper.MapDomain(domainAndUris, current, s_cultureFr, s_cultureFr).Uri.ToString(); @@ -213,8 +213,8 @@ public class SiteDomainMapperTests current = new Uri("https://domain1.com/foo/bar"); domains = new[] { - new Domain(1, "https://domain1.com", -1, s_cultureFr, false), - new Domain(1, "https://domain4.com", -1, s_cultureGb, false), + new Domain(1, "https://domain1.com", -1, s_cultureFr, false, 0), + new Domain(1, "https://domain4.com", -1, s_cultureGb, false, 1), }; domainAndUris = DomainAndUris(current, domains); output = siteDomainMapper.MapDomain(domainAndUris, current, s_cultureFr, s_cultureFr).Uri.ToString(); @@ -223,8 +223,8 @@ public class SiteDomainMapperTests current = new Uri("https://domain4.com/foo/bar"); domains = new[] { - new Domain(1, "https://domain1.com", -1, s_cultureFr, false), - new Domain(1, "https://domain4.com", -1, s_cultureGb, false), + new Domain(1, "https://domain1.com", -1, s_cultureFr, false, 0), + new Domain(1, "https://domain4.com", -1, s_cultureGb, false, 1), }; domainAndUris = DomainAndUris(current, domains); output = siteDomainMapper.MapDomain(domainAndUris, current, s_cultureFr, s_cultureFr).Uri.ToString(); @@ -247,8 +247,8 @@ public class SiteDomainMapperTests var output = siteDomainMapper.MapDomain( new[] { - new DomainAndUri(new Domain(1, "domain1.com", -1, s_cultureFr, false), current), - new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false), current), + new DomainAndUri(new Domain(1, "domain1.com", -1, s_cultureFr, false, 0), current), + new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false, 1), current), }, current, s_cultureFr, @@ -261,8 +261,8 @@ public class SiteDomainMapperTests output = siteDomainMapper.MapDomain( new[] { - new DomainAndUri(new Domain(1, "domain1.net", -1, s_cultureFr, false), current), - new DomainAndUri(new Domain(1, "domain2.net", -1, s_cultureGb, false), current), + new DomainAndUri(new Domain(1, "domain1.net", -1, s_cultureFr, false, 0), current), + new DomainAndUri(new Domain(1, "domain2.net", -1, s_cultureGb, false, 1), current), }, current, s_cultureFr, @@ -276,8 +276,8 @@ public class SiteDomainMapperTests output = siteDomainMapper.MapDomain( new[] { - new DomainAndUri(new Domain(1, "domain2.net", -1, s_cultureFr, false), current), - new DomainAndUri(new Domain(1, "domain1.net", -1, s_cultureGb, false), current), + new DomainAndUri(new Domain(1, "domain2.net", -1, s_cultureFr, false, 0), current), + new DomainAndUri(new Domain(1, "domain1.net", -1, s_cultureGb, false, 1), current), }, current, s_cultureFr, @@ -305,38 +305,38 @@ public class SiteDomainMapperTests var output = siteDomainMapper.MapDomains( new[] { - new DomainAndUri(new Domain(1, "domain1.com", -1, s_cultureFr, false), current), // no: current + what MapDomain would pick - new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain3.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain4.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain1.org", -1, s_cultureGb, false), current), // yes: same site (though bogus setup) + new DomainAndUri(new Domain(1, "domain1.com", -1, s_cultureFr, false, 0), current), // no: current + what MapDomain would pick + new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false, 1), current), // no: not same site + new DomainAndUri(new Domain(1, "domain3.com", -1, s_cultureGb, false, 2), current), // no: not same site + new DomainAndUri(new Domain(1, "domain4.com", -1, s_cultureGb, false, 3), current), // no: not same site + new DomainAndUri(new Domain(1, "domain1.org", -1, s_cultureGb, false, 4), current), // yes: same site (though bogus setup) }, current, true, s_cultureFr, s_cultureFr).ToArray(); - Assert.AreEqual(1, output.Count()); - Assert.Contains("http://domain1.org/", output.Select(d => d.Uri.ToString()).ToArray()); + Assert.AreEqual(1, output.Length); + Assert.AreEqual("http://domain1.org/", output[0].Uri.ToString()); // current is a site1 uri, domains does not contain current current = new Uri("http://domain1.com/foo/bar"); output = siteDomainMapper.MapDomains( new[] { - new DomainAndUri(new Domain(1, "domain1.net", -1, s_cultureFr, false), current), // no: what MapDomain would pick - new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain3.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain4.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain1.org", -1, s_cultureGb, false), current), // yes: same site (though bogus setup) + new DomainAndUri(new Domain(1, "domain1.net", -1, s_cultureFr, false, 0), current), // no: what MapDomain would pick + new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false, 1), current), // no: not same site + new DomainAndUri(new Domain(1, "domain3.com", -1, s_cultureGb, false, 2), current), // no: not same site + new DomainAndUri(new Domain(1, "domain4.com", -1, s_cultureGb, false, 3), current), // no: not same site + new DomainAndUri(new Domain(1, "domain1.org", -1, s_cultureGb, false, 4), current), // yes: same site (though bogus setup) }, current, true, s_cultureFr, s_cultureFr).ToArray(); - Assert.AreEqual(1, output.Count()); - Assert.Contains("http://domain1.org/", output.Select(d => d.Uri.ToString()).ToArray()); + Assert.AreEqual(1, output.Length); + Assert.AreEqual("http://domain1.org/", output[0].Uri.ToString()); siteDomainMapper.BindSites("site1", "site3"); siteDomainMapper.BindSites("site2", "site4"); @@ -346,43 +346,43 @@ public class SiteDomainMapperTests output = siteDomainMapper.MapDomains( new[] { - new DomainAndUri(new Domain(1, "domain1.com", -1, s_cultureFr, false), current), // no: current + what MapDomain would pick - new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain3.com", -1, s_cultureGb, false), current), // yes: bound site - new DomainAndUri(new Domain(1, "domain3.org", -1, s_cultureGb, false), current), // yes: bound site - new DomainAndUri(new Domain(1, "domain4.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain1.org", -1, s_cultureGb, false), current), // yes: same site (though bogus setup) + new DomainAndUri(new Domain(1, "domain1.com", -1, s_cultureFr, false, 0), current), // no: current + what MapDomain would pick + new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false, 1), current), // no: not same site + new DomainAndUri(new Domain(1, "domain3.com", -1, s_cultureGb, false, 2), current), // yes: bound site + new DomainAndUri(new Domain(1, "domain3.org", -1, s_cultureGb, false, 3), current), // yes: bound site + new DomainAndUri(new Domain(1, "domain4.com", -1, s_cultureGb, false, 4), current), // no: not same site + new DomainAndUri(new Domain(1, "domain1.org", -1, s_cultureGb, false, 5), current), // yes: same site (though bogus setup) }, current, true, s_cultureFr, s_cultureFr).ToArray(); - Assert.AreEqual(3, output.Count()); - Assert.Contains("http://domain1.org/", output.Select(d => d.Uri.ToString()).ToArray()); - Assert.Contains("http://domain3.com/", output.Select(d => d.Uri.ToString()).ToArray()); - Assert.Contains("http://domain3.org/", output.Select(d => d.Uri.ToString()).ToArray()); + Assert.AreEqual(3, output.Length); + Assert.AreEqual("http://domain3.com/", output[0].Uri.ToString()); + Assert.AreEqual("http://domain3.org/", output[1].Uri.ToString()); + Assert.AreEqual("http://domain1.org/", output[2].Uri.ToString()); // current is a site1 uri, domains does not contain current current = new Uri("http://domain1.com/foo/bar"); output = siteDomainMapper.MapDomains( new[] { - new DomainAndUri(new Domain(1, "domain1.net", -1, s_cultureFr, false), current), // no: what MapDomain would pick - new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain3.com", -1, s_cultureGb, false), current), // yes: bound site - new DomainAndUri(new Domain(1, "domain3.org", -1, s_cultureGb, false), current), // yes: bound site - new DomainAndUri(new Domain(1, "domain4.com", -1, s_cultureGb, false), current), // no: not same site - new DomainAndUri(new Domain(1, "domain1.org", -1, s_cultureGb, false), current), // yes: same site (though bogus setup) + new DomainAndUri(new Domain(1, "domain1.net", -1, s_cultureFr, false, 0), current), // no: what MapDomain would pick + new DomainAndUri(new Domain(1, "domain2.com", -1, s_cultureGb, false, 1), current), // no: not same site + new DomainAndUri(new Domain(1, "domain3.com", -1, s_cultureGb, false, 2), current), // yes: bound site + new DomainAndUri(new Domain(1, "domain3.org", -1, s_cultureGb, false, 3), current), // yes: bound site + new DomainAndUri(new Domain(1, "domain4.com", -1, s_cultureGb, false, 4), current), // no: not same site + new DomainAndUri(new Domain(1, "domain1.org", -1, s_cultureGb, false, 5), current), // yes: same site (though bogus setup) }, current, true, s_cultureFr, s_cultureFr).ToArray(); - Assert.AreEqual(3, output.Count()); - Assert.Contains("http://domain1.org/", output.Select(d => d.Uri.ToString()).ToArray()); - Assert.Contains("http://domain3.com/", output.Select(d => d.Uri.ToString()).ToArray()); - Assert.Contains("http://domain3.org/", output.Select(d => d.Uri.ToString()).ToArray()); + Assert.AreEqual(3, output.Length); + Assert.AreEqual("http://domain3.com/", output[0].Uri.ToString()); + Assert.AreEqual("http://domain3.org/", output[1].Uri.ToString()); + Assert.AreEqual("http://domain1.org/", output[2].Uri.ToString()); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UrlsProviderWithDomainsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UrlsProviderWithDomainsTests.cs index eecbd38963..dffbe656d2 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UrlsProviderWithDomainsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UrlsProviderWithDomainsTests.cs @@ -24,7 +24,7 @@ public class UrlsProviderWithDomainsTests : UrlRoutingTestBase { new UmbracoDomain("domain1.com") { - Id = 1, LanguageId = LangFrId, RootContentId = 1001, LanguageIsoCode = "fr-FR", + Id = 1, LanguageId = LangFrId, RootContentId = 1001, LanguageIsoCode = "fr-FR", SortOrder = 0, }, }); } @@ -38,7 +38,7 @@ public class UrlsProviderWithDomainsTests : UrlRoutingTestBase { new UmbracoDomain("http://domain1.com/foo") { - Id = 1, LanguageId = LangFrId, RootContentId = 1001, LanguageIsoCode = "fr-FR", + Id = 1, LanguageId = LangFrId, RootContentId = 1001, LanguageIsoCode = "fr-FR", SortOrder = 0, }, }); } @@ -52,7 +52,7 @@ public class UrlsProviderWithDomainsTests : UrlRoutingTestBase { new UmbracoDomain("http://domain1.com/") { - Id = 1, LanguageId = LangFrId, RootContentId = 10011, LanguageIsoCode = "fr-FR", + Id = 1, LanguageId = LangFrId, RootContentId = 10011, LanguageIsoCode = "fr-FR", SortOrder = 0, }, }); } @@ -66,27 +66,27 @@ public class UrlsProviderWithDomainsTests : UrlRoutingTestBase { new UmbracoDomain("http://domain1.com/") { - Id = 1, LanguageId = LangEngId, RootContentId = 1001, LanguageIsoCode = "en-US", + Id = 1, LanguageId = LangEngId, RootContentId = 1001, LanguageIsoCode = "en-US", SortOrder = 0, }, new UmbracoDomain("http://domain1.com/en") { - Id = 2, LanguageId = LangEngId, RootContentId = 10011, LanguageIsoCode = "en-US", + Id = 2, LanguageId = LangEngId, RootContentId = 10011, LanguageIsoCode = "en-US", SortOrder = 0, }, new UmbracoDomain("http://domain1.com/fr") { - Id = 3, LanguageId = LangFrId, RootContentId = 10012, LanguageIsoCode = "fr-FR", + Id = 3, LanguageId = LangFrId, RootContentId = 10012, LanguageIsoCode = "fr-FR", SortOrder = 0, }, new UmbracoDomain("http://domain3.com/") { - Id = 4, LanguageId = LangEngId, RootContentId = 1003, LanguageIsoCode = "en-US", + Id = 4, LanguageId = LangEngId, RootContentId = 1003, LanguageIsoCode = "en-US", SortOrder = 0, }, new UmbracoDomain("http://domain3.com/en") { - Id = 5, LanguageId = LangEngId, RootContentId = 10031, LanguageIsoCode = "en-US", + Id = 5, LanguageId = LangEngId, RootContentId = 10031, LanguageIsoCode = "en-US", SortOrder = 0, }, new UmbracoDomain("http://domain3.com/fr") { - Id = 6, LanguageId = LangFrId, RootContentId = 10032, LanguageIsoCode = "fr-FR", + Id = 6, LanguageId = LangFrId, RootContentId = 10032, LanguageIsoCode = "fr-FR", SortOrder = 0, }, }); } @@ -100,35 +100,35 @@ public class UrlsProviderWithDomainsTests : UrlRoutingTestBase { new UmbracoDomain("http://domain1.com/en") { - Id = 1, LanguageId = LangEngId, RootContentId = 10011, LanguageIsoCode = "en-US", + Id = 1, LanguageId = LangEngId, RootContentId = 10011, LanguageIsoCode = "en-US", SortOrder = 0, }, new UmbracoDomain("http://domain1a.com/en") { - Id = 2, LanguageId = LangEngId, RootContentId = 10011, LanguageIsoCode = "en-US", + Id = 2, LanguageId = LangEngId, RootContentId = 10011, LanguageIsoCode = "en-US", SortOrder = 1, }, new UmbracoDomain("http://domain1b.com/en") { - Id = 3, LanguageId = LangEngId, RootContentId = 10011, LanguageIsoCode = "en-US", + Id = 3, LanguageId = LangEngId, RootContentId = 10011, LanguageIsoCode = "en-US", SortOrder = 2, }, new UmbracoDomain("http://domain1.com/fr") { - Id = 4, LanguageId = LangFrId, RootContentId = 10012, LanguageIsoCode = "fr-FR", + Id = 4, LanguageId = LangFrId, RootContentId = 10012, LanguageIsoCode = "fr-FR", SortOrder = 0, }, new UmbracoDomain("http://domain1a.com/fr") { - Id = 5, LanguageId = LangFrId, RootContentId = 10012, LanguageIsoCode = "fr-FR", + Id = 5, LanguageId = LangFrId, RootContentId = 10012, LanguageIsoCode = "fr-FR", SortOrder = 1, }, new UmbracoDomain("http://domain1b.com/fr") { - Id = 6, LanguageId = LangFrId, RootContentId = 10012, LanguageIsoCode = "fr-FR", + Id = 6, LanguageId = LangFrId, RootContentId = 10012, LanguageIsoCode = "fr-FR", SortOrder = 2, }, new UmbracoDomain("http://domain3.com/en") { - Id = 7, LanguageId = LangEngId, RootContentId = 10031, LanguageIsoCode = "en-US", + Id = 7, LanguageId = LangEngId, RootContentId = 10031, LanguageIsoCode = "en-US", SortOrder = 0, }, new UmbracoDomain("http://domain3.com/fr") { - Id = 8, LanguageId = LangFrId, RootContentId = 10032, LanguageIsoCode = "fr-FR", + Id = 8, LanguageId = LangFrId, RootContentId = 10032, LanguageIsoCode = "fr-FR", SortOrder = 0, }, }); } @@ -478,7 +478,7 @@ public class UrlsProviderWithDomainsTests : UrlRoutingTestBase } Assert.AreEqual(2, result.Length); - Assert.AreEqual(result[0].Text, "http://domain1b.com/en/1001-1-1/"); - Assert.AreEqual(result[1].Text, "http://domain1a.com/en/1001-1-1/"); + Assert.AreEqual(result[0].Text, "http://domain1a.com/en/1001-1-1/"); + Assert.AreEqual(result[1].Text, "http://domain1b.com/en/1001-1-1/"); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Web/Routing/PublishedRequestBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Web/Routing/PublishedRequestBuilderTests.cs index 4074f459da..0246d3e38b 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Web/Routing/PublishedRequestBuilderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Web/Routing/PublishedRequestBuilderTests.cs @@ -47,7 +47,7 @@ public class PublishedRequestBuilderTests sut.SetDomain( new DomainAndUri( - new Domain(1, "test", 2, "en-AU", false), new Uri("https://example.com/en-au"))); + new Domain(1, "test", 2, "en-AU", false, 0), new Uri("https://example.com/en-au"))); Assert.IsNotNull(sut.Domain); Assert.IsNotNull(sut.Culture); @@ -64,7 +64,7 @@ public class PublishedRequestBuilderTests var auCulture = "en-AU"; var usCulture = "en-US"; var domain = new DomainAndUri( - new Domain(1, "test", 2, auCulture, false), new Uri("https://example.com/en-au")); + new Domain(1, "test", 2, auCulture, false, 0), new Uri("https://example.com/en-au")); IReadOnlyDictionary headers = new Dictionary { ["Hello"] = "world" }; var redirect = "https://test.com"; From 3ffe7f6d5da213be14269b31c11a541a783d94e1 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Wed, 15 Feb 2023 13:57:58 +0100 Subject: [PATCH 09/32] Fixed pipeline after dotnet sdk 7.0.200 --- build/azure-pipelines.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index da728fffb3..ff52d3c2aa 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -110,7 +110,10 @@ stages: } } - dotnet pack $(solution) --configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)/nupkg + foreach($csproj in Get-ChildItem –Path "src/" -Recurse -Filter *.csproj) + { + dotnet pack $csproj --configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)/nupkg + } - script: | sha="$(Build.SourceVersion)" sha=${sha:0:7} From 5c276c2d108448076d33b7231508d920f319ec09 Mon Sep 17 00:00:00 2001 From: Andreas Zerbst Date: Thu, 16 Feb 2023 09:18:14 +0100 Subject: [PATCH 10/32] Removed path so we can generate templates --- 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 ff52d3c2aa..56e6dccc1b 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -110,7 +110,7 @@ stages: } } - foreach($csproj in Get-ChildItem –Path "src/" -Recurse -Filter *.csproj) + foreach($csproj in Get-ChildItem -Recurse -Filter *.csproj) { dotnet pack $csproj --configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)/nupkg } From 5182f46bdb926ece52ab48cca5aa3f8b22d988a4 Mon Sep 17 00:00:00 2001 From: Mole Date: Thu, 16 Feb 2023 09:39:17 +0100 Subject: [PATCH 11/32] New Backoffice: User Groups Controller (#13811) * Add key to UserGroupDto * Fix renaming table in sqlite The SqliteSyntaxProvider needed an overload to use the correct query * Start work on user group GUID migration * Add key index to UserGroupDto * Copy over data when migrating sqlite * Make sqlite column migration work * Remove PostMigrations These should be replaced with Notification usage * Remove outer scope from Upgrader * Remove unececary null check * Add marker base class for migrations * Enable scopeless migrations * Remove unnecessary state check The final state of the migration is no longer necessarily the final state of the plan. * Extend ExecutedMigrationPlan * Ensure that MigrationPlanExecutor.Execute always returns a result. * Always save final state, regardless of errors * Remove obsolete Execute * Add Umbraco specific migration notification * Publish notification after umbraco migration * Throw the exception that failed a migration after publishing notification * Handle notification publishing in DatabaseBuilder * Fix tests * Remember to complete scope * Clean up MigrationPlanExecutor * Run each package migration in a separate scope * Add PartialMigrationsTests * Add unhappy path test * Fix bug shown by test * Move PartialMigrationsTests into the correct folder * Comment out refresh cache in data type migration Need to add this back again as a notification handler or something. * Start working on a notification test * Allow migrations to request a cache rebuild * Set RebuildCache from MigrateDataTypeConfigurations * Clean MigrationPlanExecutor * Add comment explaining the need to partial migration success * Fix tests * Allow overriding DefinePlan of UmbracoPlan This is needed to test the DatabaseBuilder * Fix notification test * Don't throw exception to be immediately re-caught * Assert that scopes notification are always published * Ensure that scopes are created when requested * Make test classes internal. It doesn't really matter, but this way it doesn't show up in intellisense * Add notification handler for clearing cookies * Add CompatibilitySuppressions * Use unscoped migration for adding GUID to user group * Make sqlite migration work It's really not pretty, square peg, round hole. * Don't re-enable foreign keys This will happen automatically next time a connection is started. * Scope database when using SQLServer * Don't call complete transaction * Tidy up a couple of comment * Only allow scoping the database from UnscopedMigrationBase * Fix comment * Remove remark in UnscopedMigrationBase as it's no longer true * Add keys when creating default user groups * Map database value from DTO to entity * Fix migration Rename also renamed the foreign keys, making it not work * Make migration idempotent * Fix unit test * Update CompatibilitySuppressions.xml * Add GetUserGroupByKey to UserService * Add ByKey endpoint * Add UniqueId to AppendGroupBy Otherwise MSSQL grenades * Ensure that languages are returned by PerformGetByQuery * add POC displaying model * Clean up by key controller * Add GetAllEndpoint * Add delete endpoint * Use GetKey to get GUID from id Instead of pulling up the entire entity. * Add UserGroup2Permission table * Fetch the new permissions when getting user groups * Dont ToString int to parse it to a short I'm pretty sure this is some way old migration type code that doesn't make any sense anymore * Add new relation to GetDeleteClauses * Persist the permissions * Split UserGroupViewModel into multiple models This is to make it possible to make endpoints more rest-ish * Bootstrap create and update endpoints * Make GetAllUserGroupController paged * Add method to create IUserGroup from UserGroupSaveModel * Add sanity check version of endpoint * Fix persisting permissions * Map section aliases to the name the frontend expects This is a temporary fix till we find out how we really want to handle this * Fix up post merge * Make naming more consistent * Implement initial update endpoint * Fix media start node * Clean name for XSS when mapping to IUserGroup * Use a set instead of a list for permission names We don't want dupes * Make permission column nvarchar max * Add UserGroupOperationStatuses * Add IUserGroupAuthorizationService * Add specific user group creation method to user service * Move validating and authorizing into its own methods * Add operation result to action result mapping * Update create controller to use the create method * Fix create end point * Comment out getting current user untill we have auth * Add usergroup service * Obsolete usergroup things from IUserService * Add update to UserGroupService interface * User IUserGroupService in controllers * User async notifications overloads * Move authorize user group creation into its own service * Add AuthorizeUserGroupUpdate method * Make new service implementations internal and sealed * Add update user * Add GetAll to usergroup service * Remove or obsolete usages of GetAllUserGroups * Add usergroup service to DI * Remove usage of GetGroupsByAlias * Remove usages of GetUserGroupByAlias * Remove usage of GetUserGroupById * Add new table when creating a new database * Implement Delete * Add skip and take to getall * Move skip take into the service * Fixup suggestions in user group service * Fixup unit tests * Allow admins to change user groups they're not a part of * Add CompatibilitySuppressions * Update openapi * Uppdate OpenApi.json again * Add missing compatibility suppression * Added missing type info in ProducesResponseTypeAttribute * Added INamedEntityViewModel and added on the relevant view models * Fixed bug, resulting in serialization not being the same as swagger reported. Now all types objects implementing an interface, is serialized with the $type property * updated OpenApi.json * Added missing title in notfound response * Typo * .Result to .GetAwaiter().GetResult() * Update comment to mention it should be implemented on CurrentUserController * Validate that start nodes actually exists * Handle not found consistently * Use iso codes instead of ids * Update OpenAPI * Automatically infer statuscode in problemdetails * Ensure that the language exists * Fix usergroup 2 permission index * Validate that group name and alias is not too long * Only return status from validation We're just returning the same usergroups, and this is less boilerplate code * Handle empty and null group names * Remove group prefix from statuses * Add some basic validation tests * Don't allow updating a usergroup to having a duplicate alias --------- Co-authored-by: Bjarke Berg --- .../Builders/ProblemDetailsBuilder.cs | 8 - .../UserGroups/ByKeyUserGroupController.cs | 39 + .../UserGroups/CreateUserGroupController.cs | 55 ++ .../UserGroups/DeleteUserGroupController.cs | 30 + .../UserGroups/GetAllUserGroupController.cs | 40 + .../UserGroups/UpdateUserGroupController.cs | 51 ++ .../UserGroups/UserGroupsControllerBase.cs | 83 ++ .../UserGroupsBuilderExtensions.cs | 14 + .../Factories/IUserGroupViewModelFactory.cs | 41 + .../Factories/UserGroupViewModelFactory.cs | 226 +++++ .../ManagementApiComposer.cs | 1 + .../Mapping/SectionMapper.cs | 54 ++ src/Umbraco.Cms.Api.Management/OpenApi.json | 818 ++++++++++++++---- .../ViewModels/DataType/DataTypeViewModel.cs | 2 +- .../Dictionary/DictionaryItemViewModel.cs | 2 +- .../ViewModels/Folder/FolderViewModel.cs | 2 +- .../ViewModels/INamedEntityViewModel.cs | 8 + .../RecycleBin/RecycleBinItemViewModel.cs | 2 +- .../ViewModels/Template/TemplateViewModel.cs | 2 +- .../Tree/EntityTreeItemViewModel.cs | 2 +- .../ViewModels/UserGroups/UserGroupBase.cs | 58 ++ .../UserGroups/UserGroupSaveModel.cs | 6 + .../UserGroups/UserGroupUpdateModel.cs | 6 + .../UserGroups/UserGroupViewModel.cs | 10 + .../CompatibilitySuppressions.xml | 14 + .../DependencyInjection/UmbracoBuilder.cs | 2 + .../Handlers/AuditNotificationsHandler.cs | 31 +- .../Models/ContentEditing/UserGroupSave.cs | 10 + .../Models/Mapping/UserMapDefinition.cs | 41 +- .../Models/Membership/IUserGroup.cs | 17 +- .../Models/Membership/UserGroup.cs | 9 + .../Persistence/Constants-DatabaseSchema.cs | 1 + .../Repositories/IEntityRepository.cs | 16 + src/Umbraco.Core/Services/EntityService.cs | 18 + src/Umbraco.Core/Services/IEntityService.cs | 16 + .../IUserGroupAuthorizationService.cs | 24 + .../Services/IUserGroupService.cs | 86 ++ src/Umbraco.Core/Services/IUserService.cs | 6 + .../UserGroupOperationStatus.cs | 22 + .../Services/UserGroupAuthorizationService.cs | 177 ++++ src/Umbraco.Core/Services/UserGroupService.cs | 376 ++++++++ src/Umbraco.Core/Services/UserService.cs | 15 +- .../CompatibilitySuppressions.xml | 7 + .../Install/DatabaseSchemaCreator.cs | 1 + .../Migrations/MigrationBase.cs | 1 + .../Migrations/UnscopedMigrationBase.cs | 2 +- .../Migrations/Upgrade/UmbracoPlan.cs | 1 + .../Upgrade/V_13_0_0/AddGuidsToUserGroups.cs | 2 +- .../V_13_0_0/AddUserGroupPermissionTable.cs | 21 + .../Dtos/UserGroup2PermissionDto.cs | 22 + .../Persistence/Dtos/UserGroupDto.cs | 5 + .../Persistence/Factories/UserGroupFactory.cs | 3 +- .../Implement/EntityRepository.cs | 21 + .../Implement/UserGroupRepository.cs | 131 ++- .../Security/BackOfficeUserStore.cs | 23 +- .../UmbracoJsonTypeInfoResolver.cs | 29 +- .../Providers/UserTelemetryProvider.cs | 18 +- .../CompatibilitySuppressions.xml | 10 + .../Controllers/ContentController.cs | 24 +- .../UserGroupEditorAuthorizationHelper.cs | 1 + .../Controllers/UserGroupsController.cs | 1 + .../Filters/UserGroupValidateAttribute.cs | 11 +- .../UserGroupServiceValidationTests.cs | 151 ++++ .../Controllers/ContentControllerTests.cs | 3 +- 64 files changed, 2637 insertions(+), 292 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/ByKeyUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/CreateUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/DeleteUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/GetAllUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UpdateUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UserGroupsControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/IUserGroupViewModelFactory.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/UserGroupViewModelFactory.cs create mode 100644 src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/INamedEntityViewModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupSaveModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupUpdateModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupViewModel.cs create mode 100644 src/Umbraco.Core/Services/IUserGroupAuthorizationService.cs create mode 100644 src/Umbraco.Core/Services/IUserGroupService.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs create mode 100644 src/Umbraco.Core/Services/UserGroupAuthorizationService.cs create mode 100644 src/Umbraco.Core/Services/UserGroupService.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddUserGroupPermissionTable.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Dtos/UserGroup2PermissionDto.cs create mode 100644 src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserGroupServiceValidationTests.cs diff --git a/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs b/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs index bc49851911..d3897d5377 100644 --- a/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs +++ b/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs @@ -7,7 +7,6 @@ public class ProblemDetailsBuilder { private string? _title; private string? _detail; - private int _status = StatusCodes.Status400BadRequest; private string? _type; public ProblemDetailsBuilder WithTitle(string title) @@ -22,12 +21,6 @@ public class ProblemDetailsBuilder return this; } - public ProblemDetailsBuilder WithStatus(int status) - { - _status = status; - return this; - } - public ProblemDetailsBuilder WithType(string type) { _type = type; @@ -39,7 +32,6 @@ public class ProblemDetailsBuilder { Title = _title, Detail = _detail, - Status = _status, Type = _type ?? "Error", }; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/ByKeyUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/ByKeyUserGroupController.cs new file mode 100644 index 0000000000..c335926e74 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/ByKeyUserGroupController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.UserGroups; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.UserGroups; + + +public class ByKeyUserGroupController : UserGroupsControllerBase +{ + private readonly IUserGroupService _userGroupService; + private readonly IUserGroupViewModelFactory _userGroupViewModelFactory; + + public ByKeyUserGroupController( + IUserGroupService userGroupService, + IUserGroupViewModelFactory userGroupViewModelFactory) + { + _userGroupService = userGroupService; + _userGroupViewModelFactory = userGroupViewModelFactory; + } + + [HttpGet("{key:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(UserGroupViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ByKey(Guid key) + { + IUserGroup? userGroup = await _userGroupService.GetAsync(key); + + if (userGroup is null) + { + return NotFound(); + } + + return await _userGroupViewModelFactory.CreateAsync(userGroup); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/CreateUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/CreateUserGroupController.cs new file mode 100644 index 0000000000..755cb1dd4b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/CreateUserGroupController.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.UserGroups; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.UserGroups; + +public class CreateUserGroupController : UserGroupsControllerBase +{ + private readonly IUserGroupService _userGroupService; + private readonly IUserGroupViewModelFactory _userGroupViewModelFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public CreateUserGroupController( + IUserGroupService userGroupService, + IUserGroupViewModelFactory userGroupViewModelFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _userGroupService = userGroupService; + _userGroupViewModelFactory = userGroupViewModelFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPost] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task Create(UserGroupSaveModel userGroupSaveModel) + { + // FIXME: Comment this in when auth is in place and we can get a currently logged in user. + // IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + // if (currentUser is null) + // { + // return UserGroupOperationStatusResult(UserGroupOperationStatus.MissingUser); + // } + + Attempt userGroupCreationAttempt = await _userGroupViewModelFactory.CreateAsync(userGroupSaveModel); + if (userGroupCreationAttempt.Success is false) + { + return UserGroupOperationStatusResult(userGroupCreationAttempt.Status); + } + + IUserGroup group = userGroupCreationAttempt.Result; + + Attempt result = await _userGroupService.CreateAsync(group, /*currentUser.Id*/ -1); + return result.Success + ? CreatedAtAction(controller => nameof(controller.ByKey), group.Key) + : UserGroupOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/DeleteUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/DeleteUserGroupController.cs new file mode 100644 index 0000000000..ad26e28cc2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/DeleteUserGroupController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.UserGroups; + +public class DeleteUserGroupController : UserGroupsControllerBase +{ + private readonly IUserGroupService _userGroupService; + + public DeleteUserGroupController(IUserGroupService userGroupService) + { + _userGroupService = userGroupService; + } + + [HttpDelete("{key:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(Guid key) + { + Attempt result = await _userGroupService.DeleteAsync(key); + + return result.Success + ? Ok() + : UserGroupOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/GetAllUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/GetAllUserGroupController.cs new file mode 100644 index 0000000000..86b61019fc --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/GetAllUserGroupController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.UserGroups; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Controllers.UserGroups; + +public class GetAllUserGroupController : UserGroupsControllerBase +{ + private readonly IUserGroupService _userGroupService; + private readonly IUserGroupViewModelFactory _userViewModelFactory; + + public GetAllUserGroupController( + IUserGroupService userGroupService, + IUserGroupViewModelFactory userViewModelFactory) + { + _userGroupService = userGroupService; + _userViewModelFactory = userViewModelFactory; + } + + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> GetAll(int skip = 0, int take = 100) + { + // FIXME: In the old controller this endpoint had a switch "onlyCurrentUserGroup" + // If this was enabled we'd only return the groups the current user was in + // and even if it was set to false we'd still remove the admin group. + // We still need to have this functionality, however, it does not belong here. + // Instead we should implement this functionality on the CurrentUserController + PagedModel userGroups = await _userGroupService.GetAllAsync(skip, take); + + var viewModels = (await _userViewModelFactory.CreateMultipleAsync(userGroups.Items)).ToList(); + return new PagedViewModel { Total = userGroups.Total, Items = viewModels }; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UpdateUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UpdateUserGroupController.cs new file mode 100644 index 0000000000..dc3f5bac70 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UpdateUserGroupController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.UserGroups; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.UserGroups; + +public class UpdateUserGroupController : UserGroupsControllerBase +{ + private readonly IUserGroupService _userGroupService; + private readonly IUserGroupViewModelFactory _userGroupViewModelFactory; + + public UpdateUserGroupController( + IUserGroupService userGroupService, + IUserGroupViewModelFactory userGroupViewModelFactory) + { + _userGroupService = userGroupService; + _userGroupViewModelFactory = userGroupViewModelFactory; + } + + [HttpPut("{key:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update(Guid key, UserGroupUpdateModel dataTypeUpdateModel) + { + IUserGroup? existingUserGroup = await _userGroupService.GetAsync(key); + + if (existingUserGroup is null) + { + return UserGroupOperationStatusResult(UserGroupOperationStatus.NotFound); + } + + Attempt userGroupUpdateAttempt = await _userGroupViewModelFactory.UpdateAsync(existingUserGroup, dataTypeUpdateModel); + if (userGroupUpdateAttempt.Success is false) + { + return UserGroupOperationStatusResult(userGroupUpdateAttempt.Status); + } + + IUserGroup userGroup = userGroupUpdateAttempt.Result; + Attempt result = await _userGroupService.UpdateAsync(userGroup, -1); + + return result.Success + ? Ok() + : UserGroupOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UserGroupsControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UserGroupsControllerBase.cs new file mode 100644 index 0000000000..a24edfd973 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UserGroupsControllerBase.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.UserGroups; + +// TODO: This needs to be an authorized controller. + +[ApiController] +[VersionedApiBackOfficeRoute("user-groups")] +[ApiExplorerSettings(GroupName = "User Groups")] +[ApiVersion("1.0")] +public class UserGroupsControllerBase : ManagementApiControllerBase +{ + protected IActionResult UserGroupOperationStatusResult(UserGroupOperationStatus status) => + status switch + { + UserGroupOperationStatus.NotFound => NotFound("The user group could not be found"), + UserGroupOperationStatus.AlreadyExists => Conflict(new ProblemDetailsBuilder() + .WithTitle("User group already exists") + .WithDetail("The user group exists already.") + .Build()), + UserGroupOperationStatus.DuplicateAlias => Conflict(new ProblemDetailsBuilder() + .WithTitle("Duplicate alias") + .WithDetail("A user group already exists with the attempted alias.") + .Build()), + UserGroupOperationStatus.MissingUser => Unauthorized(new ProblemDetailsBuilder() + .WithTitle("Missing user") + .WithDetail("A performing user was not found when attempting to create the user group.") + .Build()), + UserGroupOperationStatus.IsSystemUserGroup => BadRequest(new ProblemDetailsBuilder() + .WithTitle("System user group") + .WithDetail("The operation is not allowed on a system user group.") + .Build()), + UserGroupOperationStatus.UnauthorizedMissingUserSection => Unauthorized(new ProblemDetailsBuilder() + .WithTitle("Unauthorized") + .WithDetail("The performing user does not have access to the required section") + .Build()), + UserGroupOperationStatus.UnauthorizedMissingSections => Unauthorized(new ProblemDetailsBuilder() + .WithTitle("Unauthorized section") + .WithDetail("The specified allowed section contained a section the performing user doesn't have access to.") + .Build()), + UserGroupOperationStatus.UnauthorizedStartNodes => Unauthorized(new ProblemDetailsBuilder() + .WithTitle("Unauthorized start node") + .WithDetail("The specified start nodes contained a start node the performing user doesn't have access to.") + .Build()), + UserGroupOperationStatus.UnauthorizedMissingUserGroup => Unauthorized(new ProblemDetailsBuilder() + .WithTitle("User not in user group") + .WithDetail("The current user is not in the user group") + .Build()), + UserGroupOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cancelled by notification") + .WithDetail("A notification handler prevented the language operation.") + .Build()), + UserGroupOperationStatus.DocumentStartNodeKeyNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("Document start node key not found") + .WithDetail("The assigned document start node does not exists.") + .Build()), + UserGroupOperationStatus.MediaStartNodeKeyNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("Media start node key not found") + .WithDetail("The assigned media start node does not exists.") + .Build()), + UserGroupOperationStatus.LanguageNotFound => NotFound(new ProblemDetailsBuilder() + .WithTitle("Language not found") + .WithDetail("The specified language cannot be found.") + .Build()), + UserGroupOperationStatus.NameTooLong => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Name too long") + .WithDetail("User Group name is too long.") + .Build()), + UserGroupOperationStatus.AliasTooLong => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Alias too long") + .WithDetail("The user group alias is too long.") + .Build()), + UserGroupOperationStatus.MissingName => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Missing user group name.") + .WithDetail("The user group name is required, and cannot be an empty string.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown user group operation status."), + }; +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs new file mode 100644 index 0000000000..0cb4cf7595 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Core.DependencyInjection; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +internal static class UserGroupsBuilderExtensions +{ + internal static IUmbracoBuilder AddUserGroups(this IUmbracoBuilder builder) + { + builder.Services.AddTransient(); + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IUserGroupViewModelFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IUserGroupViewModelFactory.cs new file mode 100644 index 0000000000..b79fd7b9b3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IUserGroupViewModelFactory.cs @@ -0,0 +1,41 @@ +using Umbraco.Cms.Api.Management.ViewModels.UserGroups; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Factories; + +/// +/// A factory for creating +/// +public interface IUserGroupViewModelFactory +{ + /// + /// Creates a based on a + /// + /// + /// + Task CreateAsync(IUserGroup userGroup); + + /// + /// Creates multiple base on multiple + /// + /// + /// + Task> CreateMultipleAsync(IEnumerable userGroups); + + /// + /// Creates an based on a + /// + /// + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> CreateAsync(UserGroupSaveModel saveModel); + + /// + /// Converts the values of an update model to fit with the existing backoffice implementations, and maps it to an existing user group. + /// + /// Existing user group to map to. + /// Update model containing the new values. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> UpdateAsync(IUserGroup current, UserGroupUpdateModel update); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/UserGroupViewModelFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserGroupViewModelFactory.cs new file mode 100644 index 0000000000..e732497f3a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/UserGroupViewModelFactory.cs @@ -0,0 +1,226 @@ +using Umbraco.Cms.Api.Management.Mapping; +using Umbraco.Cms.Api.Management.ViewModels.UserGroups; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Factories; + +/// +public class UserGroupViewModelFactory : IUserGroupViewModelFactory +{ + private readonly IEntityService _entityService; + private readonly IShortStringHelper _shortStringHelper; + private readonly ILanguageService _languageService; + + public UserGroupViewModelFactory( + IEntityService entityService, + IShortStringHelper shortStringHelper, + ILanguageService languageService) + { + _entityService = entityService; + _shortStringHelper = shortStringHelper; + _languageService = languageService; + } + + /// + public async Task CreateAsync(IUserGroup userGroup) + { + Guid? contentStartNodeKey = GetKeyFromId(userGroup.StartContentId, UmbracoObjectTypes.Document); + Guid? mediaStartNodeKey = GetKeyFromId(userGroup.StartMediaId, UmbracoObjectTypes.Media); + Attempt, UserGroupOperationStatus> languageIsoCodesMappingAttempt = await MapLanguageIdsToIsoCodeAsync(userGroup.AllowedLanguages); + + // We've gotten this data from the database, so the mapping should not fail + if (languageIsoCodesMappingAttempt.Success is false) + { + throw new InvalidOperationException($"Unknown language ID in User Group: {userGroup.Name}"); + } + + return new UserGroupViewModel + { + Name = userGroup.Name ?? string.Empty, + Key = userGroup.Key, + DocumentStartNodeKey = contentStartNodeKey, + MediaStartNodeKey = mediaStartNodeKey, + Icon = userGroup.Icon, + Languages = languageIsoCodesMappingAttempt.Result, + HasAccessToAllLanguages = userGroup.HasAccessToAllLanguages, + Permissions = userGroup.PermissionNames, + Sections = userGroup.AllowedSections.Select(SectionMapper.GetName), + }; + } + + /// + public async Task> CreateMultipleAsync(IEnumerable userGroups) + { + var userGroupViewModels = new List(); + foreach (IUserGroup userGroup in userGroups) + { + userGroupViewModels.Add(await CreateAsync(userGroup)); + } + + return userGroupViewModels; + } + + /// + public async Task> CreateAsync(UserGroupSaveModel saveModel) + { + var cleanedName = saveModel.Name.CleanForXss('[', ']', '(', ')', ':'); + + var group = new UserGroup(_shortStringHelper) + { + Name = cleanedName, + Alias = cleanedName, + Icon = saveModel.Icon, + HasAccessToAllLanguages = saveModel.HasAccessToAllLanguages, + PermissionNames = saveModel.Permissions, + }; + + Attempt assignmentAttempt = AssignStartNodesToUserGroup(saveModel, group); + if (assignmentAttempt.Success is false) + { + return Attempt.FailWithStatus(assignmentAttempt.Result, group); + } + + foreach (var section in saveModel.Sections) + { + group.AddAllowedSection(SectionMapper.GetAlias(section)); + } + + Attempt, UserGroupOperationStatus> languageIsoCodeMappingAttempt = await MapLanguageIsoCodesToIdsAsync(saveModel.Languages); + if (languageIsoCodeMappingAttempt.Success is false) + { + return Attempt.FailWithStatus(languageIsoCodeMappingAttempt.Status, group); + } + + foreach (var languageId in languageIsoCodeMappingAttempt.Result) + { + group.AddAllowedLanguage(languageId); + } + + return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, group); + } + + /// + public async Task> UpdateAsync(IUserGroup current, UserGroupUpdateModel update) + { + Attempt assignmentAttempt = AssignStartNodesToUserGroup(update, current); + if (assignmentAttempt.Success is false) + { + return Attempt.FailWithStatus(assignmentAttempt.Result, current); + } + + current.ClearAllowedLanguages(); + Attempt, UserGroupOperationStatus> languageIdsMappingAttempt = await MapLanguageIsoCodesToIdsAsync(update.Languages); + if (languageIdsMappingAttempt.Success is false) + { + return Attempt.FailWithStatus(languageIdsMappingAttempt.Status, current); + } + + foreach (var languageId in languageIdsMappingAttempt.Result) + { + current.AddAllowedLanguage(languageId); + } + + current.ClearAllowedSections(); + foreach (var sectionName in update.Sections) + { + current.AddAllowedSection(SectionMapper.GetAlias(sectionName)); + } + + current.Name = update.Name.CleanForXss('[', ']', '(', ')', ':'); + current.Icon = update.Icon; + current.HasAccessToAllLanguages = update.HasAccessToAllLanguages; + current.PermissionNames = update.Permissions; + + + return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, current); + } + + private async Task, UserGroupOperationStatus>> MapLanguageIdsToIsoCodeAsync(IEnumerable ids) + { + IEnumerable languages = await _languageService.GetAllAsync(); + string[] isoCodes = languages + .Where(x => ids.Contains(x.Id)) + .Select(x => x.IsoCode) + .ToArray(); + + return isoCodes.Length == ids.Count() + ? Attempt.SucceedWithStatus, UserGroupOperationStatus>(UserGroupOperationStatus.Success, isoCodes) + : Attempt.FailWithStatus, UserGroupOperationStatus>(UserGroupOperationStatus.LanguageNotFound, isoCodes); + } + + private async Task, UserGroupOperationStatus>> MapLanguageIsoCodesToIdsAsync(IEnumerable isoCodes) + { + IEnumerable languages = await _languageService.GetAllAsync(); + int[] languageIds = languages + .Where(x => isoCodes.Contains(x.IsoCode)) + .Select(x => x.Id) + .ToArray(); + + return languageIds.Length == isoCodes.Count() + ? Attempt.SucceedWithStatus, UserGroupOperationStatus>(UserGroupOperationStatus.Success, languageIds) + : Attempt.FailWithStatus, UserGroupOperationStatus>(UserGroupOperationStatus.LanguageNotFound, languageIds); + } + + private Attempt AssignStartNodesToUserGroup(UserGroupBase source, IUserGroup target) + { + if (source.DocumentStartNodeKey is not null) + { + var contentId = GetIdFromKey(source.DocumentStartNodeKey.Value, UmbracoObjectTypes.Document); + + if (contentId is null) + { + return Attempt.Fail(UserGroupOperationStatus.DocumentStartNodeKeyNotFound); + } + + target.StartContentId = contentId; + } + + if (source.MediaStartNodeKey is not null) + { + var mediaId = GetIdFromKey(source.MediaStartNodeKey.Value, UmbracoObjectTypes.Media); + + if (mediaId is null) + { + return Attempt.Fail(UserGroupOperationStatus.MediaStartNodeKeyNotFound); + } + + target.StartMediaId = mediaId; + } + + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + private Guid? GetKeyFromId(int? id, UmbracoObjectTypes objectType) + { + if (id is null) + { + return null; + } + + Attempt attempt = _entityService.GetKey(id.Value, objectType); + if (attempt.Success is false) + { + return null; + } + + return attempt.Result; + } + + private int? GetIdFromKey(Guid key, UmbracoObjectTypes objectType) + { + Attempt attempt = _entityService.GetId(key, objectType); + + if (attempt.Success is false) + { + return null; + } + + return attempt.Result; + } +} diff --git a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs index 16eeef87b3..9b539cc742 100644 --- a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs @@ -35,6 +35,7 @@ public class ManagementApiComposer : IComposer .AddDataTypes() .AddTemplates() .AddLogViewer() + .AddUserGroups() .AddBackOfficeAuthentication() .AddApiVersioning() .AddSwaggerGen(); diff --git a/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs b/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs new file mode 100644 index 0000000000..41db2123e3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs @@ -0,0 +1,54 @@ +namespace Umbraco.Cms.Api.Management.Mapping; + +/// +/// Maps from the old section aliases to the new section names. +/// This is static since it's expected to be removed, so might as well make the clean up work as easy as possible. +/// FIXME: This is a temporary thing until permissions is fleshed out and section is either migrated to some form of permission +/// +public static class SectionMapper +{ + private static readonly List _sectionMappings = new() + { + new SectionMapping { Alias = "content", Name = "Umb.Section.Content" }, + new SectionMapping { Alias = "media", Name = "Umb.Section.Media" }, + new SectionMapping { Alias = "member", Name = "Umb.Section.Members" }, + new SectionMapping { Alias = "settings", Name = "Umb.Section.Settings" }, + new SectionMapping { Alias = "packages", Name = "Umb.Section.Packages" }, + new SectionMapping { Alias = "translation", Name = "Umb.Section.Translation" }, + new SectionMapping { Alias = "users", Name = "Umb.Section.Users" }, + new SectionMapping { Alias = "forms", Name = "Umb.Section.Forms" }, + }; + + public static string GetName(string alias) + { + SectionMapping? mapping = _sectionMappings.FirstOrDefault(x => x.Alias == alias); + + if (mapping is not null) + { + return mapping.Name; + } + + // If we can't find it we just fall back to the alias + return alias; + } + + public static string GetAlias(string name) + { + SectionMapping? mapping = _sectionMappings.FirstOrDefault(x => x.Name == name); + + if (mapping is not null) + { + return mapping.Alias; + } + + // If we can't find it we just fall back to the name + return name; + } + + private class SectionMapping + { + public required string Alias { get; init; } + + public required string Name { get; init; } + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 328914ed1f..4844299fa3 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -702,9 +702,6 @@ } } }, - "404": { - "description": "Not Found" - }, "400": { "description": "Bad Request", "content": { @@ -715,6 +712,9 @@ } } }, + "404": { + "description": "Not Found" + }, "409": { "description": "Conflict", "content": { @@ -998,7 +998,9 @@ ], "operationId": "PostDictionaryUpload", "requestBody": { - "content": { } + "content": { + + } }, "responses": { "200": { @@ -1418,9 +1420,6 @@ } ], "responses": { - "401": { - "description": "Unauthorized" - }, "200": { "description": "Success", "content": { @@ -1430,6 +1429,9 @@ } } } + }, + "401": { + "description": "Unauthorized" } } } @@ -1461,9 +1463,6 @@ } ], "responses": { - "401": { - "description": "Unauthorized" - }, "200": { "description": "Success", "content": { @@ -1473,6 +1472,9 @@ } } } + }, + "401": { + "description": "Unauthorized" } } } @@ -1707,9 +1709,6 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success", "content": { @@ -1723,6 +1722,9 @@ } } } + }, + "404": { + "description": "Not Found" } } } @@ -1744,9 +1746,6 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success", "content": { @@ -1760,6 +1759,9 @@ } } } + }, + "404": { + "description": "Not Found" } } } @@ -1784,16 +1786,6 @@ } }, "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -1807,6 +1799,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -1858,16 +1860,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -1877,6 +1869,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -1936,16 +1938,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -1959,6 +1951,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -1980,16 +1982,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -1999,6 +1991,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -2010,6 +2012,20 @@ ], "operationId": "GetInstallSettings", "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/InstallSettingsModel" + } + ] + } + } + } + }, "400": { "description": "Bad Request", "content": { @@ -2029,20 +2045,6 @@ } } } - }, - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/InstallSettingsModel" - } - ] - } - } - } } } } @@ -2067,6 +2069,9 @@ } }, "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2086,9 +2091,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -2113,6 +2115,9 @@ } }, "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2122,9 +2127,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -2187,19 +2189,6 @@ } }, "responses": { - "404": { - "description": "Not Found" - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "201": { "description": "Created", "headers": { @@ -2212,6 +2201,19 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, + "404": { + "description": "Not Found" } } } @@ -2233,9 +2235,6 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success", "content": { @@ -2249,6 +2248,9 @@ } } } + }, + "404": { + "description": "Not Found" } } }, @@ -2268,6 +2270,9 @@ } ], "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2287,9 +2292,6 @@ } } } - }, - "200": { - "description": "Success" } } }, @@ -2322,8 +2324,8 @@ } }, "responses": { - "404": { - "description": "Not Found" + "200": { + "description": "Success" }, "400": { "description": "Bad Request", @@ -2335,8 +2337,8 @@ } } }, - "200": { - "description": "Success" + "404": { + "description": "Not Found" } } } @@ -2406,6 +2408,9 @@ } ], "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2415,9 +2420,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -2545,16 +2547,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -2564,6 +2556,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -2626,16 +2628,6 @@ } }, "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "201": { "description": "Created", "headers": { @@ -2648,6 +2640,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -2669,9 +2671,6 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success", "content": { @@ -2685,6 +2684,9 @@ } } } + }, + "404": { + "description": "Not Found" } } }, @@ -2704,11 +2706,11 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success" + }, + "404": { + "description": "Not Found" } } } @@ -2738,6 +2740,9 @@ } ], "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2747,9 +2752,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -2936,9 +2938,6 @@ } ], "responses": { - "401": { - "description": "Unauthorized" - }, "200": { "description": "Success", "content": { @@ -2948,6 +2947,9 @@ } } } + }, + "401": { + "description": "Unauthorized" } } } @@ -2979,9 +2981,6 @@ } ], "responses": { - "401": { - "description": "Unauthorized" - }, "200": { "description": "Success", "content": { @@ -2991,6 +2990,9 @@ } } } + }, + "401": { + "description": "Unauthorized" } } } @@ -3652,16 +3654,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -3671,6 +3663,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -4222,16 +4224,6 @@ ], "operationId": "GetServerStatus", "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -4245,6 +4237,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -4256,16 +4258,6 @@ ], "operationId": "GetServerVersion", "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -4279,6 +4271,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -4615,6 +4617,9 @@ } }, "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -4624,9 +4629,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -5262,6 +5264,191 @@ } } } + }, + "/umbraco/management/api/v1/user-groups": { + "post": { + "tags": [ + "User Groups" + ], + "operationId": "PostUserGroups", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupSaveModel" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + } + } + }, + "get": { + "tags": [ + "User Groups" + ], + "operationId": "GetUserGroups", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedUserGroupModel" + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/user-groups/{key}": { + "get": { + "tags": [ + "User Groups" + ], + "operationId": "GetUserGroupsByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found" + } + } + }, + "delete": { + "tags": [ + "User Groups" + ], + "operationId": "DeleteUserGroupsByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "User Groups" + ], + "operationId": "PutUserGroupsByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupUpdateModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Not Found" + } + } + } } }, "components": { @@ -5279,6 +5466,9 @@ "additionalProperties": false }, "ContentTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5286,6 +5476,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "noAccess": { "type": "boolean" }, @@ -5293,7 +5486,14 @@ "type": "boolean" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "ContentTreeItemViewModel": "#/components/schemas/ContentTreeItemModel", + "DocumentTreeItemViewModel": "#/components/schemas/DocumentTreeItemModel" + } + } }, "CultureModel": { "type": "object", @@ -5335,6 +5535,9 @@ "additionalProperties": false }, "DataTypeModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5342,6 +5545,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" @@ -5352,7 +5558,13 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DataTypeViewModel": "#/components/schemas/DataTypeModel" + } + } }, "DataTypeModelBaseModel": { "type": "object", @@ -5560,6 +5772,9 @@ "additionalProperties": false }, "DictionaryItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5567,12 +5782,21 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DictionaryItemViewModel": "#/components/schemas/DictionaryItemModel" + } + } }, "DictionaryItemModelBaseModel": { "type": "object", @@ -5698,6 +5922,9 @@ "format": "int32" }, "DocumentBlueprintTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5705,6 +5932,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "documentTypeKey": { "type": "string", "format": "uuid" @@ -5717,9 +5947,18 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentBlueprintTreeItemViewModel": "#/components/schemas/DocumentBlueprintTreeItemModel" + } + } }, "DocumentTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5727,6 +5966,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "isProtected": { "type": "boolean" }, @@ -5737,9 +5979,18 @@ "type": "boolean" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTreeItemViewModel": "#/components/schemas/DocumentTreeItemModel" + } + } }, "DocumentTypeTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5747,13 +5998,25 @@ } ], "properties": { + "$type": { + "type": "string" + }, "isElement": { "type": "boolean" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTypeTreeItemViewModel": "#/components/schemas/DocumentTypeTreeItemModel" + } + } }, "EntityTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5761,6 +6024,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" @@ -5774,7 +6040,18 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "EntityTreeItemViewModel": "#/components/schemas/EntityTreeItemModel", + "ContentTreeItemViewModel": "#/components/schemas/ContentTreeItemModel", + "DocumentBlueprintTreeItemViewModel": "#/components/schemas/DocumentBlueprintTreeItemModel", + "DocumentTreeItemViewModel": "#/components/schemas/DocumentTreeItemModel", + "DocumentTypeTreeItemViewModel": "#/components/schemas/DocumentTypeTreeItemModel", + "FolderTreeItemViewModel": "#/components/schemas/FolderTreeItemModel" + } + } }, "FieldModel": { "type": "object", @@ -5825,6 +6102,9 @@ "additionalProperties": false }, "FolderModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5832,6 +6112,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" @@ -5842,7 +6125,13 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "FolderViewModel": "#/components/schemas/FolderModel" + } + } }, "FolderModelBaseModel": { "type": "object", @@ -5854,6 +6143,9 @@ "additionalProperties": false }, "FolderTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5861,11 +6153,21 @@ } ], "properties": { + "$type": { + "type": "string" + }, "isFolder": { "type": "boolean" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "FolderTreeItemViewModel": "#/components/schemas/FolderTreeItemModel", + "DocumentTypeTreeItemViewModel": "#/components/schemas/DocumentTypeTreeItemModel" + } + } }, "FolderUpdateModel": { "type": "object", @@ -6099,7 +6401,9 @@ }, "providerProperties": { "type": "object", - "additionalProperties": { }, + "additionalProperties": { + + }, "nullable": true } }, @@ -6979,6 +7283,30 @@ }, "additionalProperties": false }, + "PagedUserGroupModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupModel" + } + ] + } + } + }, + "additionalProperties": false + }, "ProblemDetailsModel": { "type": "object", "properties": { @@ -7004,7 +7332,9 @@ "nullable": true } }, - "additionalProperties": { } + "additionalProperties": { + + } }, "ProfilingStatusModel": { "type": "object", @@ -7016,8 +7346,14 @@ "additionalProperties": false }, "RecycleBinItemModel": { + "required": [ + "$type" + ], "type": "object", "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" @@ -7043,7 +7379,13 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "RecycleBinItemViewModel": "#/components/schemas/RecycleBinItemModel" + } + } }, "RedirectStatusModel": { "enum": [ @@ -7271,6 +7613,9 @@ "additionalProperties": false }, "TemplateModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -7278,12 +7623,21 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "TemplateViewModel": "#/components/schemas/TemplateModel" + } + } }, "TemplateModelBaseModel": { "type": "object", @@ -7532,6 +7886,96 @@ }, "additionalProperties": false }, + "UserGroupBaseModel": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "string", + "nullable": true + }, + "sections": { + "type": "array", + "items": { + "type": "string" + } + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + }, + "hasAccessToAllLanguages": { + "type": "boolean" + }, + "documentStartNodeKey": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "mediaStartNodeKey": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "permissions": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "UserGroupModel": { + "required": [ + "$type" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserGroupBaseModel" + } + ], + "properties": { + "$type": { + "type": "string" + }, + "key": { + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "UserGroupViewModel": "#/components/schemas/UserGroupModel" + } + } + }, + "UserGroupSaveModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserGroupBaseModel" + } + ], + "additionalProperties": false + }, + "UserGroupUpdateModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserGroupBaseModel" + } + ], + "additionalProperties": false + }, "UserInstallModel": { "required": [ "email", @@ -7603,7 +8047,9 @@ "authorizationCode": { "authorizationUrl": "/umbraco/management/api/v1.0/security/back-office/authorize", "tokenUrl": "/umbraco/management/api/v1.0/security/back-office/token", - "scopes": { } + "scopes": { + + } } } } @@ -7611,7 +8057,9 @@ }, "security": [ { - "OAuth": [ ] + "OAuth": [ + + ] } ] } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeViewModel.cs index 77a0a8df25..25d094df36 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeViewModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.DataType; -public class DataTypeViewModel : DataTypeModelBase +public class DataTypeViewModel : DataTypeModelBase, INamedEntityViewModel { public Guid Key { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemViewModel.cs index a7ee7e5b32..0b795c1fc2 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemViewModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary; -public class DictionaryItemViewModel : DictionaryItemModelBase +public class DictionaryItemViewModel : DictionaryItemModelBase, INamedEntityViewModel { public Guid Key { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderViewModel.cs index f76bf1493f..777ca988fd 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderViewModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Folder; -public class FolderViewModel : FolderModelBase +public class FolderViewModel : FolderModelBase, INamedEntityViewModel { public Guid Key { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/INamedEntityViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/INamedEntityViewModel.cs new file mode 100644 index 0000000000..1da1a24c0e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/INamedEntityViewModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels; + +public interface INamedEntityViewModel +{ + Guid Key { get; } + + string Name { get;} +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/RecycleBin/RecycleBinItemViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/RecycleBin/RecycleBinItemViewModel.cs index d3d25622f0..aa7ab3cd46 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/RecycleBin/RecycleBinItemViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/RecycleBin/RecycleBinItemViewModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.RecycleBin; -public class RecycleBinItemViewModel +public class RecycleBinItemViewModel : INamedEntityViewModel { public Guid Key { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Template/TemplateViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Template/TemplateViewModel.cs index 8fe8fc7ddd..e8958df583 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Template/TemplateViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Template/TemplateViewModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Template; -public class TemplateViewModel : TemplateModelBase +public class TemplateViewModel : TemplateModelBase, INamedEntityViewModel { public Guid Key { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/EntityTreeItemViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/EntityTreeItemViewModel.cs index a163a4f9a7..008bb81c5c 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/EntityTreeItemViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/EntityTreeItemViewModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Tree; -public class EntityTreeItemViewModel : TreeItemViewModel +public class EntityTreeItemViewModel : TreeItemViewModel, INamedEntityViewModel { public Guid Key { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupBase.cs new file mode 100644 index 0000000000..994ef00abf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupBase.cs @@ -0,0 +1,58 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups; + +/// +/// +/// Base class for front-end representation of a User Group. +/// +/// +/// Contains all the properties shared between Save, Update, Representation, etc... +/// +/// +public class UserGroupBase +{ + /// + /// The name of the user groups + /// + public required string Name { get; init; } + + /// + /// The Icon for the user group + /// + public string? Icon { get; init; } + + /// + /// The sections that the user group has access to + /// + public required IEnumerable Sections { get; init; } + + /// + /// The languages that the user group has access to + /// + public required IEnumerable Languages { get; init; } + + /// + /// Flag indicating if the user group gives access to all languages, regardless of . + /// + public required bool HasAccessToAllLanguages { get; init; } + + /// + /// The key of the document that should act as root node for the user group + /// + /// This can be overwritten by a different user group if a user is a member of multiple groups + /// + /// + public Guid? DocumentStartNodeKey { get; init; } + + /// + /// The Key of the media that should act as root node for the user group + /// + /// This can be overwritten by a different user group if a user is a member of multiple groups + /// + /// + public Guid? MediaStartNodeKey { get; init; } + + /// + /// Ad-hoc list of permissions provided, and maintained by the front-end. The server has no concept of what these mean. + /// + public required ISet Permissions { get; init; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupSaveModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupSaveModel.cs new file mode 100644 index 0000000000..d42a2c2faf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupSaveModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups; + +public class UserGroupSaveModel : UserGroupBase +{ + +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupUpdateModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupUpdateModel.cs new file mode 100644 index 0000000000..fa08b60ec9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupUpdateModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups; + +public class UserGroupUpdateModel : UserGroupBase +{ + +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupViewModel.cs new file mode 100644 index 0000000000..9b6212f7ef --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupViewModel.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups; + +public class UserGroupViewModel : UserGroupBase, INamedEntityViewModel +{ + /// + /// The key identifier for the user group. + /// + public required Guid Key { get; init; } + +} diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml index ad50b77190..7234ff16c8 100644 --- a/src/Umbraco.Core/CompatibilitySuppressions.xml +++ b/src/Umbraco.Core/CompatibilitySuppressions.xml @@ -203,6 +203,13 @@ lib/net7.0/Umbraco.Core.dll true + + CP0002 + M:Umbraco.Cms.Core.Models.Mapping.UserMapDefinition.#ctor(Umbraco.Cms.Core.Services.ILocalizedTextService,Umbraco.Cms.Core.Services.IUserService,Umbraco.Cms.Core.Services.IEntityService,Umbraco.Cms.Core.Services.ISectionService,Umbraco.Cms.Core.Cache.AppCaches,Umbraco.Cms.Core.Actions.ActionCollection,Microsoft.Extensions.Options.IOptions{Umbraco.Cms.Core.Configuration.Models.GlobalSettings},Umbraco.Cms.Core.IO.MediaFileManager,Umbraco.Cms.Core.Strings.IShortStringHelper,Umbraco.Cms.Core.Media.IImageUrlGenerator) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0002 M:Umbraco.Cms.Core.Models.Membership.ReadOnlyUserGroup.#ctor(System.Int32,System.String,System.String,System.Nullable{System.Int32},System.Nullable{System.Int32},System.String,System.Collections.Generic.IEnumerable{System.Int32},System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String},System.Boolean) @@ -602,4 +609,11 @@ lib/net7.0/Umbraco.Core.dll true + + CP0006 + P:Umbraco.Cms.Core.Models.Membership.IUserGroup.PermissionNames + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + \ No newline at end of file diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index d6d0a25c7b..5c4a2b6477 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -279,6 +279,8 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddTransient(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs b/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs index 28fbea027b..142be715f7 100644 --- a/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs +++ b/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs @@ -1,6 +1,8 @@ using System.Text; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; @@ -30,6 +32,7 @@ public sealed class AuditNotificationsHandler : private readonly GlobalSettings _globalSettings; private readonly IIpResolver _ipResolver; private readonly IMemberService _memberService; + private readonly IUserGroupService _userGroupService; private readonly IUserService _userService; public AuditNotificationsHandler( @@ -39,7 +42,8 @@ public sealed class AuditNotificationsHandler : IIpResolver ipResolver, IOptionsMonitor globalSettings, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IMemberService memberService) + IMemberService memberService, + IUserGroupService userGroupService) { _auditService = auditService; _userService = userService; @@ -47,9 +51,32 @@ public sealed class AuditNotificationsHandler : _ipResolver = ipResolver; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _memberService = memberService; + _userGroupService = userGroupService; _globalSettings = globalSettings.CurrentValue; } + [Obsolete("Use constructor that takes IUserGroupService, scheduled for removal in V15.")] + public AuditNotificationsHandler( + IAuditService auditService, + IUserService userService, + IEntityService entityService, + IIpResolver ipResolver, + IOptionsMonitor globalSettings, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IMemberService memberService) + : this( + auditService, + userService, + entityService, + ipResolver, + globalSettings, + backOfficeSecurityAccessor, + memberService, + StaticServiceProvider.Instance.GetRequiredService() + ) + { + } + private IUser CurrentPerformingUser { get @@ -95,7 +122,7 @@ public sealed class AuditNotificationsHandler : IEnumerable perms = notification.EntityPermissions; foreach (EntityPermission perm in perms) { - IUserGroup? group = _userService.GetUserGroupById(perm.UserGroupId); + IUserGroup? group = _userGroupService.GetAsync(perm.UserGroupId).Result; var assigned = string.Join(", ", perm.AssignedPermissions ?? Array.Empty()); IEntitySlim? entity = _entityService.Get(perm.EntityId); diff --git a/src/Umbraco.Core/Models/ContentEditing/UserGroupSave.cs b/src/Umbraco.Core/Models/ContentEditing/UserGroupSave.cs index a646c10337..d32f2fa9fe 100644 --- a/src/Umbraco.Core/Models/ContentEditing/UserGroupSave.cs +++ b/src/Umbraco.Core/Models/ContentEditing/UserGroupSave.cs @@ -37,6 +37,16 @@ public class UserGroupSave : EntityBasic, IValidatableObject [DataMember(Name = "hasAccessToAllLanguages")] public bool HasAccessToAllLanguages { get; set; } + /// + /// A set of ad-hoc permissions provided by the frontend. + /// + /// + /// By default the server has no concept of what these strings mean, we simple store them and return them to the UI. + /// FIXME: Permissions already exists in the form of "DefaultPermissions", but is subject to change in the future + /// when we know more about how we want to handle permissions, potentially those will be migrated in the these "soft" permissions. + /// + public ISet? Permissions { get; set; } + /// /// The list of letters (permission codes) to assign as the default for the user group /// diff --git a/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs b/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs index 34552d0f0b..c82b166eed 100644 --- a/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs +++ b/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Actions; @@ -33,6 +32,7 @@ public class UserMapDefinition : IMapDefinition private readonly ILocalizedTextService _textService; private readonly IUserService _userService; private readonly ILocalizationService _localizationService; + private readonly IUserGroupService _userGroupService; public UserMapDefinition( ILocalizedTextService textService, @@ -45,7 +45,8 @@ public class UserMapDefinition : IMapDefinition MediaFileManager mediaFileManager, IShortStringHelper shortStringHelper, IImageUrlGenerator imageUrlGenerator, - ILocalizationService localizationService) + ILocalizationService localizationService, + IUserGroupService userGroupService) { _sectionService = sectionService; _entityService = entityService; @@ -58,9 +59,10 @@ public class UserMapDefinition : IMapDefinition _shortStringHelper = shortStringHelper; _imageUrlGenerator = imageUrlGenerator; _localizationService = localizationService; + _userGroupService = userGroupService; } - [Obsolete("Please use constructor that takes an ILocalizationService instead")] + [Obsolete("Use constructor that takes IUserGroupService, scheduled for removal in V15.")] public UserMapDefinition( ILocalizedTextService textService, IUserService userService, @@ -71,19 +73,21 @@ public class UserMapDefinition : IMapDefinition IOptions globalSettings, MediaFileManager mediaFileManager, IShortStringHelper shortStringHelper, - IImageUrlGenerator imageUrlGenerator) - : this( - textService, - userService, - entityService, - sectionService, - appCaches, - actions, - globalSettings, - mediaFileManager, - shortStringHelper, - imageUrlGenerator, - StaticServiceProvider.Instance.GetRequiredService()) + IImageUrlGenerator imageUrlGenerator, + ILocalizationService localizationService) + : this( + textService, + userService, + entityService, + sectionService, + appCaches, + actions, + globalSettings, + mediaFileManager, + shortStringHelper, + imageUrlGenerator, + localizationService, + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -135,6 +139,7 @@ public class UserMapDefinition : IMapDefinition target.Permissions = source.DefaultPermissions; target.Key = source.Key; target.HasAccessToAllLanguages = source.HasAccessToAllLanguages; + target.PermissionNames = source.Permissions ?? new HashSet(); var id = GetIntId(source.Id); if (id > 0) @@ -222,7 +227,7 @@ public class UserMapDefinition : IMapDefinition target.IsApproved = false; target.ClearGroups(); - IEnumerable groups = _userService.GetUserGroupsByAlias(source.UserGroups.ToArray()); + IEnumerable groups = _userGroupService.GetAsync(source.UserGroups.ToArray()).GetAwaiter().GetResult(); foreach (IUserGroup group in groups) { target.AddGroup(group.ToReadOnlyGroup()); @@ -247,7 +252,7 @@ public class UserMapDefinition : IMapDefinition target.Id = source.Id; target.ClearGroups(); - IEnumerable groups = _userService.GetUserGroupsByAlias(source.UserGroups.ToArray()); + IEnumerable groups = _userGroupService.GetAsync(source.UserGroups.ToArray()).GetAwaiter().GetResult(); foreach (IUserGroup group in groups) { target.AddGroup(group.ToReadOnlyGroup()); diff --git a/src/Umbraco.Core/Models/Membership/IUserGroup.cs b/src/Umbraco.Core/Models/Membership/IUserGroup.cs index 11b97a9996..c78ee7cbd5 100644 --- a/src/Umbraco.Core/Models/Membership/IUserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/IUserGroup.cs @@ -1,3 +1,4 @@ +using System.Collections; using Umbraco.Cms.Core.Models.Entities; namespace Umbraco.Cms.Core.Models.Membership; @@ -24,7 +25,11 @@ public interface IUserGroup : IEntity, IRememberBeingDirty /// If this property is true it will give the group access to all languages ///
/// This is set to return true as default to avoid breaking changes - public bool HasAccessToAllLanguages => true; + public bool HasAccessToAllLanguages + { + get => true; + set { /* This is NoOp to avoid breaking changes */ } + } /// /// The set of default permissions @@ -35,6 +40,16 @@ public interface IUserGroup : IEntity, IRememberBeingDirty /// IEnumerable? Permissions { get; set; } + /// + /// The set of permissions provided by the frontend. + /// + /// + /// By default the server has no concept of what these strings mean, we simple store them and return them to the UI. + /// FIXME: For now this is named PermissionNames since Permissions already exists, but is subject to change in the future + /// when we know more about how we want to handle permissions, potentially those will be migrated in the these "soft" permissions. + /// + ISet PermissionNames { get; set; } + IEnumerable AllowedSections { get; } void RemoveAllowedSection(string sectionAlias); diff --git a/src/Umbraco.Core/Models/Membership/UserGroup.cs b/src/Umbraco.Core/Models/Membership/UserGroup.cs index 7369771cbd..119bc6bf69 100644 --- a/src/Umbraco.Core/Models/Membership/UserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/UserGroup.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Runtime.Serialization; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Strings; @@ -24,6 +25,7 @@ public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup private string _name; private bool _hasAccessToAllLanguages; private IEnumerable? _permissions; + private ISet _permissionNames = new HashSet(); private List _sectionCollection; private List _languageCollection; private int? _startContentId; @@ -124,6 +126,13 @@ public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup set => SetPropertyValueAndDetectChanges(value, ref _permissions, nameof(Permissions), _stringEnumerableComparer); } + /// + public ISet PermissionNames + { + get => _permissionNames; + set => SetPropertyValueAndDetectChanges(value, ref _permissionNames!, nameof(PermissionNames), _stringEnumerableComparer); + } + public IEnumerable AllowedSections => _sectionCollection; public int UserCount { get; } diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 420f36c759..a7c094db2a 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -54,6 +54,7 @@ public static partial class Constants public const string User2NodeNotify = TableNamePrefix + "User2NodeNotify"; public const string UserGroup2App = TableNamePrefix + "UserGroup2App"; public const string UserGroup2Node = TableNamePrefix + "UserGroup2Node"; + public const string UserGroup2Permission = TableNamePrefix + "UserGroup2Permission"; public const string UserGroup2NodePermission = TableNamePrefix + "UserGroup2NodePermission"; public const string UserGroup2Language = TableNamePrefix + "UserGroup2Language"; public const string ExternalLogin = TableNamePrefix + "ExternalLogin"; diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index ff7c8f12d9..025d291a72 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -48,6 +48,22 @@ public interface IEntityRepository : IRepository bool Exists(Guid key); + /// + /// Asserts if an entity with the given object type exists. + /// + /// The Key of the entity to find. + /// The object type key of the entity. + /// True if an entity with the given key and object type exists. + bool Exists(Guid key, Guid objectType) => throw new NotImplementedException(); + + /// + /// Asserts if an entity with the given object type exists. + /// + /// The id of the entity to find. + /// The object type key of the entity. + /// True if an entity with the given id and object type exists. + bool Exists(int id, Guid objectType) => throw new NotImplementedException(); + /// /// Gets paged entities for a query and a specific object type /// diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index b6fc244bd0..ed35e78879 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -124,6 +124,24 @@ public class EntityService : RepositoryService, IEntityService } } + /// + public bool Exists(Guid key, UmbracoObjectTypes objectType) + { + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _entityRepository.Exists(key, objectType.GetGuid()); + } + } + + /// + public bool Exists(int id, UmbracoObjectTypes objectType) + { + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _entityRepository.Exists(id, objectType.GetGuid()); + } + } + /// public virtual IEnumerable GetAll() where T : IUmbracoEntity diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 5151d9ed1f..a2204b97ce 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -60,6 +60,22 @@ public interface IEntityService /// The unique key of the entity. bool Exists(Guid key); + /// + /// Determines whether and entity of a certain object type exists. + /// + /// The unique key of the entity. + /// The object type to look for. + /// True if the entity exists, false if it does not. + bool Exists(Guid key, UmbracoObjectTypes objectType) => throw new NotImplementedException(); + + /// + /// Determines whether and entity of a certain object type exists. + /// + /// The id of the entity. + /// The object type to look for. + /// True if the entity exists, false if it does not. + bool Exists(int id, UmbracoObjectTypes objectType) => throw new NotImplementedException(); + /// /// Gets entities of a given object type. /// diff --git a/src/Umbraco.Core/Services/IUserGroupAuthorizationService.cs b/src/Umbraco.Core/Services/IUserGroupAuthorizationService.cs new file mode 100644 index 0000000000..1a223fbc01 --- /dev/null +++ b/src/Umbraco.Core/Services/IUserGroupAuthorizationService.cs @@ -0,0 +1,24 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IUserGroupAuthorizationService +{ + + /// + /// Authorizes a user to create a new user group. + /// + /// The user performing the create operation. + /// The user group to be created. + /// An attempt with an operation status. + Attempt AuthorizeUserGroupCreation(IUser performingUser, IUserGroup userGroup); + + /// + /// Authorizes a user to update an existing user group. + /// + /// The user performing the update operation. + /// The user group to be created. + /// An attempt with an operation. + Attempt AuthorizeUserGroupUpdate(IUser performingUser, IUserGroup userGroup); +} diff --git a/src/Umbraco.Core/Services/IUserGroupService.cs b/src/Umbraco.Core/Services/IUserGroupService.cs new file mode 100644 index 0000000000..ee49334d12 --- /dev/null +++ b/src/Umbraco.Core/Services/IUserGroupService.cs @@ -0,0 +1,86 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Manages user groups. +/// +public interface IUserGroupService +{ + /// + /// Gets all user groups. + /// + /// The amount of user groups to skip. + /// The amount of user groups to take. + /// All user groups as an enumerable list of . + Task> GetAllAsync(int skip, int take); + + /// + /// Gets all UserGroups matching an ID in the parameter list. + /// + /// Optional Ids of UserGroups to retrieve. + /// An enumerable list of . + Task> GetAsync(params int[] ids); + + /// + /// Gets all UserGroups matching an alias in the parameter list. + /// + /// Alias of the UserGroup to retrieve. + /// + /// An enumerable list of . + /// + Task> GetAsync(params string[] aliases); + + /// + /// Gets a UserGroup by its Alias + /// + /// Name of the UserGroup to retrieve. + /// + /// + /// + Task GetAsync(string alias); + + /// + /// Gets a UserGroup by its Id + /// + /// Id of the UserGroup to retrieve. + /// + /// + /// + Task GetAsync(int id); + + /// + /// Gets a UserGroup by its key + /// + /// Key of the UserGroup to retrieve. + /// + /// + /// + Task GetAsync(Guid key); + + /// + /// Persists a new user group. + /// + /// The user group to create. + /// The ID of the user responsible for creating the group. + /// The IDs of the users that should be part of the group when created. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> CreateAsync(IUserGroup userGroup, int performingUserId, int[]? groupMembersUserIds = null); + + /// + /// Updates an existing user group. + /// + /// The user group to update. + /// The ID of the user responsible for updating the group. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> UpdateAsync(IUserGroup userGroup, int performingUserId); + + /// + /// Deletes a UserGroup + /// + /// The key of the user group to delete. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> DeleteAsync(Guid key); +} diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index 40a3fbd899..b04e9d8850 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -240,6 +240,7 @@ public interface IUserService : IMembershipUserService /// /// Optional Ids of UserGroups to retrieve /// An enumerable list of + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] IEnumerable GetAllUserGroups(params int[] ids); /// @@ -249,6 +250,7 @@ public interface IUserService : IMembershipUserService /// /// /// + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] IEnumerable GetUserGroupsByAlias(params string[] alias); /// @@ -258,6 +260,7 @@ public interface IUserService : IMembershipUserService /// /// /// + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] IUserGroup? GetUserGroupByAlias(string name); /// @@ -267,6 +270,7 @@ public interface IUserService : IMembershipUserService /// /// /// + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] IUserGroup? GetUserGroupById(int id); /// @@ -277,12 +281,14 @@ public interface IUserService : IMembershipUserService /// If null than no changes are made to the users who are assigned to this group, however if a value is passed in /// than all users will be removed from this group and only these users will be added /// + [Obsolete("Use IUserGroupService.CreateAsync and IUserGroupService.UpdateAsync instead, scheduled for removal in V15.")] void Save(IUserGroup userGroup, int[]? userIds = null); /// /// Deletes a UserGroup /// /// UserGroup to delete + [Obsolete("Use IUserGroupService.DeleteAsync instead, scheduled for removal in V15.")] void DeleteUserGroup(IUserGroup userGroup); #endregion diff --git a/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs new file mode 100644 index 0000000000..f25f0de64e --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum UserGroupOperationStatus +{ + Success, + NotFound, + AlreadyExists, + DuplicateAlias, + MissingUser, + IsSystemUserGroup, + UnauthorizedMissingUserSection, + UnauthorizedMissingSections, + UnauthorizedStartNodes, + UnauthorizedMissingUserGroup, + CancelledByNotification, + MediaStartNodeKeyNotFound, + DocumentStartNodeKeyNotFound, + LanguageNotFound, + NameTooLong, + AliasTooLong, + MissingName, +} diff --git a/src/Umbraco.Core/Services/UserGroupAuthorizationService.cs b/src/Umbraco.Core/Services/UserGroupAuthorizationService.cs new file mode 100644 index 0000000000..57a250752d --- /dev/null +++ b/src/Umbraco.Core/Services/UserGroupAuthorizationService.cs @@ -0,0 +1,177 @@ +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class UserGroupAuthorizationService : IUserGroupAuthorizationService +{ + private readonly IContentService _contentService; + private readonly IMediaService _mediaService; + private readonly IEntityService _entityService; + private readonly AppCaches _appCaches; + + public UserGroupAuthorizationService( + IContentService contentService, + IMediaService mediaService, + IEntityService entityService, + AppCaches appCaches) + { + _contentService = contentService; + _mediaService = mediaService; + _entityService = entityService; + _appCaches = appCaches; + } + + /// + public Attempt AuthorizeUserGroupCreation(IUser performingUser, IUserGroup userGroup) + { + Attempt hasSectionAccess = AuthorizeHasAccessToUserSection(performingUser); + if (hasSectionAccess.Success is false) + { + return Attempt.Fail(hasSectionAccess.Result); + } + + Attempt authorizeSectionChanges = AuthorizeSectionAccess(performingUser, userGroup); + if (authorizeSectionChanges.Success is false) + { + return Attempt.Fail(authorizeSectionChanges.Result); + } + + Attempt authorizeContentNodeChanges = AuthorizeStartNodeChanges(performingUser, userGroup); + return authorizeSectionChanges.Success is false + ? Attempt.Fail(authorizeContentNodeChanges.Result) + : Attempt.Succeed(UserGroupOperationStatus.Success); + } + + /// + public Attempt AuthorizeUserGroupUpdate(IUser performingUser, IUserGroup userGroup) + { + Attempt hasAccessToUserSection = AuthorizeHasAccessToUserSection(performingUser); + if (hasAccessToUserSection.Success is false) + { + return Attempt.Fail(hasAccessToUserSection.Result); + } + + Attempt authorizeSectionAccess = AuthorizeSectionAccess(performingUser, userGroup); + if (authorizeSectionAccess.Success is false) + { + return Attempt.Fail(authorizeSectionAccess.Result); + } + + Attempt authorizeGroupAccess = AuthorizeGroupAccess(performingUser, userGroup); + if (authorizeGroupAccess.Success is false) + { + return Attempt.Fail(authorizeGroupAccess.Result); + } + + Attempt authorizeStartNodeChanges = AuthorizeStartNodeChanges(performingUser, userGroup); + if (authorizeSectionAccess.Success is false) + { + return Attempt.Fail(authorizeStartNodeChanges.Result); + } + + + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + /// + /// Authorize that a user is not adding a section to the group that they don't have access to. + /// + /// The user performing the action. + /// The UserGroup being created or updated. + /// An attempt with an operation status. + private Attempt AuthorizeSectionAccess(IUser performingUser, IUserGroup userGroup) + { + if (performingUser.IsAdmin()) + { + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + IEnumerable sectionsMissingAccess = userGroup.AllowedSections.Except(performingUser.AllowedSections).ToArray(); + return sectionsMissingAccess.Any() + ? Attempt.Fail(UserGroupOperationStatus.UnauthorizedMissingSections) + : Attempt.Succeed(UserGroupOperationStatus.Success); + } + + /// + /// Authorize that the user is not changing to a start node that they don't have access to. + /// + /// The user performing the action. + /// The UserGroup being created or updated. + /// An attempt with an operation status. + private Attempt AuthorizeStartNodeChanges(IUser user, IUserGroup userGroup) + { + Attempt authorizeContent = AuthorizeContentStartNode(user, userGroup); + + return authorizeContent.Success is false + ? authorizeContent + : AuthorizeMediaStartNode(user, userGroup); + } + + /// + /// Ensures that a user has access to the user section. + /// + /// The user performing the action. + /// An attempt with an operation status. + private Attempt AuthorizeHasAccessToUserSection(IUser user) + { + if (user.AllowedSections.Contains(Constants.Applications.Users) is false) + { + return Attempt.Fail(UserGroupOperationStatus.UnauthorizedMissingUserSection); + } + + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + /// + /// Ensures that the performing user is part of the user group. + /// + private Attempt AuthorizeGroupAccess(IUser performingUser, IUserGroup userGroup) => + performingUser.Groups.Any(x => x.Key == userGroup.Key) || performingUser.IsAdmin() + ? Attempt.Succeed(UserGroupOperationStatus.Success) + : Attempt.Fail(UserGroupOperationStatus.UnauthorizedMissingUserGroup); + + // We explicitly take an IUser here which is non-nullable, since nullability should be handled in caller. + private Attempt AuthorizeContentStartNode(IUser user, IUserGroup userGroup) + { + if (userGroup.StartContentId is null) + { + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + IContent? content = _contentService.GetById(userGroup.StartContentId.Value); + + if (content is null) + { + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + return user.HasPathAccess(content, _entityService, _appCaches) is false + ? Attempt.Fail(UserGroupOperationStatus.UnauthorizedStartNodes) + : Attempt.Succeed(UserGroupOperationStatus.Success); + } + + // We explicitly take an IUser here which is non-nullable, since nullability should be handled in caller. + private Attempt AuthorizeMediaStartNode(IUser user, IUserGroup userGroup) + { + + if (userGroup.StartMediaId is null) + { + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + IMedia? media = _mediaService.GetById(userGroup.StartMediaId.Value); + + if (media is null) + { + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + return user.HasPathAccess(media, _entityService, _appCaches) is false + ? Attempt.Fail(UserGroupOperationStatus.UnauthorizedStartNodes) + : Attempt.Succeed(UserGroupOperationStatus.Success); + } +} diff --git a/src/Umbraco.Core/Services/UserGroupService.cs b/src/Umbraco.Core/Services/UserGroupService.cs new file mode 100644 index 0000000000..42bf0070e7 --- /dev/null +++ b/src/Umbraco.Core/Services/UserGroupService.cs @@ -0,0 +1,376 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +/// +internal sealed class UserGroupService : RepositoryService, IUserGroupService +{ + public const int MaxUserGroupNameLength = 200; + public const int MaxUserGroupAliasLength = 200; + + private readonly IUserGroupRepository _userGroupRepository; + private readonly IUserGroupAuthorizationService _userGroupAuthorizationService; + private readonly IUserService _userService; + private readonly IEntityService _entityService; + + public UserGroupService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IUserGroupRepository userGroupRepository, + IUserGroupAuthorizationService userGroupAuthorizationService, + IUserService userService, + IEntityService entityService) + : base(provider, loggerFactory, eventMessagesFactory) + { + _userGroupRepository = userGroupRepository; + _userGroupAuthorizationService = userGroupAuthorizationService; + _userService = userService; + _entityService = entityService; + } + + /// + public Task> GetAllAsync(int skip, int take) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IUserGroup[] groups = _userGroupRepository.GetMany() + .OrderBy(x => x.Name).ToArray(); + + var total = groups.Length; + + return Task.FromResult(new PagedModel + { + Items = groups.Skip(skip).Take(take), + Total = total, + }); + } + + /// + public Task> GetAsync(params int[] ids) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IEnumerable groups = _userGroupRepository + .GetMany(ids) + .OrderBy(x => x.Name); + + return Task.FromResult(groups); + } + + /// + public Task> GetAsync(params string[] aliases) + { + if (aliases.Length == 0) + { + return Task.FromResult(Enumerable.Empty()); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IQuery query = Query().Where(x => aliases.SqlIn(x.Alias)); + IEnumerable contents = _userGroupRepository + .Get(query) + .WhereNotNull() + .OrderBy(x => x.Name) + .ToArray(); + + return Task.FromResult(contents); + } + + /// + public Task GetAsync(string alias) + { + if (string.IsNullOrWhiteSpace(alias)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(alias)); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IQuery query = Query().Where(x => x.Alias == alias); + IUserGroup? contents = _userGroupRepository.Get(query).FirstOrDefault(); + return Task.FromResult(contents); + } + + /// + public Task GetAsync(int id) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return Task.FromResult(_userGroupRepository.Get(id)); + } + + /// + public Task GetAsync(Guid key) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IQuery query = Query().Where(x => x.Key == key); + IUserGroup? groups = _userGroupRepository.Get(query).FirstOrDefault(); + return Task.FromResult(groups); + } + + /// + public async Task> DeleteAsync(Guid key) + { + IUserGroup? userGroup = await GetAsync(key); + + Attempt validationResult = ValidateUserGroupDeletion(userGroup); + if (validationResult.Success is false) + { + return validationResult; + } + + EventMessages eventMessages = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + var deletingNotification = new UserGroupDeletingNotification(userGroup!, eventMessages); + + if (await scope.Notifications.PublishCancelableAsync(deletingNotification)) + { + scope.Complete(); + return Attempt.Fail(UserGroupOperationStatus.CancelledByNotification); + } + + _userGroupRepository.Delete(userGroup!); + + scope.Notifications.Publish(new UserGroupDeletedNotification(userGroup!, eventMessages).WithStateFrom(deletingNotification)); + + scope.Complete(); + } + + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + private Attempt ValidateUserGroupDeletion(IUserGroup? userGroup) + { + if (userGroup is null) + { + return Attempt.Fail(UserGroupOperationStatus.NotFound); + } + + if (userGroup.IsSystemUserGroup()) + { + return Attempt.Fail(UserGroupOperationStatus.IsSystemUserGroup); + } + + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + /// + public async Task> CreateAsync( + IUserGroup userGroup, + int performingUserId, + int[]? groupMembersUserIds = null) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + IUser? performingUser = _userService.GetUserById(performingUserId); + if (performingUser is null) + { + return Attempt.FailWithStatus(UserGroupOperationStatus.MissingUser, userGroup); + } + + Attempt validationAttempt = await ValidateUserGroupCreationAsync(userGroup); + if (validationAttempt.Success is false) + { + return validationAttempt; + } + + Attempt authorizationAttempt = _userGroupAuthorizationService.AuthorizeUserGroupCreation(performingUser, userGroup); + if (authorizationAttempt.Success is false) + { + return Attempt.FailWithStatus(authorizationAttempt.Result, userGroup); + } + + EventMessages eventMessages = EventMessagesFactory.Get(); + var savingNotification = new UserGroupSavingNotification(userGroup, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(UserGroupOperationStatus.CancelledByNotification, userGroup); + } + + var checkedGroupMembers = EnsureNonAdminUserIsInSavedUserGroup(performingUser, groupMembersUserIds ?? Enumerable.Empty()).ToArray(); + IEnumerable usersToAdd = _userService.GetUsersById(checkedGroupMembers); + + // Since this is a brand new creation we don't have to be worried about what users were added and removed + // simply put all members that are requested to be in the group will be "added" + var userGroupWithUsers = new UserGroupWithUsers(userGroup, usersToAdd.ToArray(), Array.Empty()); + var savingUserGroupWithUsersNotification = new UserGroupWithUsersSavingNotification(userGroupWithUsers, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(savingUserGroupWithUsersNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(UserGroupOperationStatus.CancelledByNotification, userGroup); + } + + _userGroupRepository.AddOrUpdateGroupWithUsers(userGroup, checkedGroupMembers); + + scope.Complete(); + return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, userGroup); + } + + private async Task> ValidateUserGroupCreationAsync(IUserGroup userGroup) + { + if (await IsNewUserGroup(userGroup) is false) + { + return Attempt.FailWithStatus(UserGroupOperationStatus.AlreadyExists, userGroup); + } + + UserGroupOperationStatus commonValidationStatus = ValidateCommon(userGroup); + if (commonValidationStatus != UserGroupOperationStatus.Success) + { + return Attempt.FailWithStatus(commonValidationStatus, userGroup); + } + + return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, userGroup); + } + + /// + public async Task> UpdateAsync( + IUserGroup userGroup, + int performingUserId) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + IUser? performingUser = _userService.GetUserById(performingUserId); + if (performingUser is null) + { + return Attempt.FailWithStatus(UserGroupOperationStatus.MissingUser, userGroup); + } + + UserGroupOperationStatus validationStatus = await ValidateUserGroupUpdateAsync(userGroup); + if (validationStatus is not UserGroupOperationStatus.Success) + { + return Attempt.FailWithStatus(validationStatus, userGroup); + } + + Attempt authorizationAttempt = _userGroupAuthorizationService.AuthorizeUserGroupUpdate(performingUser, userGroup); + if (authorizationAttempt.Success is false) + { + return Attempt.FailWithStatus(authorizationAttempt.Result, userGroup); + } + + EventMessages eventMessages = EventMessagesFactory.Get(); + var savingNotification = new UserGroupSavingNotification(userGroup, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(UserGroupOperationStatus.CancelledByNotification, userGroup); + } + + _userGroupRepository.Save(userGroup); + scope.Notifications.Publish(new UserGroupSavedNotification(userGroup, eventMessages).WithStateFrom(savingNotification)); + + scope.Complete(); + return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, userGroup); + } + + private async Task ValidateUserGroupUpdateAsync(IUserGroup userGroup) + { + UserGroupOperationStatus commonValidationStatus = ValidateCommon(userGroup); + if (commonValidationStatus != UserGroupOperationStatus.Success) + { + return commonValidationStatus; + } + + if (await IsNewUserGroup(userGroup)) + { + return UserGroupOperationStatus.NotFound; + } + + return UserGroupOperationStatus.Success; + } + + /// + /// Validate common user group properties, that are shared between update, create, etc. + /// + private UserGroupOperationStatus ValidateCommon(IUserGroup userGroup) + { + if (string.IsNullOrEmpty(userGroup.Name)) + { + return UserGroupOperationStatus.MissingName; + } + + if (userGroup.Name.Length > MaxUserGroupNameLength) + { + return UserGroupOperationStatus.NameTooLong; + } + + if (userGroup.Alias.Length > MaxUserGroupAliasLength) + { + return UserGroupOperationStatus.AliasTooLong; + } + + if (UserGroupHasUniqueAlias(userGroup) is false) + { + return UserGroupOperationStatus.DuplicateAlias; + } + + UserGroupOperationStatus startNodesValidationStatus = ValidateStartNodesExists(userGroup); + if (startNodesValidationStatus is not UserGroupOperationStatus.Success) + { + return startNodesValidationStatus; + } + + return UserGroupOperationStatus.Success; + } + + private async Task IsNewUserGroup(IUserGroup userGroup) + { + if (userGroup.Id != 0 && userGroup.HasIdentity is false) + { + return false; + } + + return await GetAsync(userGroup.Key) is null; + } + + private UserGroupOperationStatus ValidateStartNodesExists(IUserGroup userGroup) + { + if (userGroup.StartContentId is not null + && _entityService.Exists(userGroup.StartContentId.Value, UmbracoObjectTypes.Document) is false) + { + return UserGroupOperationStatus.DocumentStartNodeKeyNotFound; + } + + if (userGroup.StartMediaId is not null + && _entityService.Exists(userGroup.StartMediaId.Value, UmbracoObjectTypes.Media) is false) + { + return UserGroupOperationStatus.MediaStartNodeKeyNotFound; + } + + return UserGroupOperationStatus.Success; + } + + private bool UserGroupHasUniqueAlias(IUserGroup userGroup) => _userGroupRepository.Get(userGroup.Alias) is null; + + /// + /// Ensures that the user creating the user group is either an admin, or in the group itself. + /// + /// + /// This is to ensure that the user can access the group they themselves created at a later point and modify it. + /// + private IEnumerable EnsureNonAdminUserIsInSavedUserGroup(IUser performingUser, IEnumerable groupMembersUserIds) + { + var userIds = groupMembersUserIds.ToList(); + + // If the performing user is and admin we don't care, they can access the group later regardless + if (performingUser.IsAdmin() is false && userIds.Contains(performingUser.Id) is false) + { + userIds.Add(performingUser.Id); + } + + return userIds; + } +} diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index 69e6351fbd..0782431e91 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -11,7 +11,9 @@ using Umbraco.Cms.Core.Persistence; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Extensions; +using UserProfile = Umbraco.Cms.Core.Models.Membership.UserProfile; namespace Umbraco.Cms.Core.Services; @@ -25,6 +27,7 @@ internal class UserService : RepositoryService, IUserService private readonly ILogger _logger; private readonly IRuntimeState _runtimeState; private readonly IUserGroupRepository _userGroupRepository; + private readonly IUserGroupAuthorizationService _userGroupAuthorizationService; private readonly IUserRepository _userRepository; public UserService( @@ -34,12 +37,14 @@ internal class UserService : RepositoryService, IUserService IRuntimeState runtimeState, IUserRepository userRepository, IUserGroupRepository userGroupRepository, - IOptions globalSettings) + IOptions globalSettings, + IUserGroupAuthorizationService userGroupAuthorizationService) : base(provider, loggerFactory, eventMessagesFactory) { _runtimeState = runtimeState; _userRepository = userRepository; _userGroupRepository = userGroupRepository; + _userGroupAuthorizationService = userGroupAuthorizationService; _globalSettings = globalSettings.Value; _logger = loggerFactory.CreateLogger(); } @@ -884,6 +889,7 @@ internal class UserService : RepositoryService, IUserService /// /// Optional Ids of UserGroups to retrieve /// An enumerable list of + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] public IEnumerable GetAllUserGroups(params int[] ids) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) @@ -892,6 +898,7 @@ internal class UserService : RepositoryService, IUserService } } + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] public IEnumerable GetUserGroupsByAlias(params string[] aliases) { if (aliases.Length == 0) @@ -914,6 +921,7 @@ internal class UserService : RepositoryService, IUserService /// /// /// + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] public IUserGroup? GetUserGroupByAlias(string alias) { if (string.IsNullOrWhiteSpace(alias)) @@ -936,6 +944,7 @@ internal class UserService : RepositoryService, IUserService /// /// /// + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] public IUserGroup? GetUserGroupById(int id) { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) @@ -958,6 +967,7 @@ internal class UserService : RepositoryService, IUserService /// False /// to not raise events /// + [Obsolete("Use IUserGroupService.CreateAsync and IUserGroupService.UpdateAsync instead, scheduled for removal in V15.")] public void Save(IUserGroup userGroup, int[]? userIds = null) { EventMessages evtMsgs = EventMessagesFactory.Get(); @@ -965,7 +975,7 @@ internal class UserService : RepositoryService, IUserService using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { // we need to figure out which users have been added / removed, for audit purposes - var empty = new IUser[0]; + IUser[] empty = Array.Empty(); IUser[] addedUsers = empty; IUser[] removedUsers = empty; @@ -1018,6 +1028,7 @@ internal class UserService : RepositoryService, IUserService /// Deletes a UserGroup /// /// UserGroup to delete + [Obsolete("Use IUserGroupService.DeleteAsync instead, scheduled for removal in V15.")] public void DeleteUserGroup(IUserGroup userGroup) { EventMessages evtMsgs = EventMessagesFactory.Get(); diff --git a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml b/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml index 7eb0063a32..fc303216ba 100644 --- a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml +++ b/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml @@ -77,6 +77,13 @@ lib/net7.0/Umbraco.Infrastructure.dll true + + CP0002 + M:Umbraco.Cms.Core.Security.BackOfficeUserStore.#ctor(Umbraco.Cms.Core.Scoping.ICoreScopeProvider,Umbraco.Cms.Core.Services.IUserService,Umbraco.Cms.Core.Services.IEntityService,Umbraco.Cms.Core.Services.IExternalLoginWithKeyService,Microsoft.Extensions.Options.IOptions{Umbraco.Cms.Core.Configuration.Models.GlobalSettings},Umbraco.Cms.Core.Mapping.IUmbracoMapper,Umbraco.Cms.Core.Security.BackOfficeErrorDescriber,Umbraco.Cms.Core.Cache.AppCaches) + lib/net7.0/Umbraco.Infrastructure.dll + lib/net7.0/Umbraco.Infrastructure.dll + true + CP0002 M:Umbraco.Cms.Infrastructure.Install.PackageMigrationRunner.#ctor(Umbraco.Cms.Core.Logging.IProfilingLogger,Umbraco.Cms.Core.Scoping.ICoreScopeProvider,Umbraco.Cms.Core.Packaging.PendingPackageMigrations,Umbraco.Cms.Core.Packaging.PackageMigrationPlanCollection,Umbraco.Cms.Core.Migrations.IMigrationPlanExecutor,Umbraco.Cms.Core.Services.IKeyValueService,Umbraco.Cms.Core.Events.IEventAggregator) diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index 8c1e0e2a54..ed4903f531 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -68,6 +68,7 @@ public class DatabaseSchemaCreator typeof(User2UserGroupDto), typeof(UserGroup2NodePermissionDto), typeof(UserGroup2AppDto), + typeof(UserGroup2PermissionDto), typeof(UserStartNodeDto), typeof(ContentNuDto), typeof(DocumentVersionDto), diff --git a/src/Umbraco.Infrastructure/Migrations/MigrationBase.cs b/src/Umbraco.Infrastructure/Migrations/MigrationBase.cs index 7b9bb1551c..ab6d079a96 100644 --- a/src/Umbraco.Infrastructure/Migrations/MigrationBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/MigrationBase.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Infrastructure.Migrations.Expressions.Rename; using Umbraco.Cms.Infrastructure.Migrations.Expressions.Update; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; +using Umbraco.Cms.Infrastructure.Scoping; namespace Umbraco.Cms.Infrastructure.Migrations; diff --git a/src/Umbraco.Infrastructure/Migrations/UnscopedMigrationBase.cs b/src/Umbraco.Infrastructure/Migrations/UnscopedMigrationBase.cs index ca52be6d9d..910b9753de 100644 --- a/src/Umbraco.Infrastructure/Migrations/UnscopedMigrationBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/UnscopedMigrationBase.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Infrastructure.Scoping; namespace Umbraco.Cms.Infrastructure.Migrations; diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index a8631b671a..5e9ef82a13 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -83,5 +83,6 @@ public class UmbracoPlan : MigrationPlan To("{419827A0-4FCE-464B-A8F3-247C6092AF55}"); To("{5F15A1CC-353D-4889-8C7E-F303B4766196}"); To("{69E12556-D9B3-493A-8E8A-65EC89FB658D}"); + To("{F2B16CD4-F181-4BEE-81C9-11CF384E6025}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs index 3d06a07ce4..3f75caabee 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs @@ -1,4 +1,4 @@ -using NPoco; +using NPoco; using Umbraco.Cms.Core; using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddUserGroupPermissionTable.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddUserGroupPermissionTable.cs new file mode 100644 index 0000000000..2510f32d65 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddUserGroupPermissionTable.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0; + +public class AddUserGroup2PermisionTable : MigrationBase +{ + public AddUserGroup2PermisionTable(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + if (TableExists(Constants.DatabaseSchema.Tables.UserGroup2Permission)) + { + return; + } + + Create.Table().Do(); + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroup2PermissionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroup2PermissionDto.cs new file mode 100644 index 0000000000..c5cf3b12b7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroup2PermissionDto.cs @@ -0,0 +1,22 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(Constants.DatabaseSchema.Tables.UserGroup2Permission)] +[ExplicitColumns] +public class UserGroup2PermissionDto +{ + [PrimaryKeyColumn(Name = "PK_userGroup2Permission", AutoIncrement = true)] + public int Id { get; set; } + + [Column("userGroupId")] + [Index(IndexTypes.NonClustered, IncludeColumns = "permission")] + [ForeignKey(typeof(UserGroupDto))] + public int UserGroupId { get; set; } + + [Column("permission")] + [SpecialDbType(SpecialDbTypes.NVARCHARMAX)] + public required string Permission { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs index e8699221d7..bd9803dfa0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs @@ -14,6 +14,7 @@ public class UserGroupDto { UserGroup2AppDtos = new List(); UserGroup2LanguageDtos = new List(); + UserGroup2PermissionDtos = new List(); } [Column("id")] @@ -77,6 +78,10 @@ public class UserGroupDto [Reference(ReferenceType.Many, ReferenceMemberName = "UserGroupId")] public List UserGroup2LanguageDtos { get; set; } + [ResultColumn] + [Reference(ReferenceType.Many, ReferenceMemberName = "UserGroupId")] + public List UserGroup2PermissionDtos { get; set; } + /// /// This is only relevant when this column is included in the results (i.e. GetUserGroupsWithUserCounts) /// diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs index e48d1cd17f..a272f84fa6 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs @@ -27,6 +27,7 @@ internal static class UserGroupFactory userGroup.UpdateDate = dto.UpdateDate; userGroup.StartContentId = dto.StartContentId; userGroup.StartMediaId = dto.StartMediaId; + userGroup.PermissionNames = dto.UserGroup2PermissionDtos.Select(x => x.Permission).ToHashSet(); userGroup.HasAccessToAllLanguages = dto.HasAccessToAllLanguages; if (dto.UserGroup2AppDtos != null) { @@ -80,7 +81,7 @@ internal static class UserGroupFactory if (entity.HasIdentity) { - dto.Id = short.Parse(entity.Id.ToString()); + dto.Id = entity.Id; } return dto; diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index 9841ae9d0c..0df84fa20f 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -289,6 +289,27 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended return Database.ExecuteScalar(sql) > 0; } + /// + public bool Exists(Guid key, Guid objectType) + { + Sql sql = Sql() + .SelectCount() + .From() + .Where(x => x.UniqueId == key && x.NodeObjectType == objectType); + + return Database.ExecuteScalar(sql) > 0; + } + + public bool Exists(int id, Guid objectType) + { + Sql sql = Sql() + .SelectCount() + .From() + .Where(x => x.NodeId == id && x.NodeObjectType == objectType); + + return Database.ExecuteScalar(sql) > 0; + } + public bool Exists(int id) { Sql sql = Sql().SelectCount().From().Where(x => x.NodeId == id); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs index 33d666dfe4..b00c1875c5 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs @@ -1,3 +1,4 @@ +using System.Collections; using Microsoft.Extensions.Logging; using NPoco; using Umbraco.Cms.Core; @@ -25,7 +26,12 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG private readonly IShortStringHelper _shortStringHelper; private readonly UserGroupWithUsersRepository _userGroupWithUsersRepository; - public UserGroupRepository(IScopeAccessor scopeAccessor, AppCaches appCaches, ILogger logger, ILoggerFactory loggerFactory, IShortStringHelper shortStringHelper) + public UserGroupRepository( + IScopeAccessor scopeAccessor, + AppCaches appCaches, + ILogger logger, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper) : base(scopeAccessor, appCaches, logger) { _shortStringHelper = shortStringHelper; @@ -299,6 +305,7 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG } dto.UserGroup2LanguageDtos = GetUserGroupLanguages(id); + dto.UserGroup2PermissionDtos = GetUserGroupPermissions(id); IUserGroup userGroup = UserGroupFactory.BuildEntity(_shortStringHelper, dto); return userGroup; @@ -322,13 +329,7 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG List dtos = Database.FetchOneToMany(x => x.UserGroup2AppDtos, sql); - IDictionary> dic = GetAllUserGroupLanguageGrouped(); - - foreach (UserGroupDto dto in dtos) - { - dic.TryGetValue(dto.Id, out var userGroup2LanguageDtos); - dto.UserGroup2LanguageDtos = userGroup2LanguageDtos ?? new(); - } + AssignUserGroupOneToManyTables(ref dtos); return dtos.Select(x => UserGroupFactory.BuildEntity(_shortStringHelper, x)); } @@ -342,10 +343,28 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG AppendGroupBy(sql); sql.OrderBy(x => x.Id); // required for references - List? dtos = Database.FetchOneToMany(x => x.UserGroup2AppDtos, sql); + List dtos = Database.FetchOneToMany(x => x.UserGroup2AppDtos, sql); + + AssignUserGroupOneToManyTables(ref dtos); + return dtos.Select(x => UserGroupFactory.BuildEntity(_shortStringHelper, x)); } + private void AssignUserGroupOneToManyTables(ref List userGroupDtos) + { + IDictionary> userGroups2Languages = GetAllUserGroupLanguageGrouped(); + IDictionary> userGroups2Permissions = GetAllUserGroupPermissionsGrouped(); + + foreach (UserGroupDto dto in userGroupDtos) + { + userGroups2Languages.TryGetValue(dto.Id, out List? userGroup2LanguageDtos); + dto.UserGroup2LanguageDtos = userGroup2LanguageDtos ?? new List(); + + userGroups2Permissions.TryGetValue(dto.Id, out List? userGroup2PermissionDtos); + dto.UserGroup2PermissionDtos = userGroup2PermissionDtos ?? new List(); + } + } + #endregion #region Overrides of EntityRepositoryBase @@ -417,6 +436,7 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG "DELETE FROM umbracoUserGroup2App WHERE userGroupId = @id", "DELETE FROM umbracoUserGroup2Node WHERE userGroupId = @id", "DELETE FROM umbracoUserGroup2NodePermission WHERE userGroupId = @id", + "DELETE FROM umbracoUserGroup2Permission WHERE userGroupId = @id", "DELETE FROM umbracoUserGroup WHERE id = @id", }; return list; @@ -433,6 +453,7 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG PersistAllowedSections(entity); PersistAllowedLanguages(entity); + PersistPermissions(entity); entity.ResetDirtyProperties(); } @@ -446,7 +467,8 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG Database.Update(userGroupDto); PersistAllowedSections(entity); - PersistAllowedLanguages(entity); + PersistAllowedLanguages(entity); + PersistPermissions(entity); entity.ResetDirtyProperties(); } @@ -466,43 +488,74 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG } } - private void PersistAllowedLanguages(IUserGroup entity) + private void PersistAllowedLanguages(IUserGroup entity) + { + var userGroup = entity; + + // First delete all + Database.Delete("WHERE UserGroupId = @UserGroupId", new { UserGroupId = userGroup.Id }); + + // Then re-add any associated with the group + foreach (var language in userGroup.AllowedLanguages) { - var userGroup = entity; - - // First delete all - Database.Delete("WHERE UserGroupId = @UserGroupId", new { UserGroupId = userGroup.Id }); - - // Then re-add any associated with the group - foreach (var language in userGroup.AllowedLanguages) + var dto = new UserGroup2LanguageDto { - var dto = new UserGroup2LanguageDto - { - UserGroupId = userGroup.Id, - LanguageId = language, - }; + UserGroupId = userGroup.Id, + LanguageId = language, + }; - Database.Insert(dto); - } + Database.Insert(dto); } + } - private List GetUserGroupLanguages(int userGroupId) + private void PersistPermissions(IUserGroup userGroup) + { + Database.Delete("WHERE UserGroupId = @UserGroupId", new { UserGroupId = userGroup.Id }); + + foreach (var permission in userGroup.PermissionNames) { - Sql query = Sql() - .Select() - .From() - .Where(x => x.UserGroupId == userGroupId); - return Database.Fetch(query); + var permissionDto = new UserGroup2PermissionDto { UserGroupId = userGroup.Id, Permission = permission, }; + Database.Insert(permissionDto); } + } - private IDictionary> GetAllUserGroupLanguageGrouped() - { - Sql query = Sql() - .Select() - .From(); - List userGroupLanguages = Database.Fetch(query); - return userGroupLanguages.GroupBy(x => x.UserGroupId).ToDictionary(x => x.Key, x => x.ToList()); - } + private List GetUserGroupLanguages(int userGroupId) + { + Sql query = Sql() + .Select() + .From() + .Where(x => x.UserGroupId == userGroupId); + return Database.Fetch(query); + } + + private IDictionary> GetAllUserGroupLanguageGrouped() + { + Sql query = Sql() + .Select() + .From(); + List userGroupLanguages = Database.Fetch(query); + return userGroupLanguages.GroupBy(x => x.UserGroupId).ToDictionary(x => x.Key, x => x.ToList()); + } + + private List GetUserGroupPermissions(int userGroupId) + { + Sql query = Sql() + .Select() + .From() + .Where(x => x.UserGroupId == userGroupId); + + return Database.Fetch(query); + } + + private Dictionary> GetAllUserGroupPermissionsGrouped() + { + Sql query = Sql() + .Select() + .From(); + + List userGroupPermissions = Database.Fetch(query); + return userGroupPermissions.GroupBy(x => x.UserGroupId).ToDictionary(x => x.Key, x => x.ToList()); + } #endregion } diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 0d2767dd25..a4ac523681 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -28,6 +28,7 @@ public class BackOfficeUserStore : UmbracoUserStore @@ -43,7 +44,8 @@ public class BackOfficeUserStore : UmbracoUserStore globalSettings, + IOptionsSnapshot globalSettings, IUmbracoMapper mapper, BackOfficeErrorDescriber describer, - AppCaches appCaches) + AppCaches appCaches, + ITwoFactorLoginService twoFactorLoginService) : this( scopeProvider, userService, entityService, externalLoginService, - StaticServiceProvider.Instance.GetRequiredService>(), + globalSettings, mapper, describer, appCaches, - StaticServiceProvider.Instance.GetRequiredService()) + twoFactorLoginService, + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -390,7 +395,7 @@ public class BackOfficeUserStore : UmbracoUserStore users = _userService.GetAllInGroup(userGroup?.Id); IList backOfficeIdentityUsers = @@ -495,7 +500,7 @@ public class BackOfficeUserStore : UmbracoUserStore?>(null); @@ -670,7 +675,7 @@ public class BackOfficeUserStore : UmbracoUserStore x.ToReadOnlyGroup()).ToArray(); // use all of the ones assigned and add them diff --git a/src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs b/src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs index db4b538f6a..02180322ee 100644 --- a/src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs +++ b/src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs @@ -31,12 +31,36 @@ public sealed class UmbracoJsonTypeInfoResolver : DefaultJsonTypeInfoResolver, I { JsonTypeInfo result = base.GetTypeInfo(type, options); - if (!type.IsInterface) + if (type.IsInterface) + { + return GetTypeInfoForInterface(result, type, options); + } + else + { + return GetTypeInfoForClass(result, type, options); + } + + } + + private JsonTypeInfo GetTypeInfoForClass(JsonTypeInfo result, Type type, JsonSerializerOptions options) + { + if (result.Kind != JsonTypeInfoKind.Object || !type.GetInterfaces().Any()) { return result; } - Type[] subTypes = FindSubTypes(type).ToArray(); + JsonPolymorphismOptions jsonPolymorphismOptions = result.PolymorphismOptions ?? new JsonPolymorphismOptions(); + + jsonPolymorphismOptions.DerivedTypes.Add(new JsonDerivedType(type, type.Name)); + + result.PolymorphismOptions = jsonPolymorphismOptions; + + return result; + } + + private JsonTypeInfo GetTypeInfoForInterface(JsonTypeInfo result, Type type, JsonSerializerOptions options) + { + IEnumerable subTypes = FindSubTypes(type); if (!subTypes.Any()) { @@ -56,6 +80,7 @@ public sealed class UmbracoJsonTypeInfoResolver : DefaultJsonTypeInfoResolver, I result.PolymorphismOptions = jsonPolymorphismOptions; + return result; } } diff --git a/src/Umbraco.Infrastructure/Telemetry/Providers/UserTelemetryProvider.cs b/src/Umbraco.Infrastructure/Telemetry/Providers/UserTelemetryProvider.cs index 5b9023da9a..f9ddfb8cb3 100644 --- a/src/Umbraco.Infrastructure/Telemetry/Providers/UserTelemetryProvider.cs +++ b/src/Umbraco.Infrastructure/Telemetry/Providers/UserTelemetryProvider.cs @@ -1,4 +1,6 @@ +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Telemetry.Interfaces; @@ -7,14 +9,26 @@ namespace Umbraco.Cms.Infrastructure.Telemetry.Providers; public class UserTelemetryProvider : IDetailedTelemetryProvider { + private readonly IUserGroupService _userGroupService; private readonly IUserService _userService; - public UserTelemetryProvider(IUserService userService) => _userService = userService; + [Obsolete("Use constructor that takes IUserGroupService, scheduled for removal in V15")] + public UserTelemetryProvider(IUserService userService) + : this(userService, StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [Obsolete("Use constructor that only takes IUserGroupService, scheduled for removal in V15")] + public UserTelemetryProvider(IUserService userService, IUserGroupService userGroupService) + { + _userService = userService; + _userGroupService = userGroupService; + } public IEnumerable GetInformation() { _userService.GetAll(1, 1, out var total); - var userGroups = _userService.GetAllUserGroups().Count(); + var userGroups = _userGroupService.GetAllAsync(0, int.MaxValue).GetAwaiter().GetResult().Items.Count(); yield return new UsageInformation(Constants.Telemetry.UserCount, total); yield return new UsageInformation(Constants.Telemetry.UserGroupCount, userGroups); diff --git a/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml b/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml new file mode 100644 index 0000000000..a4d1773597 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml @@ -0,0 +1,10 @@ + + + + CP0002 + M:Umbraco.Cms.Web.BackOffice.Controllers.ContentController.#ctor(Umbraco.Cms.Core.Dictionary.ICultureDictionary,Microsoft.Extensions.Logging.ILoggerFactory,Umbraco.Cms.Core.Strings.IShortStringHelper,Umbraco.Cms.Core.Events.IEventMessagesFactory,Umbraco.Cms.Core.Services.ILocalizedTextService,Umbraco.Cms.Core.PropertyEditors.PropertyEditorCollection,Umbraco.Cms.Core.Services.IContentService,Umbraco.Cms.Core.Services.IUserService,Umbraco.Cms.Core.Security.IBackOfficeSecurityAccessor,Umbraco.Cms.Core.Services.IContentTypeService,Umbraco.Cms.Core.Mapping.IUmbracoMapper,Umbraco.Cms.Core.Routing.IPublishedUrlProvider,Umbraco.Cms.Core.Services.IDomainService,Umbraco.Cms.Core.Services.IDataTypeService,Umbraco.Cms.Core.Services.ILocalizationService,Umbraco.Cms.Core.Services.IFileService,Umbraco.Cms.Core.Services.INotificationService,Umbraco.Cms.Core.Actions.ActionCollection,Umbraco.Cms.Infrastructure.Persistence.ISqlContext,Umbraco.Cms.Core.Serialization.IJsonSerializer,Umbraco.Cms.Core.Scoping.ICoreScopeProvider,Microsoft.AspNetCore.Authorization.IAuthorizationService,Umbraco.Cms.Core.Services.IContentVersionService) + lib/net7.0/Umbraco.Web.BackOffice.dll + lib/net7.0/Umbraco.Web.BackOffice.dll + true + + \ No newline at end of file diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index 642db289a0..a2d34a8f96 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -58,6 +58,7 @@ public class ContentController : ContentControllerBase private readonly ILocalizedTextService _localizedTextService; private readonly INotificationService _notificationService; private readonly ICultureImpactFactory _cultureImpactFactory; + private readonly IUserGroupService _userGroupService; private readonly ILogger _logger; private readonly PropertyEditorCollection _propertyEditors; private readonly IPublishedUrlProvider _publishedUrlProvider; @@ -91,7 +92,8 @@ public class ContentController : ContentControllerBase ICoreScopeProvider scopeProvider, IAuthorizationService authorizationService, IContentVersionService contentVersionService, - ICultureImpactFactory cultureImpactFactory) + ICultureImpactFactory cultureImpactFactory, + IUserGroupService userGroupService) : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, serializer) { _propertyEditors = propertyEditors; @@ -112,13 +114,14 @@ public class ContentController : ContentControllerBase _authorizationService = authorizationService; _contentVersionService = contentVersionService; _cultureImpactFactory = cultureImpactFactory; + _userGroupService = userGroupService; _logger = loggerFactory.CreateLogger(); _scopeProvider = scopeProvider; _allLangs = new Lazy>(() => - _localizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase)); + _localizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase)); } - [Obsolete("Use constructor that accepts ICultureImpactService as a parameter, scheduled for removal in V12")] + [Obsolete("User constructor that takes a IUserGroupService, scheduled for removal in V15.")] public ContentController( ICultureDictionary cultureDictionary, ILoggerFactory loggerFactory, @@ -142,7 +145,8 @@ public class ContentController : ContentControllerBase IJsonSerializer serializer, ICoreScopeProvider scopeProvider, IAuthorizationService authorizationService, - IContentVersionService contentVersionService) + IContentVersionService contentVersionService, + ICultureImpactFactory cultureImpactFactory) : this( cultureDictionary, loggerFactory, @@ -167,9 +171,11 @@ public class ContentController : ContentControllerBase scopeProvider, authorizationService, contentVersionService, - StaticServiceProvider.Instance.GetRequiredService()) - { - } + cultureImpactFactory, + StaticServiceProvider.Instance.GetRequiredService() + ) + { + } public object? Domains { get; private set; } @@ -224,7 +230,7 @@ public class ContentController : ContentControllerBase var contentPermissions = _contentService.GetPermissions(content) .ToDictionary(x => x.UserGroupId, x => x); - IUserGroup[] allUserGroups = _userService.GetAllUserGroups().ToArray(); + IUserGroup[] allUserGroups = _userGroupService.GetAllAsync(0, int.MaxValue).GetAwaiter().GetResult().Items.ToArray(); //loop through each user group foreach (IUserGroup userGroup in allUserGroups) @@ -277,7 +283,7 @@ public class ContentController : ContentControllerBase // TODO: Should non-admins be able to see detailed permissions? - IEnumerable allUserGroups = _userService.GetAllUserGroups(); + IEnumerable allUserGroups = _userGroupService.GetAllAsync(0, int.MaxValue).GetAwaiter().GetResult().Items; return GetDetailedPermissions(content, allUserGroups); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs b/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs index ff9160b1c6..8a78afa2c6 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs @@ -7,6 +7,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Web.BackOffice.Controllers; +[Obsolete("Use IUserGroupAuthorizationService instead, should be removed alongside UserGroup controller in V13.")] internal class UserGroupEditorAuthorizationHelper { private readonly AppCaches _appCaches; diff --git a/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs b/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs index 280b8cf30f..b259fbc897 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs @@ -18,6 +18,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers; [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] [Authorize(Policy = AuthorizationPolicies.SectionAccessUsers)] [PrefixlessBodyModelValidator] +[Obsolete("Use the new user group controllers instead.")] public class UserGroupsController : BackOfficeNotificationsController { private readonly AppCaches _appCaches; diff --git a/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs index 73cf10d983..77f87c449c 100644 --- a/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs @@ -20,13 +20,14 @@ internal sealed class UserGroupValidateAttribute : TypeFilterAttribute private class UserGroupValidateFilter : IActionFilter { private readonly IShortStringHelper _shortStringHelper; - private readonly IUserService _userService; + private readonly IUserGroupService _userGroupService; public UserGroupValidateFilter( - IUserService userService, + IUserGroupService userGroupService, IShortStringHelper shortStringHelper) { - _userService = userService ?? throw new ArgumentNullException(nameof(userService)); + ArgumentNullException.ThrowIfNull(userGroupService); + _userGroupService = userGroupService; _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); } @@ -45,7 +46,7 @@ internal sealed class UserGroupValidateAttribute : TypeFilterAttribute switch (userGroupSave?.Action) { case ContentSaveAction.Save: - persisted = _userService.GetUserGroupById(Convert.ToInt32(userGroupSave.Id)); + persisted = _userGroupService.GetAsync(Convert.ToInt32(userGroupSave.Id)).GetAwaiter().GetResult(); if (persisted == null) { var message = $"User group with id: {userGroupSave.Id} was not found"; @@ -73,7 +74,7 @@ internal sealed class UserGroupValidateAttribute : TypeFilterAttribute //now assign the persisted entity to the model so we can use it in the action userGroupSave.PersistedUserGroup = persisted; - IUserGroup? existing = _userService.GetUserGroupByAlias(userGroupSave.Alias); + IUserGroup? existing = _userGroupService.GetAsync(userGroupSave.Alias).GetAwaiter().GetResult(); if (existing != null && existing.Id != userGroupSave.PersistedUserGroup.Id) { context.ModelState.AddModelError("Alias", "A user group with this alias already exists"); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserGroupServiceValidationTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserGroupServiceValidationTests.cs new file mode 100644 index 0000000000..6bf09e2a00 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserGroupServiceValidationTests.cs @@ -0,0 +1,151 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class UserGroupServiceValidationTests : UmbracoIntegrationTest +{ + private IUserGroupService UserGroupService => GetRequiredService(); + + private IShortStringHelper ShortStringHelper => GetRequiredService(); + + [Test] + public async Task Cannot_create_user_group_with_name_equals_null() + { + var userGroup = new UserGroup(ShortStringHelper) + { + Name = null + }; + + var result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserGroupOperationStatus.MissingName, result.Status); + } + + [Test] + public async Task Cannot_create_user_group_with_name_longer_than_max_length() + { + var userGroup = new UserGroup(ShortStringHelper) + { + Name = "Sed porttitor lectus nibh. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Vivamus suscipit tortor eget felis porttitor volutpat. Quisque velit nisi, pretium ut lacinia in, elementum id enim." + }; + + var result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserGroupOperationStatus.NameTooLong, result.Status); + } + + [Test] + public async Task Cannot_create_user_group_with_alias_longer_than_max_length() + { + var userGroup = new UserGroup(ShortStringHelper) + { + Name = "Some Name", + Alias = "Sed porttitor lectus nibh. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Vivamus suscipit tortor eget felis porttitor volutpat. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Vivamus suscipit tortor eget felis porttitor volutpat. Quisque velit nisi, pretium ut lacinia in, elementum id enim." + }; + + var result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserGroupOperationStatus.AliasTooLong, result.Status); + } + + [Test] + public async Task Cannot_update_non_existing_user_group() + { + var userGroup = new UserGroup(ShortStringHelper) + { + Name = "Some Name", + Alias = "someAlias" + }; + + var result = await UserGroupService.UpdateAsync(userGroup, Constants.Security.SuperUserId); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserGroupOperationStatus.NotFound, result.Status); + } + + [Test] + public async Task Cannot_create_existing_user_group() + { + var userGroup = new UserGroup(ShortStringHelper) + { + Name = "Some Name", + Alias = "someAlias" + }; + + var result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId); + + Assert.IsTrue(result.Success); + + result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserGroupOperationStatus.AlreadyExists, result.Status); + } + + [Test] + public async Task Cannot_create_user_group_with_duplicate_alias() + { + var alias = "duplicateAlias"; + + var existingUserGroup = new UserGroup(ShortStringHelper) + { + Name = "I already exist", + Alias = alias + }; + var setupResult = await UserGroupService.CreateAsync(existingUserGroup, Constants.Security.SuperUserId); + Assert.IsTrue(setupResult.Success); + + var newUserGroup = new UserGroup(ShortStringHelper) + { + Name = "I have a duplicate alias", + Alias = alias, + }; + var result = await UserGroupService.CreateAsync(newUserGroup, Constants.Security.SuperUserId); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserGroupOperationStatus.DuplicateAlias, result.Status); + } + + [Test] + public async Task Cannot_update_user_group_with_duplicate_alias() + { + var alias = "duplicateAlias"; + + var existingUserGroup = new UserGroup(ShortStringHelper) + { + Name = "I already exist", + Alias = alias + }; + var setupResult = await UserGroupService.CreateAsync(existingUserGroup, Constants.Security.SuperUserId); + Assert.IsTrue(setupResult.Success); + + IUserGroup userGroupToUpdate = new UserGroup(ShortStringHelper) + { + Name = "I don't have a duplicate alias", + Alias = "somAlias", + }; + var creationResult = await UserGroupService.CreateAsync(userGroupToUpdate, Constants.Security.SuperUserId); + Assert.IsTrue(creationResult.Success); + + + userGroupToUpdate = creationResult.Result; + userGroupToUpdate.Name = "Now I have a duplicate alias"; + userGroupToUpdate.Alias = alias; + + var updateResult = await UserGroupService.UpdateAsync(userGroupToUpdate, Constants.Security.SuperUserId); + Assert.IsFalse(updateResult.Success); + Assert.AreEqual(UserGroupOperationStatus.DuplicateAlias, updateResult.Status); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs index 7046972d83..a4f5d7436f 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs @@ -266,7 +266,8 @@ public class ContentControllerTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); return controller; } From d7ef924c32f44a6dd9a506202f06d8fb4bd14de0 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Thu, 16 Feb 2023 13:08:57 +0100 Subject: [PATCH 12/32] Miniprofiler for v13 (#13841) * Updated miniprofiler and added a few configurations * added fixme * Remove file that should not have been committed * added ignore list. We check the entire path and ignore client requests anyway --- .../UmbracoBuilderExtensions.cs | 17 +++----- .../Profiler/ConfigureMiniProfilerOptions.cs | 43 +++++++++++++++++++ .../Profiler/WebProfiler.cs | 24 +++++++++-- 3 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 src/Umbraco.Web.Common/Profiler/ConfigureMiniProfilerOptions.cs diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index dc94fa3dc8..e44a7449aa 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -109,7 +109,7 @@ public static partial class UmbracoBuilderExtensions services.ConfigureOptions(); services.ConfigureOptions(); - IProfiler profiler = GetWebProfiler(config); + IProfiler profiler = GetWebProfiler(config, httpContextAccessor); services.AddLogger(webHostEnvironment, config); @@ -200,15 +200,8 @@ public static partial class UmbracoBuilderExtensions { builder.Services.AddSingleton(); - builder.Services.AddMiniProfiler(options => - { - // WebProfiler determine and start profiling. We should not use the MiniProfilerMiddleware to also profile - options.ShouldProfile = request => false; - - // this is a default path and by default it performs a 'contains' check which will match our content controller - // (and probably other requests) and ignore them. - options.IgnoredPaths.Remove("/content/"); - }); + builder.Services.AddMiniProfiler(); + builder.Services.ConfigureOptions(); builder.AddNotificationHandler(); return builder; @@ -385,7 +378,7 @@ public static partial class UmbracoBuilderExtensions return builder; } - private static IProfiler GetWebProfiler(IConfiguration config) + private static IProfiler GetWebProfiler(IConfiguration config, IHttpContextAccessor httpContextAccessor) { var isDebug = config.GetValue($"{Constants.Configuration.ConfigHosting}:Debug"); @@ -397,7 +390,7 @@ public static partial class UmbracoBuilderExtensions return new NoopProfiler(); } - var webProfiler = new WebProfiler(); + var webProfiler = new WebProfiler(httpContextAccessor); webProfiler.StartBoot(); return webProfiler; diff --git a/src/Umbraco.Web.Common/Profiler/ConfigureMiniProfilerOptions.cs b/src/Umbraco.Web.Common/Profiler/ConfigureMiniProfilerOptions.cs new file mode 100644 index 0000000000..4239ba1737 --- /dev/null +++ b/src/Umbraco.Web.Common/Profiler/ConfigureMiniProfilerOptions.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using StackExchange.Profiling; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Common.Profiler; + +internal sealed class ConfigureMiniProfilerOptions : IConfigureOptions +{ + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly string _backOfficePath; + + public ConfigureMiniProfilerOptions( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IOptions globalSettings, + IHostingEnvironment hostingEnvironment) + { + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _backOfficePath = globalSettings.Value.GetBackOfficePath(hostingEnvironment); + } + + public void Configure(MiniProfilerOptions options) + { + options.RouteBasePath = WebPath.Combine(_backOfficePath, "profiler"); + // WebProfiler determine and start profiling. We should not use the MiniProfilerMiddleware to also profile + options.ShouldProfile = request => false; + + options.IgnoredPaths.Clear(); + options.IgnoredPaths.Add(WebPath.Combine(_backOfficePath, "swagger")); + options.IgnoredPaths.Add(WebPath.Combine(options.RouteBasePath, "results-list")); + options.IgnoredPaths.Add(WebPath.Combine(options.RouteBasePath, "results-index")); + options.IgnoredPaths.Add(WebPath.Combine(options.RouteBasePath, "results")); + + options.ResultsAuthorize = IsBackofficeUserAuthorized; + options.ResultsListAuthorize = IsBackofficeUserAuthorized; + } + + private bool IsBackofficeUserAuthorized(HttpRequest request) => true;// FIXME when we can get current backoffice user, _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser is not null; +} diff --git a/src/Umbraco.Web.Common/Profiler/WebProfiler.cs b/src/Umbraco.Web.Common/Profiler/WebProfiler.cs index 9608bad715..50a0de19a9 100644 --- a/src/Umbraco.Web.Common/Profiler/WebProfiler.cs +++ b/src/Umbraco.Web.Common/Profiler/WebProfiler.cs @@ -1,6 +1,7 @@ using System.Net; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using StackExchange.Profiling; @@ -14,6 +15,14 @@ namespace Umbraco.Cms.Web.Common.Profiler; public class WebProfiler : IProfiler { + private readonly IHttpContextAccessor _httpContextAccessor; + + public WebProfiler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public static readonly AsyncLocal MiniProfilerContext = new(x => { _ = x; @@ -24,11 +33,12 @@ public class WebProfiler : IProfiler private int _first; private MiniProfiler? _startupProfiler; - public IDisposable? Step(string name) => MiniProfiler.Current?.Step(name); + public IDisposable? Step(string name) => + MiniProfiler.Current?.Step(name); public void Start() { - MiniProfiler.StartNew(); + MiniProfiler.StartNew(_httpContextAccessor.HttpContext?.Request.Path); MiniProfilerContext.Value = MiniProfiler.Current; } @@ -75,7 +85,6 @@ public class WebProfiler : IProfiler { AddSubProfiler(_startupProfiler); } - _startupProfiler = null; } @@ -102,13 +111,20 @@ public class WebProfiler : IProfiler private static ICookieManager GetCookieManager(HttpContext context) => context.RequestServices.GetRequiredService(); - private static bool ShouldProfile(HttpRequest request) + private bool ShouldProfile(HttpRequest request) { if (request.IsClientSideRequest()) { return false; } + var miniprofilerOptions = _httpContextAccessor.HttpContext?.RequestServices?.GetService>(); + if (miniprofilerOptions is not null && miniprofilerOptions.Value.IgnoredPaths.Contains(request.Path)) + { + return false; + } + + if (bool.TryParse(request.Query["umbDebug"], out var umbDebug)) { return umbDebug; From 8f9c67ffee9431bfdeda08c681165a384fb0dedb Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Thu, 16 Feb 2023 15:15:59 +0100 Subject: [PATCH 13/32] Handle static assets for the website directly in static assets project. These should not be build by the backoffice scripts as they are not owned by backoffice --- .../Umbraco.Cms.StaticAssets.csproj | 32 +++++++++++++++++-- .../umbraco/UmbracoWebsite/Maintenance.cshtml | 2 +- .../umbraco/UmbracoWebsite/NoNodes.cshtml | 2 +- .../umbraco/UmbracoWebsite/NotFound.cshtml | 2 +- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj b/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj index d082dff945..5a8dd128a5 100644 --- a/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj +++ b/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj @@ -16,7 +16,9 @@ - $(ProjectDir)wwwroot\umbraco + $(ProjectDir)wwwroot\umbraco + $(BasePath)\lib + $(BasePath)\backoffice @@ -24,9 +26,19 @@ + + + + + + + + + + @@ -36,12 +48,26 @@ + + + + + + + - + - + + + + + + + + diff --git a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml index 46739cdef7..94de5f3c52 100644 --- a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml +++ b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml @@ -17,7 +17,7 @@ Website is Under Maintainance - +