From c2ed6a629bcc46af66c5a7a578281115b27d05f9 Mon Sep 17 00:00:00 2001 From: Matthew Care Date: Fri, 15 Oct 2021 23:22:01 +0100 Subject: [PATCH 01/53] Update request handler settings `CharCollection` didn't map correctly from the config, updated to an array so that it does Add logic to concatenate user and default replacements, replacing defaults with user defined if present. Added additional option to disable the default replacements --- .../Models/RequestHandlerSettings.cs | 114 ++++++++++-------- 1 file changed, 62 insertions(+), 52 deletions(-) diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index ee223b36c6..79dabf9da0 100644 --- a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using Umbraco.Cms.Core.Configuration.UmbracoSettings; using Umbraco.Extensions; @@ -16,33 +17,34 @@ namespace Umbraco.Cms.Core.Configuration.Models { internal const bool StaticAddTrailingSlash = true; internal const string StaticConvertUrlsToAscii = "try"; + internal const bool StaticEnableDefaultCharReplacements = true; - internal static readonly CharItem[] DefaultCharCollection = + internal static readonly CharacterReplacement[] DefaultCharCollection = { - new CharItem { Char = " ", Replacement = "-" }, - new CharItem { Char = "\"", Replacement = string.Empty }, - new CharItem { Char = "'", Replacement = string.Empty }, - new CharItem { Char = "%", Replacement = string.Empty }, - new CharItem { Char = ".", Replacement = string.Empty }, - new CharItem { Char = ";", Replacement = string.Empty }, - new CharItem { Char = "/", Replacement = string.Empty }, - new CharItem { Char = "\\", Replacement = string.Empty }, - new CharItem { Char = ":", Replacement = string.Empty }, - new CharItem { Char = "#", Replacement = string.Empty }, - new CharItem { Char = "+", Replacement = "plus" }, - new CharItem { Char = "*", Replacement = "star" }, - new CharItem { Char = "&", Replacement = string.Empty }, - new CharItem { Char = "?", Replacement = string.Empty }, - new CharItem { Char = "æ", Replacement = "ae" }, - new CharItem { Char = "ä", Replacement = "ae" }, - new CharItem { Char = "ø", Replacement = "oe" }, - new CharItem { Char = "ö", Replacement = "oe" }, - new CharItem { Char = "å", Replacement = "aa" }, - new CharItem { Char = "ü", Replacement = "ue" }, - new CharItem { Char = "ß", Replacement = "ss" }, - new CharItem { Char = "|", Replacement = "-" }, - new CharItem { Char = "<", Replacement = string.Empty }, - new CharItem { Char = ">", Replacement = string.Empty } + new () { Char = " ", Replacement = "-" }, + new () { Char = "\"", Replacement = string.Empty }, + new () { Char = "'", Replacement = string.Empty }, + new () { Char = "%", Replacement = string.Empty }, + new () { Char = ".", Replacement = string.Empty }, + new () { Char = ";", Replacement = string.Empty }, + new () { Char = "/", Replacement = string.Empty }, + new () { Char = "\\", Replacement = string.Empty }, + new () { Char = ":", Replacement = string.Empty }, + new () { Char = "#", Replacement = string.Empty }, + new () { Char = "+", Replacement = "plus" }, + new () { Char = "*", Replacement = "star" }, + new () { Char = "&", Replacement = string.Empty }, + new () { Char = "?", Replacement = string.Empty }, + new () { Char = "æ", Replacement = "ae" }, + new () { Char = "ä", Replacement = "ae" }, + new () { Char = "ø", Replacement = "oe" }, + new () { Char = "ö", Replacement = "oe" }, + new () { Char = "å", Replacement = "aa" }, + new () { Char = "ü", Replacement = "ue" }, + new () { Char = "ß", Replacement = "ss" }, + new () { Char = "|", Replacement = "-" }, + new () { Char = "<", Replacement = string.Empty }, + new () { Char = ">", Replacement = string.Empty } }; /// @@ -67,41 +69,49 @@ namespace Umbraco.Cms.Core.Configuration.Models /// public bool ShouldTryConvertUrlsToAscii => ConvertUrlsToAscii.InvariantEquals("try"); - // We need to special handle ":", as this character is special in keys - - // TODO: implement from configuration - - //// var collection = _configuration.GetSection(Prefix + "CharCollection").GetChildren() - //// .Select(x => new CharItem() - //// { - //// Char = x.GetValue("Char"), - //// Replacement = x.GetValue("Replacement"), - //// }).ToArray(); - - //// if (collection.Any() || _configuration.GetSection("Prefix").GetChildren().Any(x => - //// x.Key.Equals("CharCollection", StringComparison.OrdinalIgnoreCase))) - //// { - //// return collection; - //// } - - //// return DefaultCharCollection; + /// + /// Disable all default character replacements + /// + [DefaultValue(StaticEnableDefaultCharReplacements)] + public bool EnableDefaultCharReplacements { get; set; } = StaticEnableDefaultCharReplacements; /// - /// Gets or sets a value for the default character collection for replacements. + /// Add additional character replacements, or override defaults /// - /// WB-TODO - public IEnumerable CharCollection { get; set; } = DefaultCharCollection; + public CharacterReplacement[] CharCollection { get; set; } /// - /// Defines a character replacement. + /// Get concatenated user and default character replacements + /// taking into account /// - public class CharItem : IChar + public IEnumerable GetCharReplacements() { - /// - public string Char { get; set; } + // TODO We need to special handle ":", as this character is special in keys - /// - public string Replacement { get; set; } + if (!EnableDefaultCharReplacements) + { + return CharCollection; + } + + if (CharCollection == null || !CharCollection.Any()) + { + return DefaultCharCollection; + } + + foreach (var defaultReplacement in DefaultCharCollection) + { + foreach (var userReplacement in CharCollection) + { + if (userReplacement.Char == defaultReplacement.Char) + { + defaultReplacement.Replacement = userReplacement.Replacement; + } + } + } + + var mergedCollections = DefaultCharCollection.Union(CharCollection, new CharacterReplacementEqualityComparer()); + + return mergedCollections; } } } From 1ee4e379e797ef95e579c0c72476e3ac98898a7f Mon Sep 17 00:00:00 2001 From: Matthew Care Date: Fri, 15 Oct 2021 23:23:07 +0100 Subject: [PATCH 02/53] Update classes Update class names, and location --- .../UmbracoSettings/CharacterReplacement.cs | 15 +++++++ .../CharacterReplacementEqualityComparer.cs | 40 +++++++++++++++++++ .../Configuration/UmbracoSettings/IChar.cs | 8 ---- 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacement.cs create mode 100644 src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs delete mode 100644 src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacement.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacement.cs new file mode 100644 index 0000000000..ed2f97dba9 --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacement.cs @@ -0,0 +1,15 @@ +namespace Umbraco.Cms.Core.Configuration.UmbracoSettings +{ + public class CharacterReplacement + { + /// + /// The character to replace + /// + public string Char { get; set; } + + /// + /// The replacement character + /// + public string Replacement { get; set; } + } +} diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs new file mode 100644 index 0000000000..b7dbf1cd16 --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace Umbraco.Cms.Core.Configuration.UmbracoSettings +{ + public class CharacterReplacementEqualityComparer : IEqualityComparer + { + public bool Equals(CharacterReplacement x, CharacterReplacement y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null) + { + return false; + } + + if (y is null) + { + return false; + } + + if (x.GetType() != y.GetType()) + { + return false; + } + + return x.Char == y.Char && x.Replacement == y.Replacement; + } + + public int GetHashCode(CharacterReplacement obj) + { + unchecked + { + return ((obj.Char != null ? obj.Char.GetHashCode() : 0) * 397) ^ (obj.Replacement != null ? obj.Replacement.GetHashCode() : 0); + } + } + } +} diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs deleted file mode 100644 index 4073a12149..0000000000 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Umbraco.Cms.Core.Configuration.UmbracoSettings -{ - public interface IChar - { - string Char { get; } - string Replacement { get; } - } -} From 12e89a01ba52ae3ea74c3b0de3dfe05d1fe13983 Mon Sep 17 00:00:00 2001 From: Matthew Care Date: Fri, 15 Oct 2021 23:23:56 +0100 Subject: [PATCH 03/53] Update usage Update string helper to use new method that uses user defined replacements --- src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs index cf5e71a568..287d33dd58 100644 --- a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs +++ b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Umbraco.Cms.Core.Configuration.Models; @@ -60,7 +60,7 @@ namespace Umbraco.Cms.Core.Strings /// The short string helper. public DefaultShortStringHelperConfig WithDefault(RequestHandlerSettings requestHandlerSettings) { - UrlReplaceCharacters = requestHandlerSettings.CharCollection + UrlReplaceCharacters = requestHandlerSettings.GetCharReplacements() .Where(x => string.IsNullOrEmpty(x.Char) == false) .ToDictionary(x => x.Char, x => x.Replacement); From 25b1c3c0788eb1dbbd068aeef9e5a67741d50498 Mon Sep 17 00:00:00 2001 From: Matthew Care Date: Fri, 15 Oct 2021 23:24:21 +0100 Subject: [PATCH 04/53] Add tests Add new tests for the request handler settings, and fix other tests --- .../Models/RequestHandlerSettingsTests.cs | 90 +++++++++++++++++++ ...faultShortStringHelperTestsWithoutSetup.cs | 7 +- 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs new file mode 100644 index 0000000000..c16d9b4897 --- /dev/null +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs @@ -0,0 +1,90 @@ +using System.Linq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Configuration.UmbracoSettings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models +{ + [TestFixture] + public class RequestHandlerSettingsTests + { + [Test] + public void Given_CharCollection_With_DefaultEnabled_MergesCollection() + { + var userCollection = new CharacterReplacement[] + { + new() { Char = "test", Replacement = "replace" }, + new() { Char = "test2", Replacement = "replace2" } + }; + + + var settings = new RequestHandlerSettings { CharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); + + var expectedCollection = RequestHandlerSettings.DefaultCharCollection.ToList(); + expectedCollection.AddRange(userCollection); + + Assert.AreEqual(expectedCollection.Count, actual.Count); + Assert.That(actual, Is.EquivalentTo(expectedCollection)); + } + + [Test] + public void Given_CharCollection_With_DefaultDisabled_ReturnsUserCollection() + { + var userCollection = new CharacterReplacement[] + { + new() { Char = "test", Replacement = "replace" }, + new() { Char = "test2", Replacement = "replace2" } + }; + + var settings = new RequestHandlerSettings { CharCollection = userCollection, EnableDefaultCharReplacements = false }; + var actual = settings.GetCharReplacements().ToList(); + + Assert.AreEqual(userCollection.Length, actual.Count); + Assert.That(actual, Is.EquivalentTo(userCollection)); + } + + [Test] + public void Given_CharCollection_That_OverridesDefaultValues_ReturnsReplacements() + { + var userCollection = new CharacterReplacement[] + { + new() { Char = "%", Replacement = "percent" }, + new() { Char = ".", Replacement = "dot" } + }; + + var settings = new RequestHandlerSettings { CharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); + + Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length, actual.Count); + + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); + } + + [Test] + public void Given_CharCollection_That_OverridesDefaultValues_And_ContainsNew_ReturnsMergedWithReplacements() + { + var userCollection = new CharacterReplacement[] + { + new() { Char = "%", Replacement = "percent" }, + new() { Char = ".", Replacement = "dot" }, + new() {Char = "new", Replacement = "new"} + }; + + var settings = new RequestHandlerSettings { CharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); + + // Add 1 to the length, because we're expecting to only add one new one + Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length + 1, actual.Count); + + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "new" && x.Replacement == "new")); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); + } + } +} diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs index 6f9ee481cc..5aa8198e07 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System; using System.Diagnostics; using System.Linq; using System.Text; @@ -19,7 +20,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Enumerable.Empty(), + CharCollection = Array.Empty(), ConvertUrlsToAscii = "false" }; @@ -45,7 +46,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Enumerable.Empty(), + CharCollection = Array.Empty(), ConvertUrlsToAscii = "false" }; @@ -339,7 +340,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Enumerable.Empty(), + CharCollection = Array.Empty(), ConvertUrlsToAscii = "false" }; From 98474e111062b48f6d344b165503f98f8704438a Mon Sep 17 00:00:00 2001 From: Matthew Care Date: Fri, 15 Oct 2021 23:52:26 +0100 Subject: [PATCH 05/53] Fix broken test Update tests to disable the default replacements --- .../DefaultShortStringHelperTestsWithoutSetup.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs index 5aa8198e07..ee7999cac1 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs @@ -21,6 +21,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper var requestHandlerSettings = new RequestHandlerSettings() { CharCollection = Array.Empty(), + EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; @@ -47,6 +48,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper var requestHandlerSettings = new RequestHandlerSettings() { CharCollection = Array.Empty(), + EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; @@ -341,6 +343,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper var requestHandlerSettings = new RequestHandlerSettings() { CharCollection = Array.Empty(), + EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; From 5580f8003500f6587ae2bd04e2762b7a3eb1d7e9 Mon Sep 17 00:00:00 2001 From: Maarten Mensink Date: Fri, 10 Dec 2021 10:33:35 +0100 Subject: [PATCH 06/53] fix incorrect camelcasing closes #11752 --- src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs index ecc5b78a51..27eb5b7f6e 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs @@ -118,7 +118,7 @@ namespace Umbraco.Cms.Web.BackOffice.Trees // root actions menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(new MenuItem("importDocumentType", LocalizedTextService) + menu.Items.Add(new MenuItem("importdocumenttype", LocalizedTextService) { Icon = "page-up", SeparatorBefore = true, From bbbfb99a39eb83e9811ab0c7dbeaa77e0c987301 Mon Sep 17 00:00:00 2001 From: Maarten Mensink Date: Fri, 10 Dec 2021 11:26:51 +0100 Subject: [PATCH 07/53] update key for importdocumenttype --- src/Umbraco.Web.UI/umbraco/config/lang/cs.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/cy.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/da.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/de.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/en.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/es.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/fr.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/he.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/it.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/ja.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/ko.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/nb.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/nl.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/pl.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/pt.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/ru.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/sv.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/tr.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/zh.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml | 2 +- 21 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml b/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml index af701cd5e3..aab9329656 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml @@ -19,7 +19,7 @@ Vyprázdnit koš Aktivovat Exportovat typ dokumentu - Importovat typ dokumentu + Importovat typ dokumentu Importovat balíček Editovat na stránce Odhlásit diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml b/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml index 0692d01e7a..67815470cb 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml @@ -21,7 +21,7 @@ Gwagu bin ailgylchu Galluogi Allforio Math o Ddogfen - Mewnforio Math o Ddogfen + Mewnforio Math o Ddogfen Mewnforio Pecyn Golygu mewn Cynfas Gadael diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/da.xml b/src/Umbraco.Web.UI/umbraco/config/lang/da.xml index 1f50e3ebca..34444242d2 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/da.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/da.xml @@ -21,7 +21,7 @@ Tøm papirkurv Aktivér Eksportér dokumenttype - Importér dokumenttype + Importér dokumenttype Importér pakke Redigér i Canvas Log af diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/de.xml b/src/Umbraco.Web.UI/umbraco/config/lang/de.xml index 8f2ba350d0..2a456410f2 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/de.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/de.xml @@ -19,7 +19,7 @@ Papierkorb leeren Aktivieren Dokumenttyp exportieren - Dokumenttyp importieren + Dokumenttyp importieren Paket importieren 'Canvas'-Modus starten Abmelden diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml index b1da14db12..81f7358d31 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml @@ -20,7 +20,7 @@ Empty recycle bin Enable Export Document Type - Import Document Type + Import Document Type Import Package Edit in Canvas Exit diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml index 4f0bab5991..0fa8678272 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml @@ -21,7 +21,7 @@ Empty recycle bin Enable Export Document Type - Import Document Type + Import Document Type Import Package Edit in Canvas Exit diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/es.xml b/src/Umbraco.Web.UI/umbraco/config/lang/es.xml index df78683aca..f1b9f640fd 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/es.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/es.xml @@ -18,7 +18,7 @@ Vaciar Papelera Activar Exportar Documento (tipo) - Importar Documento (tipo) + Importar Documento (tipo) Importar Paquete Editar en vivo Cerrar sesión diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml b/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml index f134a3b56b..5aa7ca4fed 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml @@ -19,7 +19,7 @@ Vider la corbeille Activer Exporter le type de document - Importer un type de document + Importer un type de document Importer un package Editer dans Canvas Déconnexion diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/he.xml b/src/Umbraco.Web.UI/umbraco/config/lang/he.xml index 9ee8bbf014..e70cf053ac 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/he.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/he.xml @@ -15,7 +15,7 @@ נטרל רוקן סל מיחזור ייצא סוג קובץ - ייבא סוג מסמך + ייבא סוג מסמך ייבא חבילה ערוך במצב "קנבס" יציאה diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/it.xml b/src/Umbraco.Web.UI/umbraco/config/lang/it.xml index a0d89bff2d..de12bc9fb3 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/it.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/it.xml @@ -21,7 +21,7 @@ Svuota il cestino Abilita Esporta il tipo di documento - Importa il tipo di documento + Importa il tipo di documento Importa il pacchetto Modifica in Area di Lavoro Uscita diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml b/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml index 4b98adad26..7ad2521f76 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml @@ -16,7 +16,7 @@ 無効 ごみ箱を空にする ドキュメントタイプの書出 - ドキュメントタイプの読込 + ドキュメントタイプの読込 パッケージの読み込み ライブ編集 ログアウト diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml b/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml index 792dd6700c..5ad8ed2a96 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml @@ -15,7 +15,7 @@ 비활성 휴지통 비우기 추출 문서 유형 - 등록 문서 유형 + 등록 문서 유형 패키지 등록 캔버스 내용 편집 종료 diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml b/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml index 1c47969189..ca62ab10d2 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml @@ -16,7 +16,7 @@ Deaktiver Tøm papirkurv Eksporter dokumenttype - Importer dokumenttype + Importer dokumenttype Importer pakke Rediger i Canvas Logg av diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml index cd675f7056..23678534d2 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml @@ -21,7 +21,7 @@ Prullenbak leegmaken Inschakelen Documenttype exporteren - Documenttype importeren + Documenttype importeren Package importeren Aanpassen in Canvas Afsluiten diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml b/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml index dfbc324df6..acca411f42 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml @@ -18,7 +18,7 @@ Opróżnij kosz Aktywuj Eksportuj typ dokumentu - Importuj typ dokumentu + Importuj typ dokumentu Importuj zbiór Edytuj na stronie Wyjście diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml b/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml index 542b03abc1..7d6ed8dec9 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml @@ -15,7 +15,7 @@ Desabilitar Esvaziar Lixeira Exportar Tipo de Documento - Importar Tipo de Documento + Importar Tipo de Documento Importar Pacote Editar na Tela Sair diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml b/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml index 9c1d9e12fb..5de2647217 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml @@ -21,7 +21,7 @@ Включить Экспорт Экспортировать - Импортировать + Импортировать Импортировать пакет Править на месте Выйти diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml b/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml index e0e2235ae9..f954b5d6fa 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml @@ -22,7 +22,7 @@ Avaktivera Töm papperskorgen Exportera dokumenttyp - Importera dokumenttyp + Importera dokumenttyp Importera paket Redigera i Canvas Logga ut diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml b/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml index 58c0f7f94b..4f3f6575c5 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml @@ -20,7 +20,7 @@ Geri dönüşüm kutusunu boşalt Etkinleştir Belge Türünü Dışa Aktar - Belge Türünü İçe Aktar + Belge Türünü İçe Aktar Paketi İçe Aktar Kanvas'ta Düzenle Çıkış diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml b/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml index 423e5cca36..474f8ed229 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml @@ -16,7 +16,7 @@ 禁用 清空回收站 导出文档类型 - 导入文档类型 + 导入文档类型 导入扩展包 实时编辑模式 退出 diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml b/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml index 8d5cf16de2..1a41cdc1f9 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml @@ -16,7 +16,7 @@ 禁用 清空回收站 匯出文檔類型 - 導入文檔類型 + 導入文檔類型 導入擴展包 即時編輯模式 退出 From d9a4c50a73d9743ad9453ddb389aa6d549e864e2 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 14 Dec 2021 09:23:55 +0100 Subject: [PATCH 08/53] Refactored message related methods to allow provision of an HttpContext, and used this in DistributedCacheBinder to ensure messages created are flushed from the same context. --- .../BatchedDatabaseServerMessenger.cs | 27 ++++++++++---- .../Cache/DistributedCacheBinder.cs | 35 +++++++++++++++++-- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/Umbraco.Web/BatchedDatabaseServerMessenger.cs b/src/Umbraco.Web/BatchedDatabaseServerMessenger.cs index f89f62eb3d..a02cd2c128 100644 --- a/src/Umbraco.Web/BatchedDatabaseServerMessenger.cs +++ b/src/Umbraco.Web/BatchedDatabaseServerMessenger.cs @@ -68,9 +68,11 @@ namespace Umbraco.Web BatchMessage(refresher, messageType, idsA, arrayType, json); } - public void FlushBatch() + public void FlushBatch() => FlushBatch(null); + + internal void FlushBatch(HttpContextBase httpContext) { - var batch = GetBatch(false); + var batch = httpContext != null ? GetBatch(false, httpContext) : GetBatch(false); if (batch == null) return; var instructions = batch.SelectMany(x => x.Instructions).ToArray(); @@ -83,9 +85,9 @@ namespace Umbraco.Web { WriteInstructions(scope, instructionsBatch); } + scope.Complete(); } - } private void WriteInstructions(IScope scope, IEnumerable instructions) @@ -111,10 +113,15 @@ namespace Umbraco.Web // the case if the asp.net synchronization context has kicked in ?? (HttpContext.Current == null ? null : new HttpContextWrapper(HttpContext.Current)); - // if no context was found, return null - we cannot not batch + // if no context was found, return null - we cannot batch if (httpContext == null) return null; - var key = typeof (BatchedDatabaseServerMessenger).Name; + return GetBatch(create, httpContext); + } + + protected ICollection GetBatch(bool create, HttpContextBase httpContext) + { + var key = typeof(BatchedDatabaseServerMessenger).Name; // no thread-safety here because it'll run in only 1 thread (request) at a time var batch = (ICollection)httpContext.Items[key]; @@ -128,9 +135,17 @@ namespace Umbraco.Web MessageType messageType, IEnumerable ids = null, Type idType = null, + string json = null) => BatchMessage(refresher, messageType, null, ids, idType, json); + + protected void BatchMessage( + ICacheRefresher refresher, + MessageType messageType, + HttpContextBase httpContext, + IEnumerable ids = null, + Type idType = null, string json = null) { - var batch = GetBatch(true); + var batch = httpContext != null ? GetBatch(true, httpContext) : GetBatch(true); var instructions = RefreshInstruction.GetInstructions(refresher, messageType, ids, idType, json); // batch if we can, else write to DB immediately diff --git a/src/Umbraco.Web/Cache/DistributedCacheBinder.cs b/src/Umbraco.Web/Cache/DistributedCacheBinder.cs index bfb1a01a69..5987d425a5 100644 --- a/src/Umbraco.Web/Cache/DistributedCacheBinder.cs +++ b/src/Umbraco.Web/Cache/DistributedCacheBinder.cs @@ -9,6 +9,7 @@ using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Services; using Umbraco.Core.Services.Changes; +using Umbraco.Core.Sync; namespace Umbraco.Web.Cache { @@ -21,10 +22,12 @@ namespace Umbraco.Web.Cache private readonly DistributedCache _distributedCache; private readonly IUmbracoContextFactory _umbracoContextFactory; private readonly ILogger _logger; + private readonly BatchedDatabaseServerMessenger _serverMessenger; /// /// Initializes a new instance of the class. /// + [Obsolete("Please use the constructor accepting an instance of IServerMessenger. This constructor will be removed in a future version.")] public DistributedCacheBinder(DistributedCache distributedCache, IUmbracoContextFactory umbracoContextFactory, ILogger logger) { _distributedCache = distributedCache; @@ -32,6 +35,15 @@ namespace Umbraco.Web.Cache _umbracoContextFactory = umbracoContextFactory; } + /// + /// Initializes a new instance of the class. + /// + public DistributedCacheBinder(DistributedCache distributedCache, IUmbracoContextFactory umbracoContextFactory, ILogger logger, IServerMessenger serverMessenger) + : this(distributedCache, umbracoContextFactory, logger) + { + _serverMessenger = serverMessenger as BatchedDatabaseServerMessenger; + } + // internal for tests internal static MethodInfo FindHandler(IEventDefinition eventDefinition) { @@ -42,7 +54,6 @@ namespace Umbraco.Web.Cache private static readonly Lazy CandidateHandlers = new Lazy(() => { - return typeof(DistributedCacheBinder) .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .Select(x => @@ -66,9 +77,9 @@ namespace Umbraco.Web.Cache { // Ensure we run with an UmbracoContext, because this may run in a background task, // yet developers may be using the 'current' UmbracoContext in the event handlers. - using (_umbracoContextFactory.EnsureUmbracoContext()) + using (var umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext()) { - // When it comes to content types types, a change to any single one will trigger a reload of the content and media caches. + // When it comes to content types, a change to any single one will trigger a reload of the content and media caches. // We can reduce the impact of that by grouping the events to invoke just one per type, providing a collection of the individual arguments. var groupedEvents = GetGroupedEventList(events); foreach (var e in groupedEvents) @@ -84,6 +95,24 @@ namespace Umbraco.Web.Cache handler.Invoke(this, new[] { e.Sender, e.Args }); } + + // Handled events may be triggering messages to be sent for load balanced servers to refresh their caches. + // When the state changes that initiate the events are handled outside of an Umbraco request and rather in a + // background task, we'll have ensured an Umbraco context, but using a newly created HttpContext. + // + // An example of this is when using an Umbraco Deploy content transfer operation + // (see: https://github.com/umbraco/Umbraco.Deploy.Issues/issues/90). + // + // This will be used in the event handlers, and when the methods on BatchedDatabaseServerMessenger are called, + // they'll be using this "ensured" HttpContext, populating a batch of message stored in HttpContext.Items. + // When the FlushBatch method is called on the end of an Umbraco request (via the event handler wired up in + // DatabaseServerRegistrarAndMessengerComponent), this will use the HttpContext associated with the request, + // which will be a different one, and so won't have the batch stored in it's HttpContext.Items. + // + // As such by making an explicit call here, and providing the ensured HttpContext that will have had it's + // Items dictionary populated with the batch of messages, we'll make sure the batch is flushed, and the + // database instructions written. + _serverMessenger?.FlushBatch(umbracoContextReference.UmbracoContext.HttpContext); } } From 7006461ba27102597bc7d9b9c0fbf34ea5e2c180 Mon Sep 17 00:00:00 2001 From: Sebastiaan Janssen Date: Thu, 16 Dec 2021 16:30:18 +0100 Subject: [PATCH 09/53] Revert "Added notifications toggle to user groups (#10450)" This reverts commit 4c6d4b9326c094f2e66f627083ccf875a01188a2. --- .../Migrations/Install/DatabaseDataCreator.cs | 6 ++--- .../Migrations/Upgrade/UmbracoPlan.cs | 5 +--- .../AddDefaultForNotificationsToggle.cs | 15 ------------ src/Umbraco.Core/Umbraco.Core.csproj | 1 - src/Umbraco.Web.UI/Umbraco/config/lang/da.xml | 1 - src/Umbraco.Web.UI/Umbraco/config/lang/en.xml | 1 - .../Umbraco/config/lang/en_us.xml | 1 - src/Umbraco.Web/Actions/ActionNotify.cs | 24 ------------------- .../Trees/ContentTreeController.cs | 7 +++++- src/Umbraco.Web/Umbraco.Web.csproj | 1 - 10 files changed, 10 insertions(+), 52 deletions(-) delete mode 100644 src/Umbraco.Core/Migrations/Upgrade/V_8_18_0/AddDefaultForNotificationsToggle.cs delete mode 100644 src/Umbraco.Web/Actions/ActionNotify.cs diff --git a/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs index d9cc22d26d..bd7a96f6e7 100644 --- a/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs @@ -188,9 +188,9 @@ namespace Umbraco.Core.Migrations.Install private void CreateUserGroupData() { - _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 1, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.AdminGroupAlias, Name = "Administrators", DefaultPermissions = "CADMOSKTPIURZ:5F7ïN", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-medal" }); - _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 2, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.WriterGroupAlias, Name = "Writers", DefaultPermissions = "CAH:FN", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-edit" }); - _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 3, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.EditorGroupAlias, Name = "Editors", DefaultPermissions = "CADMOSKTPUZ:5FïN", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-tools" }); + _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 1, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.AdminGroupAlias, Name = "Administrators", DefaultPermissions = "CADMOSKTPIURZ:5F7ï", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-medal" }); + _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 2, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.WriterGroupAlias, Name = "Writers", DefaultPermissions = "CAH:F", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-edit" }); + _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 3, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.EditorGroupAlias, Name = "Editors", DefaultPermissions = "CADMOSKTPUZ:5Fï", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-tools" }); _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 4, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.TranslatorGroupAlias, Name = "Translators", DefaultPermissions = "AF", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-globe" }); _database.Insert(Constants.DatabaseSchema.Tables.UserGroup, "id", false, new UserGroupDto { Id = 5, StartMediaId = -1, StartContentId = -1, Alias = Constants.Security.SensitiveDataGroupAlias, Name = "Sensitive data", DefaultPermissions = "", CreateDate = DateTime.Now, UpdateDate = DateTime.Now, Icon = "icon-lock" }); } diff --git a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs index 895fd1946b..a557c7e78a 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs @@ -211,11 +211,8 @@ namespace Umbraco.Core.Migrations.Upgrade // to 8.17.0 To("{153865E9-7332-4C2A-9F9D-F20AEE078EC7}"); - // to 8.18.0 - To("{8BAF5E6C-DCB7-41AE-824F-4215AE4F1F98}"); - To("{AD3D3B7F-8E74-45A4-85DB-7FFAD57F9243}"); - //FINAL + To("{8BAF5E6C-DCB7-41AE-824F-4215AE4F1F98}"); } } } diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_18_0/AddDefaultForNotificationsToggle.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_18_0/AddDefaultForNotificationsToggle.cs deleted file mode 100644 index 0173600584..0000000000 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_18_0/AddDefaultForNotificationsToggle.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Umbraco.Core.Migrations.Upgrade.V_8_18_0 -{ - public class AddDefaultForNotificationsToggle : MigrationBase - { - public AddDefaultForNotificationsToggle(IMigrationContext context) : base(context) - { - } - - public override void Migrate() - { - var updateSQL = Sql($"UPDATE {Constants.DatabaseSchema.Tables.UserGroup} SET userGroupDefaultPermissions = userGroupDefaultPermissions + 'N' WHERE userGroupAlias IN ('admin', 'writer', 'editor')"); - Execute.Sql(updateSQL.SQL).Do(); - } - } -} diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index e6012c9e97..632031a2e6 100755 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -151,7 +151,6 @@ - diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml index 07752530fa..2dfec4523c 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/da.xml @@ -85,7 +85,6 @@ Tillad adgang til at oversætte en node Tillad adgang til at gemme en node Tillad adgang til at oprette en indholdsskabelon - Tillad adgang til at oprette notificeringer for noder Indhold diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml index 6d29c9adcd..ef63b4f292 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml @@ -82,7 +82,6 @@ Allow access to translate a node Allow access to save a node Allow access to create a Content Template - Allow access to setup notifications for content nodes Content diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml index 6c75d4e04a..e0560ec507 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml @@ -83,7 +83,6 @@ Allow access to translate a node Allow access to save a node Allow access to create a Content Template - Allow access to setup notifications for content nodes Content diff --git a/src/Umbraco.Web/Actions/ActionNotify.cs b/src/Umbraco.Web/Actions/ActionNotify.cs deleted file mode 100644 index a8f6a4c2a2..0000000000 --- a/src/Umbraco.Web/Actions/ActionNotify.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Umbraco.Core; - -namespace Umbraco.Web.Actions -{ - public class ActionNotify : IAction - { - public char Letter => 'N'; - - public bool ShowInNotifier => false; - - public bool CanBePermissionAssigned => true; - - public string Icon => "megaphone"; - - public string Alias => "notify"; - - public string Category => Constants.Conventions.PermissionCategories.ContentCategory; - } -} diff --git a/src/Umbraco.Web/Trees/ContentTreeController.cs b/src/Umbraco.Web/Trees/ContentTreeController.cs index e6b4f45d22..c2c938249e 100644 --- a/src/Umbraco.Web/Trees/ContentTreeController.cs +++ b/src/Umbraco.Web/Trees/ContentTreeController.cs @@ -242,7 +242,12 @@ namespace Umbraco.Web.Trees if (EmailSender.CanSendRequiredEmail) { - AddActionNode(item, menu, true, opensDialog: true); + menu.Items.Add(new MenuItem("notify", Services.TextService) + { + Icon = "megaphone", + SeparatorBefore = true, + OpensDialog = true + }); } if((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false) diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index fb2ce5dfc1..5c84e65514 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -136,7 +136,6 @@ Properties\SolutionInfo.cs - From 4e6d09b6268da683f38755d6603ce1da7a9d6175 Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska Date: Mon, 20 Dec 2021 14:37:18 +0100 Subject: [PATCH 10/53] Adds a new Health Check --- .../Security/UmbracoApplicationUrlCheck.cs | 101 ++++++++++++++++++ src/Umbraco.Web/Umbraco.Web.csproj | 1 + 2 files changed, 102 insertions(+) create mode 100644 src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs diff --git a/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs b/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs new file mode 100644 index 0000000000..f5e571a86a --- /dev/null +++ b/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using Umbraco.Core; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.IO; +using Umbraco.Core.Services; +using Umbraco.Web.HealthCheck.Checks.Config; + +namespace Umbraco.Web.HealthCheck.Checks.Security +{ + [HealthCheck( + "6708CA45-E96E-40B8-A40A-0607C1CA7F28", + "Application URL Configuration", + Description = "Checks if the Umbraco application URL is configured for your site.", + Group = "Security")] + public class UmbracoApplicationUrlCheck : HealthCheck + { + private readonly ILocalizedTextService _textService; + private readonly IRuntimeState _runtime; + private readonly IUmbracoSettingsSection _settings; + + private const string SetApplicationUrlAction = "setApplicationUrl"; + + public UmbracoApplicationUrlCheck(ILocalizedTextService textService, IRuntimeState runtime, IUmbracoSettingsSection settings) + { + _textService = textService; + _runtime = runtime; + _settings = settings; + } + + /// + /// Executes the action and returns its status + /// + /// + /// + public override HealthCheckStatus ExecuteAction(HealthCheckAction action) + { + switch (action.Alias) + { + case SetApplicationUrlAction: + return SetUmbracoApplicationUrl(); + default: + throw new InvalidOperationException("UmbracoApplicationUrlCheck action requested is either not executable or does not exist"); + } + } + + public override IEnumerable GetStatus() + { + //return the statuses + return new[] { CheckUmbracoApplicationUrl() }; + } + + private HealthCheckStatus CheckUmbracoApplicationUrl() + { + var urlConfigured = !_settings.WebRouting.UmbracoApplicationUrl.IsNullOrWhiteSpace(); + var actions = new List(); + + string resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResult", new[] { urlConfigured ? string.Empty : "not" }); + StatusResultType resultType = urlConfigured ? StatusResultType.Success : StatusResultType.Warning; + + if (urlConfigured == false) + { + actions.Add(new HealthCheckAction(SetApplicationUrlAction, Id) + { + Name = _textService.Localize("healthcheck", "umbracoApplicationUrlConfigureButton"), + Description = _textService.Localize("healthcheck", "umbracoApplicationUrlConfigureDescription") + }); + } + + return new HealthCheckStatus(resultMessage) + { + ResultType = resultType, + Actions = actions + }; + } + + private HealthCheckStatus SetUmbracoApplicationUrl() + { + var configFilePath = IOHelper.MapPath("~/config/umbracoSettings.config"); + const string xPath = "/settings/web.routing/@umbracoApplicationUrl"; + var configurationService = new ConfigurationService(configFilePath, xPath, _textService); + var urlValue = _runtime.ApplicationUrl.ToString(); + var updateConfigFile = configurationService.UpdateConfigFile(urlValue); + + if (updateConfigFile.Success) + { + return + new HealthCheckStatus(_textService.Localize("healthcheck", "umbracoApplicationUrlConfigureSuccess", new[] { urlValue })) + { + ResultType = StatusResultType.Success + }; + } + + return + new HealthCheckStatus(_textService.Localize("healthcheck", "umbracoApplicationUrlConfigureError", new[] { updateConfigFile.Result })) + { + ResultType = StatusResultType.Error + }; + } + } +} diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 5c84e65514..89317e8f99 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -190,6 +190,7 @@ + From bcabf0599576b6397bbd7598391b2127c693c084 Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska Date: Mon, 20 Dec 2021 14:37:42 +0100 Subject: [PATCH 11/53] Adds new translation --- src/Umbraco.Web.UI/Umbraco/config/lang/en.xml | 5 +++++ src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml index ef63b4f292..8b16c80a25 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml @@ -2182,6 +2182,11 @@ To manage your website, simply open the Umbraco backoffice and start adding cont + The 'umbracoApplicationUrl' option is %0% set in your umbracoSettings.config file. + Set Umbraco application URL in Config + Adds a value to the 'umbracoApplicationUrl' option of umbracoSettings.config to prevent configuring insecure endpoint as the hostname of your Umbraco application. + %0% in your umbracoSettings.config file.]]> + Could not update the value of 'umbracoApplicationUrl' option in your umbracoSettings.config file. Error: %0% %0%.]]> %0%. If they aren't being written to no action need be taken.]]> X-Frame-Options used to control whether a site can be IFRAMEd by another was found.]]> diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml index e0560ec507..9ce74e904b 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml @@ -2219,6 +2219,11 @@ To manage your website, simply open the Umbraco backoffice and start adding cont + The 'umbracoApplicationUrl' option is %0% set in your umbracoSettings.config file. + Set Umbraco application URL in Config + Adds a value to the 'umbracoApplicationUrl' option of umbracoSettings.config to prevent configuring insecure endpoint as the hostname of your Umbraco application. + %0% in your umbracoSettings.config file.]]> + Could not update the value of 'umbracoApplicationUrl' option in your umbracoSettings.config file. Error: %0% %0%.]]> %0%. If they aren't being written to no action need be taken.]]> X-Frame-Options used to control whether a site can be IFRAMEd by another was found.]]> From 68fdf6521e3caf8ef9d5dc3073ccf36d0c295a17 Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska Date: Mon, 20 Dec 2021 15:21:12 +0100 Subject: [PATCH 12/53] Being more descriptive --- src/Umbraco.Web.UI/Umbraco/config/lang/en.xml | 3 ++- .../Umbraco/config/lang/en_us.xml | 3 ++- .../Security/UmbracoApplicationUrlCheck.cs | 18 +++++++++++++----- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml index 8b16c80a25..2b62c6ba21 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en.xml @@ -2182,7 +2182,8 @@ To manage your website, simply open the Umbraco backoffice and start adding cont - The 'umbracoApplicationUrl' option is %0% set in your umbracoSettings.config file. + %0% in your umbracoSettings.config file.]]> + The 'umbracoApplicationUrl' option is not set in your umbracoSettings.config file. Set Umbraco application URL in Config Adds a value to the 'umbracoApplicationUrl' option of umbracoSettings.config to prevent configuring insecure endpoint as the hostname of your Umbraco application. %0% in your umbracoSettings.config file.]]> diff --git a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml index 9ce74e904b..79121d015d 100644 --- a/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/Umbraco/config/lang/en_us.xml @@ -2219,7 +2219,8 @@ To manage your website, simply open the Umbraco backoffice and start adding cont - The 'umbracoApplicationUrl' option is %0% set in your umbracoSettings.config file. + %0% in your umbracoSettings.config file.]]> + The 'umbracoApplicationUrl' option is not set in your umbracoSettings.config file. Set Umbraco application URL in Config Adds a value to the 'umbracoApplicationUrl' option of umbracoSettings.config to prevent configuring insecure endpoint as the hostname of your Umbraco application. %0% in your umbracoSettings.config file.]]> diff --git a/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs b/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs index f5e571a86a..bc14f43235 100644 --- a/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs +++ b/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs @@ -52,20 +52,28 @@ namespace Umbraco.Web.HealthCheck.Checks.Security private HealthCheckStatus CheckUmbracoApplicationUrl() { - var urlConfigured = !_settings.WebRouting.UmbracoApplicationUrl.IsNullOrWhiteSpace(); + var url = _settings.WebRouting.UmbracoApplicationUrl; + + string resultMessage; + StatusResultType resultType; var actions = new List(); - string resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResult", new[] { urlConfigured ? string.Empty : "not" }); - StatusResultType resultType = urlConfigured ? StatusResultType.Success : StatusResultType.Warning; - - if (urlConfigured == false) + if (url.IsNullOrWhiteSpace()) { + resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse"); + resultType = StatusResultType.Warning; + actions.Add(new HealthCheckAction(SetApplicationUrlAction, Id) { Name = _textService.Localize("healthcheck", "umbracoApplicationUrlConfigureButton"), Description = _textService.Localize("healthcheck", "umbracoApplicationUrlConfigureDescription") }); } + else + { + resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url }); + resultType = StatusResultType.Success; + } return new HealthCheckStatus(resultMessage) { From 7a5f72b5eb0b5b2bf8ec40ddad3060263b2dc4da Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 21 Dec 2021 07:23:08 +0100 Subject: [PATCH 13/53] Use current request for emails (#11775) * Use current request for emails * Fix tests --- src/Umbraco.Core/Sync/ApplicationUrlHelper.cs | 17 ++++++++++ .../AuthenticationControllerTests.cs | 4 ++- .../Web/Controllers/UsersControllerTests.cs | 13 ++++--- .../Editors/AuthenticationController.cs | 19 +++++++++-- src/Umbraco.Web/Editors/UsersController.cs | 34 +++++++++++++------ 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs b/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs index 52af734f1c..d934e24575 100644 --- a/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs +++ b/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs @@ -102,5 +102,22 @@ namespace Umbraco.Core.Sync return url.TrimEnd(Constants.CharArrays.ForwardSlash); } + + /// + /// Will get the application URL from configuration, if none is specified will fall back to URL from request. + /// + /// + /// + /// + /// + public static Uri GetApplicationUriUncached( + HttpRequestBase request, + IUmbracoSettingsSection umbracoSettingsSection) + { + var settingUrl = umbracoSettingsSection.WebRouting.UmbracoApplicationUrl; + return string.IsNullOrEmpty(settingUrl) + ? new Uri(request.Url, IOHelper.ResolveUrl(SystemDirectories.Umbraco)) + : new Uri(settingUrl); + } } } diff --git a/src/Umbraco.Tests/Web/Controllers/AuthenticationControllerTests.cs b/src/Umbraco.Tests/Web/Controllers/AuthenticationControllerTests.cs index 3d264663b5..9bd9ee73ed 100644 --- a/src/Umbraco.Tests/Web/Controllers/AuthenticationControllerTests.cs +++ b/src/Umbraco.Tests/Web/Controllers/AuthenticationControllerTests.cs @@ -16,6 +16,7 @@ using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Composing; using Umbraco.Core.Configuration; +using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Persistence; @@ -82,7 +83,8 @@ namespace Umbraco.Tests.Web.Controllers Factory.GetInstance(), Factory.GetInstance(), Factory.GetInstance(), - helper); + helper, + Factory.GetInstance()); return usersController; } diff --git a/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs b/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs index 85dd303432..b9289c1392 100644 --- a/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs +++ b/src/Umbraco.Tests/Web/Controllers/UsersControllerTests.cs @@ -14,6 +14,7 @@ using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Composing; using Umbraco.Core.Configuration; +using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; @@ -84,7 +85,8 @@ namespace Umbraco.Tests.Web.Controllers Factory.GetInstance(), Factory.GetInstance(), Factory.GetInstance(), - helper); + helper, + Factory.GetInstance()); return usersController; } @@ -148,7 +150,8 @@ namespace Umbraco.Tests.Web.Controllers Factory.GetInstance(), Factory.GetInstance(), Factory.GetInstance(), - helper); + helper, + Factory.GetInstance()); return usersController; } @@ -183,7 +186,8 @@ namespace Umbraco.Tests.Web.Controllers Factory.GetInstance(), Factory.GetInstance(), Factory.GetInstance(), - helper); + helper, + Factory.GetInstance()); return usersController; } @@ -253,7 +257,8 @@ namespace Umbraco.Tests.Web.Controllers Factory.GetInstance(), Factory.GetInstance(), Factory.GetInstance(), - helper); + helper, + Factory.GetInstance()); return usersController; } diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index 3ecc6b64a4..54612377e0 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -27,6 +27,8 @@ using Umbraco.Web.Composing; using IUser = Umbraco.Core.Models.Membership.IUser; using Umbraco.Web.Editors.Filters; using Microsoft.Owin.Security; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.Sync; namespace Umbraco.Web.Editors { @@ -40,12 +42,23 @@ namespace Umbraco.Web.Editors [DisableBrowserCache] public class AuthenticationController : UmbracoApiController { + private readonly IUmbracoSettingsSection _umbracoSettingsSection; private BackOfficeUserManager _userManager; private BackOfficeSignInManager _signInManager; - public AuthenticationController(IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) + public AuthenticationController( + IGlobalSettings globalSettings, + IUmbracoContextAccessor umbracoContextAccessor, + ISqlContext sqlContext, + ServiceContext services, + AppCaches appCaches, + IProfilingLogger logger, + IRuntimeState runtimeState, + UmbracoHelper umbracoHelper, + IUmbracoSettingsSection umbracoSettingsSection) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper, Current.Mapper) { + _umbracoSettingsSection = umbracoSettingsSection; } protected BackOfficeUserManager UserManager => _userManager @@ -552,8 +565,8 @@ namespace Umbraco.Web.Editors r = code }); - // Construct full URL using configured application URL (which will fall back to request) - var applicationUri = Current.RuntimeState.ApplicationUrl; + // Construct full URL using configured application URL (which will fall back to current request) + var applicationUri = ApplicationUrlHelper.GetApplicationUriUncached(http.Request, _umbracoSettingsSection); var callbackUri = new Uri(applicationUri, action); return callbackUri.ToString(); } diff --git a/src/Umbraco.Web/Editors/UsersController.cs b/src/Umbraco.Web/Editors/UsersController.cs index dda0dfc933..4bfd72854f 100644 --- a/src/Umbraco.Web/Editors/UsersController.cs +++ b/src/Umbraco.Web/Editors/UsersController.cs @@ -17,6 +17,7 @@ using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Composing; using Umbraco.Core.Configuration; +using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Models; @@ -27,6 +28,7 @@ using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Security; using Umbraco.Core.Services; +using Umbraco.Core.Sync; using Umbraco.Web.Editors.Filters; using Umbraco.Web.Models; using Umbraco.Web.Models.ContentEditing; @@ -46,9 +48,21 @@ namespace Umbraco.Web.Editors [IsCurrentUserModelFilter] public class UsersController : UmbracoAuthorizedJsonController { - public UsersController(IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) + private readonly IUmbracoSettingsSection _umbracoSettingsSection; + + public UsersController( + IGlobalSettings globalSettings, + IUmbracoContextAccessor umbracoContextAccessor, + ISqlContext sqlContext, + ServiceContext services, + AppCaches appCaches, + IProfilingLogger logger, + IRuntimeState runtimeState, + UmbracoHelper umbracoHelper, + IUmbracoSettingsSection umbracoSettingsSection) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) { + _umbracoSettingsSection = umbracoSettingsSection; } /// @@ -390,7 +404,7 @@ namespace Umbraco.Web.Editors user = CheckUniqueEmail(userSave.Email, u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue); var userMgr = TryGetOwinContext().Result.GetBackOfficeUserManager(); - + if (!EmailSender.CanSendRequiredEmail && !userMgr.HasSendingUserInviteEventHandler) { throw new HttpResponseException( @@ -462,12 +476,12 @@ namespace Umbraco.Web.Editors Email = userSave.Email, Username = userSave.Username }; - } + } } else { //send the email - await SendUserInviteEmailAsync(display, Security.CurrentUser.Name, Security.CurrentUser.Email, user, userSave.Message); + await SendUserInviteEmailAsync(display, Security.CurrentUser.Name, Security.CurrentUser.Email, user, userSave.Message); } display.AddSuccessNotification(Services.TextService.Localize("speechBubbles", "resendInviteHeader"), Services.TextService.Localize("speechBubbles", "resendInviteSuccess", new[] { user.Name })); @@ -525,9 +539,9 @@ namespace Umbraco.Web.Editors invite = inviteToken }); - // Construct full URL using configured application URL (which will fall back to request) - var applicationUri = RuntimeState.ApplicationUrl; - var inviteUri = new Uri(applicationUri, action); + // Construct full URL will use the value in settings if specified, otherwise will use the current request URL + var requestUrl = ApplicationUrlHelper.GetApplicationUriUncached(http.Request, _umbracoSettingsSection); + var inviteUri = new Uri(requestUrl, action); var emailSubject = Services.TextService.Localize("user", "inviteEmailCopySubject", //Ensure the culture of the found user is used for the email! @@ -622,7 +636,7 @@ namespace Umbraco.Web.Editors if (Current.Configs.Settings().Security.UsernameIsEmail && found.Username == found.Email && userSave.Username != userSave.Email) { userSave.Username = userSave.Email; - } + } if (hasErrors) throw new HttpResponseException(Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState)); @@ -647,13 +661,13 @@ namespace Umbraco.Web.Editors } /// - /// + /// /// /// /// public async Task> PostChangePassword(ChangingPasswordModel changingPasswordModel) { - changingPasswordModel = changingPasswordModel ?? throw new ArgumentNullException(nameof(changingPasswordModel)); + changingPasswordModel = changingPasswordModel ?? throw new ArgumentNullException(nameof(changingPasswordModel)); if (ModelState.IsValid == false) { From 8a8b1e3247e6a79e8ca60e6a883305d2a2038bb5 Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 21 Dec 2021 10:30:19 +0100 Subject: [PATCH 14/53] Rename CharacterReplacement back to CharItem Since the class is public renaming this would be a breaking change, since if anyone is directly referring to the class it would break --- .../CharacterReplacement.cs => Models/CharItem.cs} | 4 ++-- .../Configuration/Models/RequestHandlerSettings.cs | 6 +++--- .../CharacterReplacementEqualityComparer.cs | 7 ++++--- .../DefaultShortStringHelperTestsWithoutSetup.cs | 6 +++--- 4 files changed, 12 insertions(+), 11 deletions(-) rename src/Umbraco.Core/Configuration/{UmbracoSettings/CharacterReplacement.cs => Models/CharItem.cs} (74%) diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacement.cs b/src/Umbraco.Core/Configuration/Models/CharItem.cs similarity index 74% rename from src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacement.cs rename to src/Umbraco.Core/Configuration/Models/CharItem.cs index ed2f97dba9..9a1178a1bf 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacement.cs +++ b/src/Umbraco.Core/Configuration/Models/CharItem.cs @@ -1,6 +1,6 @@ -namespace Umbraco.Cms.Core.Configuration.UmbracoSettings +namespace Umbraco.Cms.Core.Configuration.Models { - public class CharacterReplacement + public class CharItem { /// /// The character to replace diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index 79dabf9da0..105712396d 100644 --- a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs @@ -19,7 +19,7 @@ namespace Umbraco.Cms.Core.Configuration.Models internal const string StaticConvertUrlsToAscii = "try"; internal const bool StaticEnableDefaultCharReplacements = true; - internal static readonly CharacterReplacement[] DefaultCharCollection = + internal static readonly CharItem[] DefaultCharCollection = { new () { Char = " ", Replacement = "-" }, new () { Char = "\"", Replacement = string.Empty }, @@ -78,13 +78,13 @@ namespace Umbraco.Cms.Core.Configuration.Models /// /// Add additional character replacements, or override defaults /// - public CharacterReplacement[] CharCollection { get; set; } + public CharItem[] CharCollection { get; set; } /// /// Get concatenated user and default character replacements /// taking into account /// - public IEnumerable GetCharReplacements() + public IEnumerable GetCharReplacements() { // TODO We need to special handle ":", as this character is special in keys diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs index b7dbf1cd16..7a830e9655 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; +using Umbraco.Cms.Core.Configuration.Models; namespace Umbraco.Cms.Core.Configuration.UmbracoSettings { - public class CharacterReplacementEqualityComparer : IEqualityComparer + public class CharacterReplacementEqualityComparer : IEqualityComparer { - public bool Equals(CharacterReplacement x, CharacterReplacement y) + public bool Equals(CharItem x, CharItem y) { if (ReferenceEquals(x, y)) { @@ -29,7 +30,7 @@ namespace Umbraco.Cms.Core.Configuration.UmbracoSettings return x.Char == y.Char && x.Replacement == y.Replacement; } - public int GetHashCode(CharacterReplacement obj) + public int GetHashCode(CharItem obj) { unchecked { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs index ee7999cac1..b686aee278 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs @@ -20,7 +20,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Array.Empty(), + CharCollection = Array.Empty(), EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; @@ -47,7 +47,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Array.Empty(), + CharCollection = Array.Empty(), EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; @@ -342,7 +342,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Array.Empty(), + CharCollection = Array.Empty(), EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; From 338bbdd3825f89b9c5e6835313c8012be2b80b9a Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 21 Dec 2021 10:36:25 +0100 Subject: [PATCH 15/53] Reintroduce IChar interface Removing a public interface is a breaking change, in case someone is implementing the interface for some reason. --- src/Umbraco.Core/Configuration/Models/CharItem.cs | 4 +++- .../Configuration/Models/RequestHandlerSettings.cs | 6 +++--- .../CharacterReplacementEqualityComparer.cs | 6 +++--- src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs | 9 +++++++++ 4 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs diff --git a/src/Umbraco.Core/Configuration/Models/CharItem.cs b/src/Umbraco.Core/Configuration/Models/CharItem.cs index 9a1178a1bf..e269e0a83e 100644 --- a/src/Umbraco.Core/Configuration/Models/CharItem.cs +++ b/src/Umbraco.Core/Configuration/Models/CharItem.cs @@ -1,6 +1,8 @@ +using Umbraco.Cms.Core.Configuration.UmbracoSettings; + namespace Umbraco.Cms.Core.Configuration.Models { - public class CharItem + public class CharItem : IChar { /// /// The character to replace diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index 105712396d..3c76af7252 100644 --- a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs @@ -78,13 +78,13 @@ namespace Umbraco.Cms.Core.Configuration.Models /// /// Add additional character replacements, or override defaults /// - public CharItem[] CharCollection { get; set; } + public IEnumerable CharCollection { get; set; } /// /// Get concatenated user and default character replacements /// taking into account /// - public IEnumerable GetCharReplacements() + public IEnumerable GetCharReplacements() { // TODO We need to special handle ":", as this character is special in keys @@ -109,7 +109,7 @@ namespace Umbraco.Cms.Core.Configuration.Models } } - var mergedCollections = DefaultCharCollection.Union(CharCollection, new CharacterReplacementEqualityComparer()); + var mergedCollections = DefaultCharCollection.Union(CharCollection, new CharacterReplacementEqualityComparer()); return mergedCollections; } diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs index 7a830e9655..a916febb93 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs @@ -3,9 +3,9 @@ using Umbraco.Cms.Core.Configuration.Models; namespace Umbraco.Cms.Core.Configuration.UmbracoSettings { - public class CharacterReplacementEqualityComparer : IEqualityComparer + public class CharacterReplacementEqualityComparer : IEqualityComparer { - public bool Equals(CharItem x, CharItem y) + public bool Equals(IChar x, IChar y) { if (ReferenceEquals(x, y)) { @@ -30,7 +30,7 @@ namespace Umbraco.Cms.Core.Configuration.UmbracoSettings return x.Char == y.Char && x.Replacement == y.Replacement; } - public int GetHashCode(CharItem obj) + public int GetHashCode(IChar obj) { unchecked { diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs new file mode 100644 index 0000000000..61e840245c --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Configuration.UmbracoSettings +{ + public interface IChar + { + string Char { get; } + + string Replacement { get; } + } +} From d77e198d3144f21eb48034eafd63b9c7b4251ba0 Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 21 Dec 2021 10:42:54 +0100 Subject: [PATCH 16/53] Move unit test to the new location --- .../Models/RequestHandlerSettingsTests.cs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) rename {src => tests}/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs (57%) diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs similarity index 57% rename from src/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs rename to tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs index c16d9b4897..9f2368d462 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs @@ -11,7 +11,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models [Test] public void Given_CharCollection_With_DefaultEnabled_MergesCollection() { - var userCollection = new CharacterReplacement[] + var userCollection = new CharItem[] { new() { Char = "test", Replacement = "replace" }, new() { Char = "test2", Replacement = "replace2" } @@ -31,10 +31,10 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models [Test] public void Given_CharCollection_With_DefaultDisabled_ReturnsUserCollection() { - var userCollection = new CharacterReplacement[] + var userCollection = new CharItem[] { - new() { Char = "test", Replacement = "replace" }, - new() { Char = "test2", Replacement = "replace2" } + new () { Char = "test", Replacement = "replace" }, + new () { Char = "test2", Replacement = "replace2" } }; var settings = new RequestHandlerSettings { CharCollection = userCollection, EnableDefaultCharReplacements = false }; @@ -47,10 +47,10 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models [Test] public void Given_CharCollection_That_OverridesDefaultValues_ReturnsReplacements() { - var userCollection = new CharacterReplacement[] + var userCollection = new CharItem[] { - new() { Char = "%", Replacement = "percent" }, - new() { Char = ".", Replacement = "dot" } + new () { Char = "%", Replacement = "percent" }, + new () { Char = ".", Replacement = "dot" } }; var settings = new RequestHandlerSettings { CharCollection = userCollection }; @@ -58,20 +58,20 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length, actual.Count); - Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); - Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); - Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); - Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); } [Test] public void Given_CharCollection_That_OverridesDefaultValues_And_ContainsNew_ReturnsMergedWithReplacements() { - var userCollection = new CharacterReplacement[] + var userCollection = new CharItem[] { - new() { Char = "%", Replacement = "percent" }, - new() { Char = ".", Replacement = "dot" }, - new() {Char = "new", Replacement = "new"} + new () { Char = "%", Replacement = "percent" }, + new () { Char = ".", Replacement = "dot" }, + new () { Char = "new", Replacement = "new" } }; var settings = new RequestHandlerSettings { CharCollection = userCollection }; @@ -80,11 +80,11 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models // Add 1 to the length, because we're expecting to only add one new one Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length + 1, actual.Count); - Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); - Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); - Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "new" && x.Replacement == "new")); - Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); - Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "new" && x.Replacement == "new")); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); } } } From 6147375a3e7d133c196c13216bc966018dfcd817 Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 21 Dec 2021 11:56:33 +0100 Subject: [PATCH 17/53] Return CharItem instead of IChar from CharCollection On second thought this is not a breaking change since CharItem implements IChar, so you could still do something lke IEnumerable chars = requestHandlerSettings.CharCollection --- .../Configuration/Models/RequestHandlerSettings.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index 3c76af7252..c27ad63092 100644 --- a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs @@ -78,13 +78,13 @@ namespace Umbraco.Cms.Core.Configuration.Models /// /// Add additional character replacements, or override defaults /// - public IEnumerable CharCollection { get; set; } + public IEnumerable CharCollection { get; set; } /// /// Get concatenated user and default character replacements /// taking into account /// - public IEnumerable GetCharReplacements() + public IEnumerable GetCharReplacements() { // TODO We need to special handle ":", as this character is special in keys @@ -109,7 +109,7 @@ namespace Umbraco.Cms.Core.Configuration.Models } } - var mergedCollections = DefaultCharCollection.Union(CharCollection, new CharacterReplacementEqualityComparer()); + var mergedCollections = DefaultCharCollection.Union(CharCollection, new CharacterReplacementEqualityComparer()); return mergedCollections; } From 80cda3364b09c3241e6284761dc416e79ef8fe71 Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 21 Dec 2021 13:35:30 +0100 Subject: [PATCH 18/53] Make CharCollection return the merged result Since this is the way it's been previously, we cannot change the behaviour of this. Unfortunately due to how the config binding works, we have to make sure it returns null the very first time it's called by the framework to ensure that overrides actually gets registered. --- .../Models/RequestHandlerSettings.cs | 37 +++++++++++++++---- .../Strings/DefaultShortStringHelperConfig.cs | 2 +- .../Models/RequestHandlerSettingsTests.cs | 12 +++--- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index c27ad63092..4894fb7125 100644 --- a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs @@ -75,32 +75,53 @@ namespace Umbraco.Cms.Core.Configuration.Models [DefaultValue(StaticEnableDefaultCharReplacements)] public bool EnableDefaultCharReplacements { get; set; } = StaticEnableDefaultCharReplacements; + private IEnumerable _charCollection; + /// /// Add additional character replacements, or override defaults /// - public IEnumerable CharCollection { get; set; } + public IEnumerable CharCollection + { + get + { + // This is pretty ugly, but necessary. + // Essentially the config binding will only run properly if we return null the first time this is invoked. + // Otherwise whatever we return will just be used and user specific bindings won't overwrite the existing ones. + // However the next time this get is invoked, for instance in DefaultShortStringHelper we want to actually run the GetCharReplacements + // To make sure that the default bindings is used if configured to do so. + // Therefore we set _charCollection to be something, and still return null, to trick dotnet to actually read the config. + if (_charCollection is null) + { + _charCollection = Enumerable.Empty(); + return null; + } + + return GetCharReplacements(); + } + + set => _charCollection = value; + } /// /// Get concatenated user and default character replacements /// taking into account /// - public IEnumerable GetCharReplacements() + private IEnumerable GetCharReplacements() { // TODO We need to special handle ":", as this character is special in keys - if (!EnableDefaultCharReplacements) { - return CharCollection; + return _charCollection ?? Enumerable.Empty(); } - if (CharCollection == null || !CharCollection.Any()) + if (_charCollection == null || !_charCollection.Any()) { return DefaultCharCollection; } - foreach (var defaultReplacement in DefaultCharCollection) + foreach (CharItem defaultReplacement in DefaultCharCollection) { - foreach (var userReplacement in CharCollection) + foreach (CharItem userReplacement in _charCollection) { if (userReplacement.Char == defaultReplacement.Char) { @@ -109,7 +130,7 @@ namespace Umbraco.Cms.Core.Configuration.Models } } - var mergedCollections = DefaultCharCollection.Union(CharCollection, new CharacterReplacementEqualityComparer()); + IEnumerable mergedCollections = DefaultCharCollection.Union(_charCollection, new CharacterReplacementEqualityComparer()); return mergedCollections; } diff --git a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs index 287d33dd58..273d7a6562 100644 --- a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs +++ b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs @@ -60,7 +60,7 @@ namespace Umbraco.Cms.Core.Strings /// The short string helper. public DefaultShortStringHelperConfig WithDefault(RequestHandlerSettings requestHandlerSettings) { - UrlReplaceCharacters = requestHandlerSettings.GetCharReplacements() + UrlReplaceCharacters = requestHandlerSettings.CharCollection .Where(x => string.IsNullOrEmpty(x.Char) == false) .ToDictionary(x => x.Char, x => x.Replacement); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs index 9f2368d462..d681d4a70e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs @@ -13,13 +13,13 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models { var userCollection = new CharItem[] { - new() { Char = "test", Replacement = "replace" }, - new() { Char = "test2", Replacement = "replace2" } + new () { Char = "test", Replacement = "replace" }, + new () { Char = "test2", Replacement = "replace2" } }; var settings = new RequestHandlerSettings { CharCollection = userCollection }; - var actual = settings.GetCharReplacements().ToList(); + var actual = settings.CharCollection.ToList(); var expectedCollection = RequestHandlerSettings.DefaultCharCollection.ToList(); expectedCollection.AddRange(userCollection); @@ -38,7 +38,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models }; var settings = new RequestHandlerSettings { CharCollection = userCollection, EnableDefaultCharReplacements = false }; - var actual = settings.GetCharReplacements().ToList(); + var actual = settings.CharCollection.ToList(); Assert.AreEqual(userCollection.Length, actual.Count); Assert.That(actual, Is.EquivalentTo(userCollection)); @@ -54,7 +54,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models }; var settings = new RequestHandlerSettings { CharCollection = userCollection }; - var actual = settings.GetCharReplacements().ToList(); + var actual = settings.CharCollection.ToList(); Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length, actual.Count); @@ -75,7 +75,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models }; var settings = new RequestHandlerSettings { CharCollection = userCollection }; - var actual = settings.GetCharReplacements().ToList(); + var actual = settings.CharCollection.ToList(); // Add 1 to the length, because we're expecting to only add one new one Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length + 1, actual.Count); From 040116c004d5d16d553914193729b3e70f9216b7 Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 21 Dec 2021 14:48:56 +0100 Subject: [PATCH 19/53] Remove TODO With some testing this seems to be a none-issue, since we don't operate on the keys themselves --- src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index 4894fb7125..a38f152f3b 100644 --- a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs @@ -108,7 +108,6 @@ namespace Umbraco.Cms.Core.Configuration.Models /// private IEnumerable GetCharReplacements() { - // TODO We need to special handle ":", as this character is special in keys if (!EnableDefaultCharReplacements) { return _charCollection ?? Enumerable.Empty(); From c6d28f01a8d2afba588b265529bc5549d4c5dd29 Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 21 Dec 2021 15:15:53 +0100 Subject: [PATCH 20/53] Check for null in DefaultShortStringHelperConfig.WithDefault --- .../Strings/DefaultShortStringHelperConfig.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs index 273d7a6562..d5bab4afb1 100644 --- a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs +++ b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Configuration.UmbracoSettings; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Strings @@ -60,7 +61,18 @@ namespace Umbraco.Cms.Core.Strings /// The short string helper. public DefaultShortStringHelperConfig WithDefault(RequestHandlerSettings requestHandlerSettings) { - UrlReplaceCharacters = requestHandlerSettings.CharCollection + // CharCollection could potentially be null if not invoked first by the framework, for instance in tests, so ensure that it's initialized. + IEnumerable charCollection = requestHandlerSettings.CharCollection; + if (charCollection is null) + { + charCollection = requestHandlerSettings.CharCollection; + if (charCollection is null) + { + throw new ArgumentNullException(nameof(requestHandlerSettings.CharCollection)); + } + } + + UrlReplaceCharacters = charCollection .Where(x => string.IsNullOrEmpty(x.Char) == false) .ToDictionary(x => x.Char, x => x.Replacement); From 53e5a25df87ef8b8bce1f5cd32d8f5d652b582c9 Mon Sep 17 00:00:00 2001 From: Mole Date: Wed, 22 Dec 2021 11:29:04 +0100 Subject: [PATCH 21/53] Remove ugly CharCollection hack By adding a new configuration and mapping the old CharCollection to that, we can get around having to return null the first time, by obsoleting the old one and redirecting to the new GetCharReplacements method --- .../Models/RequestHandlerSettings.cs | 62 +++---------------- .../UmbracoBuilder.Configuration.cs | 20 ++++++ .../RequestHandlerSettingsExtension.cs | 44 +++++++++++++ .../Strings/DefaultShortStringHelperConfig.cs | 11 +--- .../Models/RequestHandlerSettingsTests.cs | 18 +++--- 5 files changed, 81 insertions(+), 74 deletions(-) create mode 100644 src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index a38f152f3b..051c31dc26 100644 --- a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs @@ -1,9 +1,9 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System; using System.Collections.Generic; using System.ComponentModel; -using System.Linq; using Umbraco.Cms.Core.Configuration.UmbracoSettings; using Umbraco.Extensions; @@ -75,63 +75,15 @@ namespace Umbraco.Cms.Core.Configuration.Models [DefaultValue(StaticEnableDefaultCharReplacements)] public bool EnableDefaultCharReplacements { get; set; } = StaticEnableDefaultCharReplacements; - private IEnumerable _charCollection; + /// + /// Add additional character replacements, or override defaults + /// + [Obsolete("Use the GetCharReplacements extension method in the Umbraco.Extensions namespace instead. Scheduled for removal in V11")] + public IEnumerable CharCollection { get; set; } = DefaultCharCollection; /// /// Add additional character replacements, or override defaults /// - public IEnumerable CharCollection - { - get - { - // This is pretty ugly, but necessary. - // Essentially the config binding will only run properly if we return null the first time this is invoked. - // Otherwise whatever we return will just be used and user specific bindings won't overwrite the existing ones. - // However the next time this get is invoked, for instance in DefaultShortStringHelper we want to actually run the GetCharReplacements - // To make sure that the default bindings is used if configured to do so. - // Therefore we set _charCollection to be something, and still return null, to trick dotnet to actually read the config. - if (_charCollection is null) - { - _charCollection = Enumerable.Empty(); - return null; - } - - return GetCharReplacements(); - } - - set => _charCollection = value; - } - - /// - /// Get concatenated user and default character replacements - /// taking into account - /// - private IEnumerable GetCharReplacements() - { - if (!EnableDefaultCharReplacements) - { - return _charCollection ?? Enumerable.Empty(); - } - - if (_charCollection == null || !_charCollection.Any()) - { - return DefaultCharCollection; - } - - foreach (CharItem defaultReplacement in DefaultCharCollection) - { - foreach (CharItem userReplacement in _charCollection) - { - if (userReplacement.Char == defaultReplacement.Char) - { - defaultReplacement.Replacement = userReplacement.Replacement; - } - } - } - - IEnumerable mergedCollections = DefaultCharCollection.Union(_charCollection, new CharacterReplacementEqualityComparer()); - - return mergedCollections; - } + public IEnumerable UserDefinedCharCollection { get; set; } } } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 6ef87464e8..8043698b6e 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Reflection; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; @@ -76,6 +78,24 @@ namespace Umbraco.Cms.Core.DependencyInjection .AddUmbracoOptions() .AddUmbracoOptions(); + builder.Services.Configure(options => + { + var userDefinedReplacements = new List(); + IEnumerable config = builder.Config + .GetSection( + $"{Constants.Configuration.ConfigRequestHandler}:{nameof(RequestHandlerSettings.CharCollection)}") + .GetChildren(); + + foreach (IConfigurationSection section in config) + { + var @char = section.GetValue(nameof(CharItem.Char)); + var replacement = section.GetValue(nameof(CharItem.Replacement)); + userDefinedReplacements.Add(new CharItem { Char = @char, Replacement = replacement }); + } + + options.UserDefinedCharCollection = userDefinedReplacements; + }); + return builder; } } diff --git a/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs b/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs new file mode 100644 index 0000000000..d54d00188a --- /dev/null +++ b/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Configuration.UmbracoSettings; + +namespace Umbraco.Extensions +{ + /// + /// Get concatenated user and default character replacements + /// taking into account + /// + public static class RequestHandlerSettingsExtension + { + public static IEnumerable GetCharReplacements(this RequestHandlerSettings requestHandlerSettings) + { + if (!requestHandlerSettings.EnableDefaultCharReplacements) + { + return requestHandlerSettings.UserDefinedCharCollection ?? Enumerable.Empty(); + } + + if (requestHandlerSettings.UserDefinedCharCollection == null || !requestHandlerSettings.UserDefinedCharCollection.Any()) + { + return RequestHandlerSettings.DefaultCharCollection; + } + + foreach (CharItem defaultReplacement in RequestHandlerSettings.DefaultCharCollection) + { + foreach (CharItem userReplacement in requestHandlerSettings.UserDefinedCharCollection) + { + if (userReplacement.Char == defaultReplacement.Char) + { + defaultReplacement.Replacement = userReplacement.Replacement; + } + } + } + + IEnumerable mergedCollections = + RequestHandlerSettings.DefaultCharCollection.Union( + requestHandlerSettings.UserDefinedCharCollection, new CharacterReplacementEqualityComparer()); + + return mergedCollections; + } + } +} diff --git a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs index d5bab4afb1..b0f0a9b003 100644 --- a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs +++ b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs @@ -61,16 +61,7 @@ namespace Umbraco.Cms.Core.Strings /// The short string helper. public DefaultShortStringHelperConfig WithDefault(RequestHandlerSettings requestHandlerSettings) { - // CharCollection could potentially be null if not invoked first by the framework, for instance in tests, so ensure that it's initialized. - IEnumerable charCollection = requestHandlerSettings.CharCollection; - if (charCollection is null) - { - charCollection = requestHandlerSettings.CharCollection; - if (charCollection is null) - { - throw new ArgumentNullException(nameof(requestHandlerSettings.CharCollection)); - } - } + IEnumerable charCollection = requestHandlerSettings.GetCharReplacements(); UrlReplaceCharacters = charCollection .Where(x => string.IsNullOrEmpty(x.Char) == false) diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs index d681d4a70e..f159ecbc85 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs @@ -1,7 +1,7 @@ using System.Linq; using NUnit.Framework; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Configuration.UmbracoSettings; +using Umbraco.Extensions; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models { @@ -18,8 +18,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models }; - var settings = new RequestHandlerSettings { CharCollection = userCollection }; - var actual = settings.CharCollection.ToList(); + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); var expectedCollection = RequestHandlerSettings.DefaultCharCollection.ToList(); expectedCollection.AddRange(userCollection); @@ -37,8 +37,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models new () { Char = "test2", Replacement = "replace2" } }; - var settings = new RequestHandlerSettings { CharCollection = userCollection, EnableDefaultCharReplacements = false }; - var actual = settings.CharCollection.ToList(); + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection, EnableDefaultCharReplacements = false }; + var actual = settings.GetCharReplacements().ToList(); Assert.AreEqual(userCollection.Length, actual.Count); Assert.That(actual, Is.EquivalentTo(userCollection)); @@ -53,8 +53,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models new () { Char = ".", Replacement = "dot" } }; - var settings = new RequestHandlerSettings { CharCollection = userCollection }; - var actual = settings.CharCollection.ToList(); + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length, actual.Count); @@ -74,8 +74,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models new () { Char = "new", Replacement = "new" } }; - var settings = new RequestHandlerSettings { CharCollection = userCollection }; - var actual = settings.CharCollection.ToList(); + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); // Add 1 to the length, because we're expecting to only add one new one Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length + 1, actual.Count); From 04d20269ab51addc0f127750a9ddfe4109690b21 Mon Sep 17 00:00:00 2001 From: Mole Date: Wed, 22 Dec 2021 13:02:13 +0100 Subject: [PATCH 22/53] Merge CharCollection and UserDefinedCharCollection Otherwise only CharCollection would work, and that's obsolete, now you can use either, but UserDefinedCharCollection takes priority --- .../UmbracoBuilder.Configuration.cs | 19 +---- .../RequestHandlerSettingsExtension.cs | 74 ++++++++++++++++--- 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 8043698b6e..d1a8542688 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Configuration.Models.Validation; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.DependencyInjection { @@ -78,23 +79,7 @@ namespace Umbraco.Cms.Core.DependencyInjection .AddUmbracoOptions() .AddUmbracoOptions(); - builder.Services.Configure(options => - { - var userDefinedReplacements = new List(); - IEnumerable config = builder.Config - .GetSection( - $"{Constants.Configuration.ConfigRequestHandler}:{nameof(RequestHandlerSettings.CharCollection)}") - .GetChildren(); - - foreach (IConfigurationSection section in config) - { - var @char = section.GetValue(nameof(CharItem.Char)); - var replacement = section.GetValue(nameof(CharItem.Replacement)); - userDefinedReplacements.Add(new CharItem { Char = @char, Replacement = replacement }); - } - - options.UserDefinedCharCollection = userDefinedReplacements; - }); + builder.Services.Configure(options => options.MergeReplacements(builder.Config)); return builder; } diff --git a/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs b/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs index d54d00188a..e9e6618f8c 100644 --- a/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs +++ b/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Configuration; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Configuration.UmbracoSettings; @@ -11,34 +13,84 @@ namespace Umbraco.Extensions /// public static class RequestHandlerSettingsExtension { + /// + /// Get concatenated user and default character replacements + /// taking into account + /// public static IEnumerable GetCharReplacements(this RequestHandlerSettings requestHandlerSettings) { - if (!requestHandlerSettings.EnableDefaultCharReplacements) + if (requestHandlerSettings.EnableDefaultCharReplacements is false) { return requestHandlerSettings.UserDefinedCharCollection ?? Enumerable.Empty(); } - if (requestHandlerSettings.UserDefinedCharCollection == null || !requestHandlerSettings.UserDefinedCharCollection.Any()) + if (requestHandlerSettings.UserDefinedCharCollection == null || requestHandlerSettings.UserDefinedCharCollection.Any() is false) { return RequestHandlerSettings.DefaultCharCollection; } - foreach (CharItem defaultReplacement in RequestHandlerSettings.DefaultCharCollection) + return MergeUnique(requestHandlerSettings.UserDefinedCharCollection, RequestHandlerSettings.DefaultCharCollection); + } + + /// + /// Merges CharCollection and UserDefinedCharCollection, prioritizing UserDefinedCharCollection + /// + internal static void MergeReplacements(this RequestHandlerSettings requestHandlerSettings, IConfiguration configuration) + { + string sectionKey = $"{Constants.Configuration.ConfigRequestHandler}:"; + + IEnumerable charCollection = GetReplacements( + configuration, + $"{sectionKey}{nameof(RequestHandlerSettings.CharCollection)}"); + + IEnumerable userDefinedCharCollection = GetReplacements( + configuration, + $"{sectionKey}{nameof(requestHandlerSettings.UserDefinedCharCollection)}"); + + IEnumerable mergedCollection = MergeUnique(userDefinedCharCollection, charCollection); + + requestHandlerSettings.UserDefinedCharCollection = mergedCollection; + } + + private static IEnumerable GetReplacements(IConfiguration configuration, string key) + { + var replacements = new List(); + IEnumerable config = configuration.GetSection(key).GetChildren(); + + foreach (IConfigurationSection section in config) { - foreach (CharItem userReplacement in requestHandlerSettings.UserDefinedCharCollection) + var @char = section.GetValue(nameof(CharItem.Char)); + var replacement = section.GetValue(nameof(CharItem.Replacement)); + replacements.Add(new CharItem { Char = @char, Replacement = replacement }); + } + + return replacements; + } + + /// + /// Merges two IEnumerable of CharItem without any duplicates, items in priorityReplacements will override those in alternativeReplacements + /// + private static IEnumerable MergeUnique( + IEnumerable priorityReplacements, + IEnumerable alternativeReplacements) + { + var priorityReplacementsList = priorityReplacements.ToList(); + var alternativeReplacementsList = alternativeReplacements.ToList(); + + foreach (CharItem alternativeReplacement in alternativeReplacementsList) + { + foreach (CharItem priorityReplacement in priorityReplacementsList) { - if (userReplacement.Char == defaultReplacement.Char) + if (priorityReplacement.Char == alternativeReplacement.Char) { - defaultReplacement.Replacement = userReplacement.Replacement; + alternativeReplacement.Replacement = priorityReplacement.Replacement; } } } - IEnumerable mergedCollections = - RequestHandlerSettings.DefaultCharCollection.Union( - requestHandlerSettings.UserDefinedCharCollection, new CharacterReplacementEqualityComparer()); - - return mergedCollections; + return priorityReplacementsList.Union( + alternativeReplacementsList, + new CharacterReplacementEqualityComparer()); } } } From fad0c948a15ed01fcca479b42c6b21a4b11be8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Kottal?= Date: Tue, 21 Dec 2021 13:51:35 +0100 Subject: [PATCH 23/53] Makes the width of .login-overlay__logo dynamic --- src/Umbraco.Web.UI.Client/src/less/pages/login.less | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/less/pages/login.less b/src/Umbraco.Web.UI.Client/src/less/pages/login.less index cf49af526b..2763a879ea 100644 --- a/src/Umbraco.Web.UI.Client/src/less/pages/login.less +++ b/src/Umbraco.Web.UI.Client/src/less/pages/login.less @@ -30,11 +30,14 @@ position: absolute; top: 22px; left: 25px; - width: 30px; height: 30px; z-index: 1; } +.login-overlay__logo > img { + max-height:100%; +} + .login-overlay .umb-modalcolumn { background: none; border: none; From 025db57b039345d50e94a29acef782bcb0554489 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Tue, 28 Dec 2021 14:50:19 +0100 Subject: [PATCH 24/53] Fix issue with missing URLs shown in redirect management --- src/Umbraco.Core/Routing/DefaultUrlProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs index ef7a29afaf..ae2c3d7f3a 100644 --- a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs @@ -154,7 +154,7 @@ namespace Umbraco.Cms.Core.Routing : DomainUtilities.DomainForNode(umbracoContext.PublishedSnapshot.Domains, _siteDomainMapper, int.Parse(route.Substring(0, pos), CultureInfo.InvariantCulture), current, culture); var defaultCulture = _localizationService.GetDefaultLanguageIsoCode(); - if (domainUri is not null || culture == defaultCulture || culture is null) + if (domainUri is not null || culture == defaultCulture || string.IsNullOrEmpty(culture)) { var url = AssembleUrl(domainUri, path, current, mode).ToString(); return UrlInfo.Url(url, culture); From be8e7b285bb978158e16ce746002b99c6a137379 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Tue, 28 Dec 2021 14:22:03 +0100 Subject: [PATCH 25/53] Fix incorrect styling of actions in tags editor --- .../src/less/components/umb-tags-editor.less | 36 ++++++++++--------- .../components/tags/umb-tags-editor.html | 1 + 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-tags-editor.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-tags-editor.less index 213807e685..2d41cbe6f2 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-tags-editor.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-tags-editor.less @@ -1,4 +1,4 @@ -.umb-tags-editor { +.umb-tags-editor { border: @inputBorder solid 1px; padding: 5px; min-height: 54px; @@ -14,24 +14,26 @@ position: relative; user-select: all; + > .btn-icon { + color: @white; + padding: 0; + position: relative; + cursor: pointer; + padding-left: 2px; + font-size: 15px; + right: -5px; + bottom: -1px; + user-select: none; + } + .umb_confirm-action { - > .btn-icon { - color: @white; - padding: 0; - position: relative; - cursor: pointer; - padding-left: 2px; - font-size: 15px; - right: -5px; - bottom: -1px; - user-select: none; - } - - .umb_confirm-action__overlay.-left { - top: 8px; - left: auto; - right: 15px; + &__overlay { + &.-left { + top: 8px; + left: auto; + right: 15px; + } } } } diff --git a/src/Umbraco.Web.UI.Client/src/views/components/tags/umb-tags-editor.html b/src/Umbraco.Web.UI.Client/src/views/components/tags/umb-tags-editor.html index e8f77d09a5..7277ff63c2 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/tags/umb-tags-editor.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/tags/umb-tags-editor.html @@ -13,6 +13,7 @@ From 421faf8d43237d93bfc9e2a77b64b902126998ae Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 3 Jan 2022 15:07:44 +0100 Subject: [PATCH 26/53] Fix assignDomain to handle case sensitive operating systems (#11784) --- src/Umbraco.Core/Actions/ActionAssignDomain.cs | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/cs.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/cy.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/da.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/de.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/en.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/es.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/fr.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/he.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/it.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/ja.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/ko.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/nb.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/nl.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/pl.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/pt.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/ru.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/sv.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/tr.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/zh.xml | 2 +- src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Umbraco.Core/Actions/ActionAssignDomain.cs b/src/Umbraco.Core/Actions/ActionAssignDomain.cs index e03e2de81c..6340a03082 100644 --- a/src/Umbraco.Core/Actions/ActionAssignDomain.cs +++ b/src/Umbraco.Core/Actions/ActionAssignDomain.cs @@ -8,7 +8,7 @@ public const char ActionLetter = 'I'; public char Letter => ActionLetter; - public string Alias => "assignDomain"; + public string Alias => "assigndomain"; public string Category => Constants.Conventions.PermissionCategories.AdministrationCategory; public string Icon => "home"; public bool ShowInNotifier => false; diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml b/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml index af701cd5e3..a90aa33355 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/cs.xml @@ -60,7 +60,7 @@ Ostatní - Povolit přístup k přiřazování kultury a názvů hostitelů + Povolit přístup k přiřazování kultury a názvů hostitelů Povolit přístup k zobrazení protokolu historie uzlu Povolit přístup k zobrazení uzlu Povolit přístup ke změně typu dokumentu daného uzlu diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml b/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml index 0692d01e7a..60ff3ffdb1 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/cy.xml @@ -5,7 +5,7 @@ https://www.method4.co.uk/ - Diwylliannau ac Enwau Gwesteia + Diwylliannau ac Enwau Gwesteia Trywydd Archwilio Dewis Nod Newid Math o Ddogfen diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/da.xml b/src/Umbraco.Web.UI/umbraco/config/lang/da.xml index 27901846a9..1f6dcff88f 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/da.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/da.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Tilføj domæne + Tilføj domæne Revisionsspor Gennemse elementer Skift Dokument Type diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/de.xml b/src/Umbraco.Web.UI/umbraco/config/lang/de.xml index 8f2ba350d0..0cabf29497 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/de.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/de.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Kulturen und Hostnamen + Kulturen und Hostnamen Protokoll Durchsuchen Dokumenttyp ändern diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml index 4411209cd5..47c104b822 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Culture and Hostnames + Culture and Hostnames Audit Trail Browse Node Change Document Type diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml index 5a17eafbb2..e4d2784c8c 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Culture and Hostnames + Culture and Hostnames Audit Trail Browse Node Change Document Type diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/es.xml b/src/Umbraco.Web.UI/umbraco/config/lang/es.xml index df78683aca..d99548f5c5 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/es.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/es.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Administrar dominios + Administrar dominios Historial Nodo de Exploración Cambiar tipo de documento diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml b/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml index 4681818c47..68dc28c99b 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/fr.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Culture et noms d'hôte + Culture et noms d'hôte Informations d'audit Parcourir Changer le type de document diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/he.xml b/src/Umbraco.Web.UI/umbraco/config/lang/he.xml index 9ee8bbf014..b99890282e 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/he.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/he.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - נהל שמות מתחם + נהל שמות מתחם מעקב ביקורות צפה בתוכן העתק diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/it.xml b/src/Umbraco.Web.UI/umbraco/config/lang/it.xml index a0d89bff2d..7e20c0b266 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/it.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/it.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Gestisci hostnames + Gestisci hostnames Audit Trail Sfoglia Cambia tipo di documento diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml b/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml index 4b98adad26..142f7d055b 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/ja.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - ドメインの割り当て + ドメインの割り当て 動作記録 ノードの参照 ドキュメントタイプの変更 diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml b/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml index 792dd6700c..c340f3f30a 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/ko.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - 호스트명 관리 + 호스트명 관리 감사 추적 노드 탐색 복사 diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml b/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml index 1c47969189..44fad5d2ec 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/nb.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Angi domene + Angi domene Revisjoner Bla gjennom Skift dokumenttype diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml index f830c3368d..0c24b119c9 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Beheer domeinnamen + Beheer domeinnamen Documentgeschiedenis Node bekijken Documenttype wijzigen diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml b/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml index dfbc324df6..712b695f77 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/pl.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Zarządzanie hostami + Zarządzanie hostami Historia zmian Przeglądaj węzeł Zmień typ dokumentu diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml b/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml index 542b03abc1..eac232e851 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/pt.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Gerenciar hostnames + Gerenciar hostnames Caminho de Auditoria Navegar o Nó Copiar diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml b/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml index 9c1d9e12fb..5c18fbc682 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/ru.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Языки и домены + Языки и домены История исправлений Просмотреть Изменить тип документа diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml b/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml index e0e2235ae9..dda58366d8 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/sv.xml @@ -8,7 +8,7 @@ Innehåll - Hantera domännamn + Hantera domännamn Hantera versioner Surfa på sidan Ändra dokumenttyp diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml b/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml index 58c0f7f94b..66d097b1e9 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/tr.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - Kültür ve Ana Bilgisayar Adları + Kültür ve Ana Bilgisayar Adları Denetim Yolu Düğüme Göz At Belge Türünü Değiştir diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml b/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml index ba51488f9f..8c78154b62 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/zh.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - 管理主机名 + 管理主机名 跟踪审计 浏览节点 改变文档类型 diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml b/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml index 8d5cf16de2..992a7ba55b 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/zh_tw.xml @@ -5,7 +5,7 @@ https://our.umbraco.com/documentation/Extending-Umbraco/Language-Files - 管理主機名稱 + 管理主機名稱 跟蹤審計 流覽節點 改變文檔類型 From bbfa975096adf2f10bfdc7c661f316f934dfc602 Mon Sep 17 00:00:00 2001 From: Mole Date: Mon, 3 Jan 2022 15:36:52 +0100 Subject: [PATCH 27/53] Bump versions to non-rc --- build/templates/UmbracoPackage/.template.config/template.json | 2 +- build/templates/UmbracoProject/.template.config/template.json | 2 +- src/Directory.Build.props | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build/templates/UmbracoPackage/.template.config/template.json b/build/templates/UmbracoPackage/.template.config/template.json index b69b63dd1c..6a6f0fb9ab 100644 --- a/build/templates/UmbracoPackage/.template.config/template.json +++ b/build/templates/UmbracoPackage/.template.config/template.json @@ -24,7 +24,7 @@ "version": { "type": "parameter", "datatype": "string", - "defaultValue": "9.2.0-rc", + "defaultValue": "9.2.0", "description": "The version of Umbraco to load using NuGet", "replaces": "UMBRACO_VERSION_FROM_TEMPLATE" }, diff --git a/build/templates/UmbracoProject/.template.config/template.json b/build/templates/UmbracoProject/.template.config/template.json index 6ec0babd70..d3c3d1a79f 100644 --- a/build/templates/UmbracoProject/.template.config/template.json +++ b/build/templates/UmbracoProject/.template.config/template.json @@ -57,7 +57,7 @@ "version": { "type": "parameter", "datatype": "string", - "defaultValue": "9.2.0-rc", + "defaultValue": "9.2.0", "description": "The version of Umbraco to load using NuGet", "replaces": "UMBRACO_VERSION_FROM_TEMPLATE" }, diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fed70283e7..8d923733fa 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ 9.2.0 9.2.0 - 9.2.0-rc + 9.2.0 9.2.0 9.0 en-US From 65c0d8fceca475d8a77ffb9289c4217b2bfeb3a6 Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 21 Dec 2021 12:48:35 +0100 Subject: [PATCH 28/53] V9: Use current request for emails (#11778) * Use request url for email * Fixed potential null ref exceptions Co-authored-by: Bjarke Berg --- .../Controllers/AuthenticationController.cs | 56 ++++++++++- .../Controllers/UsersController.cs | 92 ++++++++++++++++--- .../Extensions/HttpRequestExtensions.cs | 31 ++++++- 3 files changed, 160 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs index f159011d80..30ad3f75ae 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; @@ -29,6 +30,7 @@ using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Controllers; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.Models; using Umbraco.Extensions; @@ -71,9 +73,11 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers private readonly LinkGenerator _linkGenerator; private readonly IBackOfficeExternalLoginProviders _externalAuthenticationOptions; private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly WebRoutingSettings _webRoutingSettings; // TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here - + [ActivatorUtilitiesConstructor] public AuthenticationController( IBackOfficeSecurityAccessor backofficeSecurityAccessor, IBackOfficeUserManager backOfficeUserManager, @@ -91,7 +95,9 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers IHostingEnvironment hostingEnvironment, LinkGenerator linkGenerator, IBackOfficeExternalLoginProviders externalAuthenticationOptions, - IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions) + IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions, + IHttpContextAccessor httpContextAccessor, + IOptions webRoutingSettings) { _backofficeSecurityAccessor = backofficeSecurityAccessor; _userManager = backOfficeUserManager; @@ -110,6 +116,50 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers _linkGenerator = linkGenerator; _externalAuthenticationOptions = externalAuthenticationOptions; _backOfficeTwoFactorOptions = backOfficeTwoFactorOptions; + _httpContextAccessor = httpContextAccessor; + _webRoutingSettings = webRoutingSettings.Value; + } + + [Obsolete("Use constructor that also takes IHttpAccessor and IOptions, scheduled for removal in V11")] + public AuthenticationController( + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IBackOfficeUserManager backOfficeUserManager, + IBackOfficeSignInManager signInManager, + IUserService userService, + ILocalizedTextService textService, + IUmbracoMapper umbracoMapper, + IOptions globalSettings, + IOptions securitySettings, + ILogger logger, + IIpResolver ipResolver, + IOptions passwordConfiguration, + IEmailSender emailSender, + ISmsSender smsSender, + IHostingEnvironment hostingEnvironment, + LinkGenerator linkGenerator, + IBackOfficeExternalLoginProviders externalAuthenticationOptions, + IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions) + : this( + backofficeSecurityAccessor, + backOfficeUserManager, + signInManager, + userService, + textService, + umbracoMapper, + globalSettings, + securitySettings, + logger, + ipResolver, + passwordConfiguration, + emailSender, + smsSender, + hostingEnvironment, + linkGenerator, + externalAuthenticationOptions, + backOfficeTwoFactorOptions, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService>()) + { } /// @@ -629,7 +679,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers }); // Construct full URL using configured application URL (which will fall back to request) - var applicationUri = _hostingEnvironment.ApplicationMainUrl; + Uri applicationUri = _httpContextAccessor.GetRequiredHttpContext().Request.GetApplicationUri(_webRoutingSettings); var callbackUri = new Uri(applicationUri, action); return callbackUri.ToString(); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index 79e7838110..72377c0670 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MimeKit; @@ -42,6 +43,7 @@ using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; using Constants = Umbraco.Cms.Core.Constants; @@ -75,7 +77,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers private readonly UserEditorAuthorizationHelper _userEditorAuthorizationHelper; private readonly IPasswordChanger _passwordChanger; private readonly ILogger _logger; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly WebRoutingSettings _webRoutingSettings; + [ActivatorUtilitiesConstructor] public UsersController( MediaFileManager mediaFileManager, IOptions contentSettings, @@ -96,7 +101,9 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers LinkGenerator linkGenerator, IBackOfficeExternalLoginProviders externalLogins, UserEditorAuthorizationHelper userEditorAuthorizationHelper, - IPasswordChanger passwordChanger) + IPasswordChanger passwordChanger, + IHttpContextAccessor httpContextAccessor, + IOptions webRoutingSettings) { _mediaFileManager = mediaFileManager; _contentSettings = contentSettings.Value; @@ -119,6 +126,55 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers _userEditorAuthorizationHelper = userEditorAuthorizationHelper; _passwordChanger = passwordChanger; _logger = _loggerFactory.CreateLogger(); + _httpContextAccessor = httpContextAccessor; + _webRoutingSettings = webRoutingSettings.Value; + } + + [Obsolete("Use constructor that also takes IHttpAccessor and IOptions, scheduled for removal in V11")] + public UsersController( + MediaFileManager mediaFileManager, + IOptions contentSettings, + IHostingEnvironment hostingEnvironment, + ISqlContext sqlContext, + IImageUrlGenerator imageUrlGenerator, + IOptions securitySettings, + IEmailSender emailSender, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + AppCaches appCaches, + IShortStringHelper shortStringHelper, + IUserService userService, + ILocalizedTextService localizedTextService, + IUmbracoMapper umbracoMapper, + IOptions globalSettings, + IBackOfficeUserManager backOfficeUserManager, + ILoggerFactory loggerFactory, + LinkGenerator linkGenerator, + IBackOfficeExternalLoginProviders externalLogins, + UserEditorAuthorizationHelper userEditorAuthorizationHelper, + IPasswordChanger passwordChanger) + : this(mediaFileManager, + contentSettings, + hostingEnvironment, + sqlContext, + imageUrlGenerator, + securitySettings, + emailSender, + backofficeSecurityAccessor, + appCaches, + shortStringHelper, + userService, + localizedTextService, + umbracoMapper, + globalSettings, + backOfficeUserManager, + loggerFactory, + linkGenerator, + externalLogins, + userEditorAuthorizationHelper, + passwordChanger, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService>()) + { } /// @@ -421,20 +477,25 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers /// public async Task> PostInviteUser(UserInvite userSave) { - if (userSave == null) throw new ArgumentNullException("userSave"); + if (userSave == null) + { + throw new ArgumentNullException("userSave"); + } if (userSave.Message.IsNullOrWhiteSpace()) + { ModelState.AddModelError("Message", "Message cannot be empty"); + } IUser user; if (_securitySettings.UsernameIsEmail) { - //ensure it's the same + // ensure it's the same userSave.Username = userSave.Email; } else { - //first validate the username if we're showing it + // first validate the username if we're showing it var userResult = CheckUniqueUsername(userSave.Username, u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue); if (!(userResult.Result is null)) { @@ -443,6 +504,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers user = userResult.Value; } + user = CheckUniqueEmail(userSave.Email, u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue); if (ModelState.IsValid == false) @@ -455,7 +517,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers return ValidationProblem("No Email server is configured"); } - //Perform authorization here to see if the current user can actually save this user with the info being requested + // Perform authorization here to see if the current user can actually save this user with the info being requested var canSaveUser = _userEditorAuthorizationHelper.IsAuthorized(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser, user, null, null, userSave.UserGroups); if (canSaveUser == false) { @@ -464,8 +526,8 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers if (user == null) { - //we want to create the user with the UserManager, this ensures the 'empty' (special) password - //format is applied without us having to duplicate that logic + // we want to create the user with the UserManager, this ensures the 'empty' (special) password + // format is applied without us having to duplicate that logic var identityUser = BackOfficeIdentityUser.CreateNew(_globalSettings, userSave.Username, userSave.Email, _globalSettings.DefaultUILanguage); identityUser.Name = userSave.Name; @@ -475,21 +537,21 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers return ValidationProblem(created.Errors.ToErrorMessage()); } - //now re-look the user back up + // now re-look the user back up user = _userService.GetByEmail(userSave.Email); } - //map the save info over onto the user + // map the save info over onto the user user = _umbracoMapper.Map(userSave, user); - //ensure the invited date is set + // ensure the invited date is set user.InvitedDate = DateTime.Now; - //Save the updated user (which will process the user groups too) + // Save the updated user (which will process the user groups too) _userService.Save(user); var display = _umbracoMapper.Map(user); - //send the email + // send the email await SendUserInviteEmailAsync(display, _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Name, _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Email, user, userSave.Message); display.AddSuccessNotification(_localizedTextService.Localize("speechBubbles","resendInviteHeader"), _localizedTextService.Localize("speechBubbles","resendInviteSuccess", new[] { user.Name })); @@ -544,14 +606,14 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers }); // Construct full URL using configured application URL (which will fall back to request) - var applicationUri = _hostingEnvironment.ApplicationMainUrl; + Uri applicationUri = _httpContextAccessor.GetRequiredHttpContext().Request.GetApplicationUri(_webRoutingSettings); var inviteUri = new Uri(applicationUri, action); var emailSubject = _localizedTextService.Localize("user","inviteEmailCopySubject", - //Ensure the culture of the found user is used for the email! + // Ensure the culture of the found user is used for the email! UmbracoUserExtensions.GetUserCulture(to.Language, _localizedTextService, _globalSettings)); var emailBody = _localizedTextService.Localize("user","inviteEmailCopyFormat", - //Ensure the culture of the found user is used for the email! + // Ensure the culture of the found user is used for the email! UmbracoUserExtensions.GetUserCulture(to.Language, _localizedTextService, _globalSettings), new[] { userDisplay.Name, from, message, inviteUri.ToString(), senderEmail }); diff --git a/src/Umbraco.Web.Common/Extensions/HttpRequestExtensions.cs b/src/Umbraco.Web.Common/Extensions/HttpRequestExtensions.cs index c7c2bb3115..2aeb2555eb 100644 --- a/src/Umbraco.Web.Common/Extensions/HttpRequestExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/HttpRequestExtensions.cs @@ -1,9 +1,12 @@ -using System.IO; +using System; +using System.IO; using System.Net; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Routing; namespace Umbraco.Extensions @@ -107,5 +110,31 @@ namespace Umbraco.Extensions return result; } } + + /// + /// Gets the application URI, will use the one specified in settings if present + /// + public static Uri GetApplicationUri(this HttpRequest request, WebRoutingSettings routingSettings) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (routingSettings == null) + { + throw new ArgumentNullException(nameof(routingSettings)); + } + + if (string.IsNullOrEmpty(routingSettings.UmbracoApplicationUrl)) + { + var requestUri = new Uri(request.GetDisplayUrl()); + + // Create a new URI with the relative uri as /, this ensures that only the base path is returned. + return new Uri(requestUri, "/"); + } + + return new Uri(routingSettings.UmbracoApplicationUrl); + } } } From 763cb70e677ac0c85557b19b5df09eccfa1b9dfb Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Mon, 20 Dec 2021 08:45:11 +0100 Subject: [PATCH 29/53] Move member properties to Member Content App (V9 merge regression) (#11768) * Fix regression after merging to v9 * Update test to align with removed member properties --- .../Mapping/MemberTabsAndPropertiesMapper.cs | 34 +++++-------------- .../Controllers/MemberControllerUnitTests.cs | 14 -------- 2 files changed, 9 insertions(+), 39 deletions(-) diff --git a/src/Umbraco.Core/Models/Mapping/MemberTabsAndPropertiesMapper.cs b/src/Umbraco.Core/Models/Mapping/MemberTabsAndPropertiesMapper.cs index d61e32d88a..d8ac8d635d 100644 --- a/src/Umbraco.Core/Models/Mapping/MemberTabsAndPropertiesMapper.cs +++ b/src/Umbraco.Core/Models/Mapping/MemberTabsAndPropertiesMapper.cs @@ -65,14 +65,11 @@ namespace Umbraco.Cms.Core.Models.Mapping var resolved = base.Map(source, context); - // This is kind of a hack because a developer is supposed to be allowed to set their property editor - would have been much easier - // if we just had all of the membership provider fields on the member table :( - // TODO: But is there a way to map the IMember.IsLockedOut to the property ? i dunno. + // IMember.IsLockedOut can't be set to true, so make it readonly when that's the case (you can only unlock) var isLockedOutProperty = resolved.SelectMany(x => x.Properties).FirstOrDefault(x => x.Alias == Constants.Conventions.Member.IsLockedOut); if (isLockedOutProperty?.Value != null && isLockedOutProperty.Value.ToString() != "1") { - isLockedOutProperty.View = "readonlyvalue"; - isLockedOutProperty.Value = _localizedTextService.Localize("general", "no"); + isLockedOutProperty.Readonly = true; } return resolved; @@ -191,20 +188,6 @@ namespace Umbraco.Cms.Core.Models.Mapping { var properties = new List { - new ContentPropertyDisplay - { - Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}id", - Label = _localizedTextService.Localize("general","id"), - Value = new List {member.Id.ToString(), member.Key.ToString()}, - View = "idwithguid" - }, - new ContentPropertyDisplay - { - Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}doctype", - Label = _localizedTextService.Localize("content","membertype"), - Value = _localizedTextService.UmbracoDictionaryTranslate(CultureDictionary, member.ContentType.Name), - View = _propertyEditorCollection[Constants.PropertyEditors.Aliases.Label].GetValueEditor().View - }, GetLoginProperty(member, _localizedTextService), new ContentPropertyDisplay { @@ -212,7 +195,7 @@ namespace Umbraco.Cms.Core.Models.Mapping Label = _localizedTextService.Localize("general","email"), Value = member.Email, View = "email", - Validation = {Mandatory = true} + Validation = { Mandatory = true } }, new ContentPropertyDisplay { @@ -221,12 +204,10 @@ namespace Umbraco.Cms.Core.Models.Mapping Value = new Dictionary { // TODO: why ignoreCase, what are we doing here?! - {"newPassword", member.GetAdditionalDataValueIgnoreCase("NewPassword", null)}, + { "newPassword", member.GetAdditionalDataValueIgnoreCase("NewPassword", null) } }, - // TODO: Hard coding this because the changepassword doesn't necessarily need to be a resolvable (real) property editor View = "changepassword", - // Initialize the dictionary with the configuration from the default membership provider - Config = GetPasswordConfig(member) + Config = GetPasswordConfig(member) // Initialize the dictionary with the configuration from the default membership provider }, new ContentPropertyDisplay { @@ -234,7 +215,10 @@ namespace Umbraco.Cms.Core.Models.Mapping Label = _localizedTextService.Localize("content","membergroup"), Value = GetMemberGroupValue(member.Username), View = "membergroups", - Config = new Dictionary {{"IsRequired", true}} + Config = new Dictionary + { + { "IsRequired", true } + } } }; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs index da5175f272..069e94f732 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs @@ -618,20 +618,6 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.BackOffice.Controllers Id = 77, Properties = new List() { - new ContentPropertyDisplay() - { - Alias = "_umb_id", - View = "idwithguid", - Value = new [] - { - "123", - "guid" - } - }, - new ContentPropertyDisplay() - { - Alias = "_umb_doctype" - }, new ContentPropertyDisplay() { Alias = "_umb_login" From a54c5bb21d4b9af4d984e1c7d0d45a20693ff3d4 Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 4 Jan 2022 10:11:34 +0100 Subject: [PATCH 30/53] V8: Merge package telemetry from V9 (#11785) * Merge Telemetry classes from V9 * Use TelemetryService in ReportSiteTask * Migrate tests --- .../CompositionExtensions/Services.cs | 3 + src/Umbraco.Core/Manifest/ManifestParser.cs | 2 +- src/Umbraco.Core/Manifest/PackageManifest.cs | 35 ++++++++ .../Telemetry/ITelemetryService.cs | 15 ++++ .../Telemetry/Models/PackageTelemetry.cs | 28 +++++++ .../Telemetry/Models/TelemetryReportData.cs | 34 ++++++++ .../Telemetry/TelemetryService.cs | 81 +++++++++++++++++++ src/Umbraco.Core/Umbraco.Core.csproj | 4 + .../Manifest/ManifestParserTests.cs | 22 +++++ src/Umbraco.Web/Telemetry/ReportSiteTask.cs | 34 +++----- .../Telemetry/TelemetryComponent.cs | 9 ++- 11 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 src/Umbraco.Core/Telemetry/ITelemetryService.cs create mode 100644 src/Umbraco.Core/Telemetry/Models/PackageTelemetry.cs create mode 100644 src/Umbraco.Core/Telemetry/Models/TelemetryReportData.cs create mode 100644 src/Umbraco.Core/Telemetry/TelemetryService.cs diff --git a/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs b/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs index 4f9a953212..e912f7281c 100644 --- a/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs +++ b/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs @@ -8,6 +8,7 @@ using Umbraco.Core.Logging; using Umbraco.Core.Packaging; using Umbraco.Core.Services; using Umbraco.Core.Services.Implement; +using Umbraco.Core.Telemetry; namespace Umbraco.Core.Composing.CompositionExtensions { @@ -79,6 +80,8 @@ namespace Umbraco.Core.Composing.CompositionExtensions factory.GetInstance(), factory.GetInstance(), new DirectoryInfo(IOHelper.GetRootDirectorySafe()))); + composition.RegisterUnique(); + return composition; } diff --git a/src/Umbraco.Core/Manifest/ManifestParser.cs b/src/Umbraco.Core/Manifest/ManifestParser.cs index 9bbb0875d8..a9ce06e8da 100644 --- a/src/Umbraco.Core/Manifest/ManifestParser.cs +++ b/src/Umbraco.Core/Manifest/ManifestParser.cs @@ -67,7 +67,7 @@ namespace Umbraco.Core.Manifest /// /// Gets all manifests. /// - private IEnumerable GetManifests() + internal IEnumerable GetManifests() { var manifests = new List(); diff --git a/src/Umbraco.Core/Manifest/PackageManifest.cs b/src/Umbraco.Core/Manifest/PackageManifest.cs index e50eb69467..cadd661e28 100644 --- a/src/Umbraco.Core/Manifest/PackageManifest.cs +++ b/src/Umbraco.Core/Manifest/PackageManifest.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using Newtonsoft.Json; using Umbraco.Core.PropertyEditors; @@ -9,6 +10,28 @@ namespace Umbraco.Core.Manifest /// public class PackageManifest { + private string _packageName; + + [JsonProperty("name")] + public string PackageName + { + get + { + if (string.IsNullOrWhiteSpace(_packageName) is false) + { + return _packageName; + } + + if (string.IsNullOrWhiteSpace(Source) is false) + { + _packageName = Path.GetFileName(Path.GetDirectoryName(Source)); + } + + return _packageName; + } + set => _packageName = value; + } + /// /// Gets the source path of the manifest. /// @@ -66,5 +89,17 @@ namespace Umbraco.Core.Manifest /// [JsonProperty("sections")] public ManifestSection[] Sections { get; set; } = Array.Empty(); + + /// + /// Gets or sets the version of the package + /// + [JsonProperty("version")] + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether telemetry is allowed + /// + [JsonProperty("allowPackageTelemetry")] + public bool AllowPackageTelemetry { get; set; } = true; } } diff --git a/src/Umbraco.Core/Telemetry/ITelemetryService.cs b/src/Umbraco.Core/Telemetry/ITelemetryService.cs new file mode 100644 index 0000000000..f4ca3736f6 --- /dev/null +++ b/src/Umbraco.Core/Telemetry/ITelemetryService.cs @@ -0,0 +1,15 @@ +using Umbraco.Core.Telemetry.Models; + +namespace Umbraco.Core.Telemetry +{ + /// + /// Service which gathers the data for telemetry reporting + /// + public interface ITelemetryService + { + /// + /// Try and get the + /// + bool TryGetTelemetryReportData(out TelemetryReportData telemetryReportData); + } +} diff --git a/src/Umbraco.Core/Telemetry/Models/PackageTelemetry.cs b/src/Umbraco.Core/Telemetry/Models/PackageTelemetry.cs new file mode 100644 index 0000000000..a86c4c4fa2 --- /dev/null +++ b/src/Umbraco.Core/Telemetry/Models/PackageTelemetry.cs @@ -0,0 +1,28 @@ +using System; +using System.Runtime.Serialization; + +namespace Umbraco.Core.Telemetry.Models +{ + /// + /// Serializable class containing information about an installed package. + /// + [Serializable] + [DataContract(Name = "packageTelemetry")] + public class PackageTelemetry + { + /// + /// Gets or sets the name of the installed package. + /// + [DataMember(Name = "name")] + public string Name { get; set; } + + /// + /// Gets or sets the version of the installed package. + /// + /// + /// This may be an empty string if no version is specified, or if package telemetry has been restricted. + /// + [DataMember(Name = "version")] + public string Version { get; set; } + } +} diff --git a/src/Umbraco.Core/Telemetry/Models/TelemetryReportData.cs b/src/Umbraco.Core/Telemetry/Models/TelemetryReportData.cs new file mode 100644 index 0000000000..560bd1dcfe --- /dev/null +++ b/src/Umbraco.Core/Telemetry/Models/TelemetryReportData.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace Umbraco.Core.Telemetry.Models +{ + /// + /// Serializable class containing telemetry information. + /// + [DataContract] + public class TelemetryReportData + { + /// + /// Gets or sets a random GUID to prevent an instance posting multiple times pr. day. + /// + [DataMember(Name = "id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the Umbraco CMS version. + /// + [DataMember(Name = "version")] + public string Version { get; set; } + + /// + /// Gets or sets an enumerable containing information about packages. + /// + /// + /// Contains only the name and version of the packages, unless no version is specified. + /// + [DataMember(Name = "packages")] + public IEnumerable Packages { get; set; } + } +} diff --git a/src/Umbraco.Core/Telemetry/TelemetryService.cs b/src/Umbraco.Core/Telemetry/TelemetryService.cs new file mode 100644 index 0000000000..a1b1f39ecd --- /dev/null +++ b/src/Umbraco.Core/Telemetry/TelemetryService.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using Umbraco.Core.Configuration; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.Manifest; +using Umbraco.Core.Telemetry.Models; + +namespace Umbraco.Core.Telemetry +{ + /// + internal class TelemetryService : ITelemetryService + { + private readonly IUmbracoSettingsSection _settings; + private readonly ManifestParser _manifestParser; + + /// + /// Initializes a new instance of the class. + /// + public TelemetryService( + ManifestParser manifestParser, + IUmbracoSettingsSection settings) + { + _manifestParser = manifestParser; + _settings = settings; + } + + /// + public bool TryGetTelemetryReportData(out TelemetryReportData telemetryReportData) + { + if (TryGetTelemetryId(out Guid telemetryId) is false) + { + telemetryReportData = null; + return false; + } + + telemetryReportData = new TelemetryReportData + { + Id = telemetryId, + Version = UmbracoVersion.SemanticVersion.ToSemanticString(), + Packages = GetPackageTelemetry() + }; + return true; + } + + private bool TryGetTelemetryId(out Guid telemetryId) + { + // Parse telemetry string as a GUID & verify its a GUID and not some random string + // since users may have messed with or decided to empty the app setting or put in something random + if (Guid.TryParse(_settings.BackOffice.Id, out var parsedTelemetryId) is false) + { + telemetryId = Guid.Empty; + return false; + } + + telemetryId = parsedTelemetryId; + return true; + } + + private IEnumerable GetPackageTelemetry() + { + List packages = new (); + var manifests = _manifestParser.GetManifests(); + + foreach (var manifest in manifests) + { + if (manifest.AllowPackageTelemetry is false) + { + continue; + } + + packages.Add(new PackageTelemetry + { + Name = manifest.PackageName, + Version = manifest.Version ?? string.Empty + }); + } + + return packages; + } + } +} diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 632031a2e6..6729930174 100755 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -396,6 +396,10 @@ + + + + diff --git a/src/Umbraco.Tests/Manifest/ManifestParserTests.cs b/src/Umbraco.Tests/Manifest/ManifestParserTests.cs index 6b140e3757..26c031c7aa 100644 --- a/src/Umbraco.Tests/Manifest/ManifestParserTests.cs +++ b/src/Umbraco.Tests/Manifest/ManifestParserTests.cs @@ -443,5 +443,27 @@ javascript: ['~/test.js',/*** some note about stuff asd09823-4**09234*/ '~/test2 Assert.AreEqual("Content", manifest.Sections[0].Name); Assert.AreEqual("World", manifest.Sections[1].Name); } + + [Test] + public void CanParseManifest_Version() + { + const string json = @"{""name"": ""VersionPackage"", ""version"": ""1.0.0""}"; + PackageManifest manifest = _parser.ParseManifest(json); + + Assert.Multiple(() => + { + Assert.AreEqual("VersionPackage", manifest.PackageName); + Assert.AreEqual("1.0.0", manifest.Version); + }); + } + + [Test] + public void CanParseManifest_TrackingAllowed() + { + const string json = @"{""allowPackageTelemetry"": false }"; + PackageManifest manifest = _parser.ParseManifest(json); + + Assert.IsFalse(manifest.AllowPackageTelemetry); + } } } diff --git a/src/Umbraco.Web/Telemetry/ReportSiteTask.cs b/src/Umbraco.Web/Telemetry/ReportSiteTask.cs index 24ac7cbf3a..78d4b24ab6 100644 --- a/src/Umbraco.Web/Telemetry/ReportSiteTask.cs +++ b/src/Umbraco.Web/Telemetry/ReportSiteTask.cs @@ -1,14 +1,11 @@ using Newtonsoft.Json; using System; using System.Net.Http; -using System.Runtime.Serialization; using System.Text; using System.Threading; using System.Threading.Tasks; -using Umbraco.Core; -using Umbraco.Core.Configuration; -using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Logging; +using Umbraco.Core.Telemetry; using Umbraco.Web.Scheduling; namespace Umbraco.Web.Telemetry @@ -17,14 +14,19 @@ namespace Umbraco.Web.Telemetry { private readonly IProfilingLogger _logger; private static HttpClient _httpClient; - private readonly IUmbracoSettingsSection _settings; + private readonly ITelemetryService _telemetryService; - public ReportSiteTask(IBackgroundTaskRunner runner, int delayBeforeWeStart, int howOftenWeRepeat, IProfilingLogger logger, IUmbracoSettingsSection settings) + public ReportSiteTask( + IBackgroundTaskRunner runner, + int delayBeforeWeStart, + int howOftenWeRepeat, + IProfilingLogger logger, + ITelemetryService telemetryService) : base(runner, delayBeforeWeStart, howOftenWeRepeat) { _logger = logger; _httpClient = new HttpClient(); - _settings = settings; + _telemetryService = telemetryService; } /// @@ -34,12 +36,9 @@ namespace Umbraco.Web.Telemetry /// A value indicating whether to repeat the task. public override async Task PerformRunAsync(CancellationToken token) { - // Try & get a value stored in umbracoSettings.config on the backoffice XML element ID attribute - var backofficeIdentifierRaw = _settings.BackOffice.Id; - // Parse as a GUID & verify its a GUID and not some random string // In case of users may have messed or decided to empty the file contents or put in something random - if (Guid.TryParse(backofficeIdentifierRaw, out var telemetrySiteIdentifier) == false) + if (_telemetryService.TryGetTelemetryReportData(out var telemetryReportData) is false) { // Some users may have decided to mess with the XML attribute and put in something else // Stop repeating this task (no need to keep checking) @@ -61,8 +60,7 @@ namespace Umbraco.Web.Telemetry using (var request = new HttpRequestMessage(HttpMethod.Post, "installs/")) { - var postData = new TelemetryReportData { Id = telemetrySiteIdentifier, Version = UmbracoVersion.SemanticVersion.ToSemanticString() }; - request.Content = new StringContent(JsonConvert.SerializeObject(postData), Encoding.UTF8, "application/json"); //CONTENT-TYPE header + request.Content = new StringContent(JsonConvert.SerializeObject(telemetryReportData), Encoding.UTF8, "application/json"); //CONTENT-TYPE header // Set a low timeout - no need to use a larger default timeout for this POST request _httpClient.Timeout = new TimeSpan(0, 0, 1); @@ -86,15 +84,5 @@ namespace Umbraco.Web.Telemetry } public override bool IsAsync => true; - - [DataContract] - private class TelemetryReportData - { - [DataMember(Name = "id")] - public Guid Id { get; set; } - - [DataMember(Name = "version")] - public string Version { get; set; } - } } } diff --git a/src/Umbraco.Web/Telemetry/TelemetryComponent.cs b/src/Umbraco.Web/Telemetry/TelemetryComponent.cs index 1ae9ad9764..c3d29f72ca 100644 --- a/src/Umbraco.Web/Telemetry/TelemetryComponent.cs +++ b/src/Umbraco.Web/Telemetry/TelemetryComponent.cs @@ -1,6 +1,7 @@ using Umbraco.Core.Composing; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Logging; +using Umbraco.Core.Telemetry; using Umbraco.Web.Scheduling; namespace Umbraco.Web.Telemetry @@ -8,13 +9,13 @@ namespace Umbraco.Web.Telemetry public class TelemetryComponent : IComponent { private readonly IProfilingLogger _logger; - private readonly IUmbracoSettingsSection _settings; + private readonly ITelemetryService _telemetryService; private BackgroundTaskRunner _telemetryReporterRunner; - public TelemetryComponent(IProfilingLogger logger, IUmbracoSettingsSection settings) + public TelemetryComponent(IProfilingLogger logger, IUmbracoSettingsSection settings, ITelemetryService telemetryService) { _logger = logger; - _settings = settings; + _telemetryService = telemetryService; } public void Initialize() @@ -26,7 +27,7 @@ namespace Umbraco.Web.Telemetry const int howOftenWeRepeat = 60 * 1000 * 60 * 24; // 60 * 1000 * 60 * 24 = 24hrs (86400000) // As soon as we add our task to the runner it will start to run (after its delay period) - var task = new ReportSiteTask(_telemetryReporterRunner, delayBeforeWeStart, howOftenWeRepeat, _logger, _settings); + var task = new ReportSiteTask(_telemetryReporterRunner, delayBeforeWeStart, howOftenWeRepeat, _logger, _telemetryService); _telemetryReporterRunner.TryAdd(task); } From 75bb8051bff84e3cc2e9da2379d696f02d7a8845 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Wed, 5 Jan 2022 11:11:27 +0100 Subject: [PATCH 31/53] Prune Image Cropper and Media Picker (v3) values (#11805) * Clean up redundant/default Umbraco.ImageCropper data * Fix ToString() and add HasCrops() method * Re-use crop/focal point pruning for Umbraco.MediaPicker3 * Fix ImageCropperTest Co-authored-by: Elitsa Marinovska --- .../ValueConverters/ImageCropperValue.cs | 88 ++++++++++++++----- .../PropertyEditors/ImageCropperTest.cs | 6 +- .../ImageCropperPropertyValueEditor.cs | 39 +++++--- .../MediaPicker3PropertyEditor.cs | 81 ++++++++++++++++- 4 files changed, 174 insertions(+), 40 deletions(-) diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/ImageCropperValue.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/ImageCropperValue.cs index f2151778d9..555c198f7d 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/ImageCropperValue.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/ImageCropperValue.cs @@ -1,12 +1,11 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Globalization; using System.Linq; using System.Runtime.Serialization; -using System.Text; using System.Web; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Core.Composing; using Umbraco.Core.Models; using Umbraco.Core.Serialization; @@ -18,14 +17,14 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters /// [JsonConverter(typeof(NoTypeConverterJsonConverter))] [TypeConverter(typeof(ImageCropperValueTypeConverter))] - [DataContract(Name="imageCropDataSet")] + [DataContract(Name = "imageCropDataSet")] public class ImageCropperValue : IHtmlString, IEquatable { /// /// Gets or sets the value source image. /// - [DataMember(Name="src")] - public string Src { get; set;} + [DataMember(Name = "src")] + public string Src { get; set; } /// /// Gets or sets the value focal point. @@ -41,9 +40,7 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters /// public override string ToString() - { - return Crops != null ? (Crops.Any() ? JsonConvert.SerializeObject(this) : Src) : string.Empty; - } + => HasCrops() || HasFocalPoint() ? JsonConvert.SerializeObject(this, Formatting.None) : Src; /// public string ToHtmlString() => Src; @@ -134,13 +131,19 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters /// /// public bool HasFocalPoint() - => FocalPoint != null && (FocalPoint.Left != 0.5m || FocalPoint.Top != 0.5m); + => FocalPoint is ImageCropperFocalPoint focalPoint && (focalPoint.Left != 0.5m || focalPoint.Top != 0.5m); + + /// + /// Determines whether the value has crops. + /// + public bool HasCrops() + => Crops is IEnumerable crops && crops.Any(); /// /// Determines whether the value has a specified crop. /// public bool HasCrop(string alias) - => Crops != null && Crops.Any(x => x.Alias == alias); + => Crops is IEnumerable crops && crops.Any(x => x.Alias == alias); /// /// Determines whether the value has a source image. @@ -179,6 +182,51 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters }; } + /// + /// Removes redundant crop data/default focal point. + /// + /// The image cropper value. + /// + /// The cleaned up value. + /// + public static void Prune(JObject value) + { + if (value is null) throw new ArgumentNullException(nameof(value)); + + if (value.TryGetValue("crops", out var crops)) + { + if (crops.HasValues) + { + foreach (var crop in crops.Values().ToList()) + { + if (crop.TryGetValue("coordinates", out var coordinates) == false || coordinates.HasValues == false) + { + // Remove crop without coordinates + crop.Remove(); + continue; + } + + // Width/height are already stored in the crop configuration + crop.Remove("width"); + crop.Remove("height"); + } + } + + if (crops.HasValues == false) + { + // Remove empty crops + value.Remove("crops"); + } + } + + if (value.TryGetValue("focalPoint", out var focalPoint) && + (focalPoint.HasValues == false || (focalPoint.Value("top") == 0.5m && focalPoint.Value("left") == 0.5m))) + { + // Remove empty/default focal point + value.Remove("focalPoint"); + } + } + #region IEquatable /// @@ -212,8 +260,8 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters // properties are, practically, readonly // ReSharper disable NonReadonlyMemberInGetHashCode var hashCode = Src?.GetHashCode() ?? 0; - hashCode = (hashCode*397) ^ (FocalPoint?.GetHashCode() ?? 0); - hashCode = (hashCode*397) ^ (Crops?.GetHashCode() ?? 0); + hashCode = (hashCode * 397) ^ (FocalPoint?.GetHashCode() ?? 0); + hashCode = (hashCode * 397) ^ (Crops?.GetHashCode() ?? 0); return hashCode; // ReSharper restore NonReadonlyMemberInGetHashCode } @@ -258,7 +306,7 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters { // properties are, practically, readonly // ReSharper disable NonReadonlyMemberInGetHashCode - return (Left.GetHashCode()*397) ^ Top.GetHashCode(); + return (Left.GetHashCode() * 397) ^ Top.GetHashCode(); // ReSharper restore NonReadonlyMemberInGetHashCode } } @@ -312,9 +360,9 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters // properties are, practically, readonly // ReSharper disable NonReadonlyMemberInGetHashCode var hashCode = Alias?.GetHashCode() ?? 0; - hashCode = (hashCode*397) ^ Width; - hashCode = (hashCode*397) ^ Height; - hashCode = (hashCode*397) ^ (Coordinates?.GetHashCode() ?? 0); + hashCode = (hashCode * 397) ^ Width; + hashCode = (hashCode * 397) ^ Height; + hashCode = (hashCode * 397) ^ (Coordinates?.GetHashCode() ?? 0); return hashCode; // ReSharper restore NonReadonlyMemberInGetHashCode } @@ -339,7 +387,7 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters public decimal Y2 { get; set; } #region IEquatable - + /// public bool Equals(ImageCropperCropCoordinates other) => ReferenceEquals(this, other) || Equals(this, other); @@ -369,9 +417,9 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters // properties are, practically, readonly // ReSharper disable NonReadonlyMemberInGetHashCode var hashCode = X1.GetHashCode(); - hashCode = (hashCode*397) ^ Y1.GetHashCode(); - hashCode = (hashCode*397) ^ X2.GetHashCode(); - hashCode = (hashCode*397) ^ Y2.GetHashCode(); + hashCode = (hashCode * 397) ^ Y1.GetHashCode(); + hashCode = (hashCode * 397) ^ X2.GetHashCode(); + hashCode = (hashCode * 397) ^ Y2.GetHashCode(); return hashCode; // ReSharper restore NonReadonlyMemberInGetHashCode } diff --git a/src/Umbraco.Tests/PropertyEditors/ImageCropperTest.cs b/src/Umbraco.Tests/PropertyEditors/ImageCropperTest.cs index c40708770e..eed45b0d27 100644 --- a/src/Umbraco.Tests/PropertyEditors/ImageCropperTest.cs +++ b/src/Umbraco.Tests/PropertyEditors/ImageCropperTest.cs @@ -29,13 +29,13 @@ namespace Umbraco.Tests.PropertyEditors { private const string CropperJson1 = "{\"focalPoint\": {\"left\": 0.96,\"top\": 0.80827067669172936},\"src\": \"/media/1005/img_0671.jpg\",\"crops\": [{\"alias\":\"thumb\",\"width\": 100,\"height\": 100,\"coordinates\": {\"x1\": 0.58729977382575338,\"y1\": 0.055768992440203169,\"x2\": 0,\"y2\": 0.32457553600198386}}]}"; private const string CropperJson2 = "{\"focalPoint\": {\"left\": 0.98,\"top\": 0.80827067669172936},\"src\": \"/media/1005/img_0672.jpg\",\"crops\": [{\"alias\":\"thumb\",\"width\": 100,\"height\": 100,\"coordinates\": {\"x1\": 0.58729977382575338,\"y1\": 0.055768992440203169,\"x2\": 0,\"y2\": 0.32457553600198386}}]}"; - private const string CropperJson3 = "{\"focalPoint\": {\"left\": 0.98,\"top\": 0.80827067669172936},\"src\": \"/media/1005/img_0672.jpg\",\"crops\": []}"; + private const string CropperJson3 = "{\"focalPoint\": {\"left\": 0.5,\"top\": 0.5},\"src\": \"/media/1005/img_0672.jpg\",\"crops\": []}"; private const string MediaPath = "/media/1005/img_0671.jpg"; [Test] public void CanConvertImageCropperDataSetSrcToString() { - //cropperJson3 - has not crops + //cropperJson3 - has no crops var cropperValue = CropperJson3.DeserializeImageCropperValue(); var serialized = cropperValue.TryConvertTo(); Assert.IsTrue(serialized.Success); @@ -45,7 +45,7 @@ namespace Umbraco.Tests.PropertyEditors [Test] public void CanConvertImageCropperDataSetJObject() { - //cropperJson3 - has not crops + //cropperJson3 - has no crops var cropperValue = CropperJson3.DeserializeImageCropperValue(); var serialized = cropperValue.TryConvertTo(); Assert.IsTrue(serialized.Success); diff --git a/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyValueEditor.cs b/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyValueEditor.cs index 4aac8f54aa..8e13d1bb5a 100644 --- a/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyValueEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyValueEditor.cs @@ -1,6 +1,6 @@ -using Newtonsoft.Json.Linq; -using System; +using System; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Core; using Umbraco.Core.IO; using Umbraco.Core.Logging; @@ -66,31 +66,42 @@ namespace Umbraco.Web.PropertyEditors /// public override object FromEditor(ContentPropertyData editorValue, object currentValue) { - // get the current path + // Get the current path var currentPath = string.Empty; try { var svalue = currentValue as string; var currentJson = string.IsNullOrWhiteSpace(svalue) ? null : JObject.Parse(svalue); - if (currentJson != null && currentJson["src"] != null) - currentPath = currentJson["src"].Value(); + if (currentJson != null && currentJson.TryGetValue("src", out var src)) + { + currentPath = src.Value(); + } } catch (Exception ex) { - // for some reason the value is invalid so continue as if there was no value there + // For some reason the value is invalid, so continue as if there was no value there _logger.Warn(ex, "Could not parse current db value to a JObject."); } + if (string.IsNullOrWhiteSpace(currentPath) == false) currentPath = _mediaFileSystem.GetRelativePath(currentPath); - // get the new json and path - JObject editorJson = null; + // Get the new JSON and file path var editorFile = string.Empty; - if (editorValue.Value != null) + if (editorValue.Value is JObject editorJson) { - editorJson = editorValue.Value as JObject; - if (editorJson != null && editorJson["src"] != null) + // Populate current file + if (editorJson["src"] != null) + { editorFile = editorJson["src"].Value(); + } + + // Clean up redundant/default data + ImageCropperValue.Prune(editorJson); + } + else + { + editorJson = null; } // ensure we have the required guids @@ -118,7 +129,7 @@ namespace Umbraco.Web.PropertyEditors return null; // clear } - return editorJson?.ToString(); // unchanged + return editorJson?.ToString(Formatting.None); // unchanged } // process the file @@ -135,7 +146,8 @@ namespace Umbraco.Web.PropertyEditors // update json and return if (editorJson == null) return null; editorJson["src"] = filepath == null ? string.Empty : _mediaFileSystem.GetUrl(filepath); - return editorJson.ToString(); + + return editorJson.ToString(Formatting.None); } private string ProcessFile(ContentPropertyData editorValue, ContentPropertyFile file, string currentPath, Guid cuid, Guid puid) @@ -160,7 +172,6 @@ namespace Umbraco.Web.PropertyEditors return filepath; } - public override string ConvertDbToString(PropertyType propertyType, object value, IDataTypeService dataTypeService) { if (value == null || string.IsNullOrEmpty(value.ToString())) diff --git a/src/Umbraco.Web/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MediaPicker3PropertyEditor.cs index 43d190e173..5f1b319e01 100644 --- a/src/Umbraco.Web/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -1,8 +1,9 @@ -using Newtonsoft.Json; -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Core; using Umbraco.Core.Logging; using Umbraco.Core.Models; @@ -50,7 +51,36 @@ namespace Umbraco.Web.PropertyEditors { var value = property.GetValue(culture, segment); - return Deserialize(value); + var dtos = Deserialize(value).ToList(); + + var dataType = dataTypeService.GetDataType(property.PropertyType.DataTypeId); + if (dataType?.Configuration != null) + { + var configuration = dataType.ConfigurationAs(); + + foreach (var dto in dtos) + { + dto.ApplyConfiguration(configuration); + } + } + + return dtos; + } + + public override object FromEditor(ContentPropertyData editorValue, object currentValue) + { + if (editorValue.Value is JArray dtos) + { + // Clean up redundant/default data + foreach (var dto in dtos.Values()) + { + MediaWithCropsDto.Prune(dto); + } + + return dtos.ToString(Formatting.None); + } + + return base.FromEditor(editorValue, currentValue); } public IEnumerable GetReferences(object value) @@ -117,6 +147,51 @@ namespace Umbraco.Web.PropertyEditors [DataMember(Name = "focalPoint")] public ImageCropperValue.ImageCropperFocalPoint FocalPoint { get; set; } + + /// + /// Applies the configuration to ensure only valid crops are kept and have the correct width/height. + /// + /// The configuration. + public void ApplyConfiguration(MediaPicker3Configuration configuration) + { + var crops = new List(); + + var configuredCrops = configuration?.Crops; + if (configuredCrops != null) + { + foreach (var configuredCrop in configuredCrops) + { + var crop = Crops?.FirstOrDefault(x => x.Alias == configuredCrop.Alias); + + crops.Add(new ImageCropperValue.ImageCropperCrop + { + Alias = configuredCrop.Alias, + Width = configuredCrop.Width, + Height = configuredCrop.Height, + Coordinates = crop?.Coordinates + }); + } + } + + Crops = crops; + + if (configuration?.EnableLocalFocalPoint == false) + { + FocalPoint = null; + } + } + + /// + /// Removes redundant crop data/default focal point. + /// + /// The media with crops DTO. + /// + /// The cleaned up value. + /// + /// + /// Because the DTO uses the same JSON keys as the image cropper value for crops and focal point, we can re-use the prune method. + /// + public static void Prune(JObject value) => ImageCropperValue.Prune(value); } } } From dfc3e56eb79965117c4ebfb489d9e71779878b39 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Wed, 5 Jan 2022 13:16:07 +0100 Subject: [PATCH 32/53] Check if we're in debug and set IncludeErrorPolicy accordingly --- src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs b/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs index a62df6b5e4..3df6ffb47b 100644 --- a/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs +++ b/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs @@ -1,4 +1,5 @@ -using System.Web.Http; +using System.Web; +using System.Web.Http; using System.Web.Http.Controllers; using System.Web.Http.Filters; @@ -11,7 +12,7 @@ namespace Umbraco.Web.WebApi { public override void OnActionExecuting(HttpActionContext actionContext) { - actionContext.ControllerContext.Configuration.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Always; + actionContext.ControllerContext.Configuration.IncludeErrorDetailPolicy = HttpContext.Current.IsDebuggingEnabled ? IncludeErrorDetailPolicy.Always : IncludeErrorDetailPolicy.Default; } } } From 2155062678e5366d26330ab20262dd11f6ab02af Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Mon, 10 Jan 2022 09:32:29 +0100 Subject: [PATCH 33/53] Prune/remove indentation from JSON property values (#11806) * Use Formatting.None for JSON property data in default value editor * Use Formatting.None for JSON property data in custom value editors * Use Formatting.None for JSON property data in Nested Content and Block List * Use Formatting.None for JSON property tags * Use Formatting.None for JSON configuration data * Use Formatting.None in custom JSON converter * Ensure empty tags and complex editor values are not stored * Fix NestedContentPropertyComponentTests * Do not store empty property data * Use Formatting.None and don't store configured crops (without coordinates) * Fix JSON deserialization of tags value --- .../Models/PropertyTagsExtensions.cs | 19 +- ...omplexPropertyEditorContentEventHandler.cs | 8 +- .../PropertyEditors/ConfigurationEditor.cs | 2 + .../PropertyEditors/DataValueEditor.cs | 16 +- .../Serialization/JsonToStringConverter.cs | 3 +- .../BlockEditorComponentTests.cs | 3 +- .../NestedContentPropertyComponentTests.cs | 417 +++++++++--------- .../Compose/BlockEditorComponent.cs | 2 +- .../Compose/NestedContentPropertyComponent.cs | 10 +- .../BlockEditorPropertyEditor.cs | 2 +- .../ColorPickerConfigurationEditor.cs | 2 +- .../PropertyEditors/GridPropertyEditor.cs | 2 +- .../ImageCropperPropertyEditor.cs | 23 +- .../ImageCropperPropertyValueEditor.cs | 4 + .../MultiUrlPickerValueEditor.cs | 14 +- .../MultipleTextStringPropertyEditor.cs | 2 +- .../PropertyEditors/MultipleValueEditor.cs | 8 +- .../NestedContentPropertyEditor.cs | 9 +- .../PropertyEditors/RichTextPropertyEditor.cs | 2 +- .../PropertyEditors/TagsPropertyEditor.cs | 2 +- 20 files changed, 281 insertions(+), 269 deletions(-) diff --git a/src/Umbraco.Core/Models/PropertyTagsExtensions.cs b/src/Umbraco.Core/Models/PropertyTagsExtensions.cs index 63cf870221..c97bf4c66a 100644 --- a/src/Umbraco.Core/Models/PropertyTagsExtensions.cs +++ b/src/Umbraco.Core/Models/PropertyTagsExtensions.cs @@ -68,11 +68,13 @@ namespace Umbraco.Core.Models switch (storageType) { case TagsStorageType.Csv: - property.SetValue(string.Join(delimiter.ToString(), currentTags.Union(trimmedTags)), culture); // csv string + property.SetValue(string.Join(delimiter.ToString(), currentTags.Union(trimmedTags)).NullOrWhiteSpaceAsNull(), culture); // csv string break; case TagsStorageType.Json: - property.SetValue(JsonConvert.SerializeObject(currentTags.Union(trimmedTags).ToArray()), culture); // json array + var updatedTags = currentTags.Union(trimmedTags).ToArray(); + var updatedValue = updatedTags.Length == 0 ? null : JsonConvert.SerializeObject(updatedTags, Formatting.None); + property.SetValue(updatedValue, culture); // json array break; } } @@ -81,11 +83,12 @@ namespace Umbraco.Core.Models switch (storageType) { case TagsStorageType.Csv: - property.SetValue(string.Join(delimiter.ToString(), trimmedTags), culture); // csv string + property.SetValue(string.Join(delimiter.ToString(), trimmedTags).NullOrWhiteSpaceAsNull(), culture); // csv string break; case TagsStorageType.Json: - property.SetValue(JsonConvert.SerializeObject(trimmedTags), culture); // json array + var updatedValue = trimmedTags.Length == 0 ? null : JsonConvert.SerializeObject(trimmedTags, Formatting.None); + property.SetValue(updatedValue, culture); // json array break; } } @@ -121,11 +124,13 @@ namespace Umbraco.Core.Models switch (storageType) { case TagsStorageType.Csv: - property.SetValue(string.Join(delimiter.ToString(), currentTags.Except(trimmedTags)), culture); // csv string + property.SetValue(string.Join(delimiter.ToString(), currentTags.Except(trimmedTags)).NullOrWhiteSpaceAsNull(), culture); // csv string break; case TagsStorageType.Json: - property.SetValue(JsonConvert.SerializeObject(currentTags.Except(trimmedTags).ToArray()), culture); // json array + var updatedTags = currentTags.Except(trimmedTags).ToArray(); + var updatedValue = updatedTags.Length == 0 ? null : JsonConvert.SerializeObject(updatedTags, Formatting.None); + property.SetValue(updatedValue, culture); // json array break; } } @@ -157,7 +162,7 @@ namespace Umbraco.Core.Models case TagsStorageType.Json: try { - return JsonConvert.DeserializeObject(value).Select(x => x.ToString().Trim()); + return JsonConvert.DeserializeObject(value).Select(x => x.Trim()); } catch (JsonException) { diff --git a/src/Umbraco.Core/PropertyEditors/ComplexPropertyEditorContentEventHandler.cs b/src/Umbraco.Core/PropertyEditors/ComplexPropertyEditorContentEventHandler.cs index 2b819d4555..f0876acb9b 100644 --- a/src/Umbraco.Core/PropertyEditors/ComplexPropertyEditorContentEventHandler.cs +++ b/src/Umbraco.Core/PropertyEditors/ComplexPropertyEditorContentEventHandler.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Core.Events; using Umbraco.Core.Models; using Umbraco.Core.Services; @@ -59,11 +61,13 @@ namespace Umbraco.Core.PropertyEditors foreach (var cultureVal in propVals) { // Remove keys from published value & any nested properties - var updatedPublishedVal = _formatPropertyValue(cultureVal.PublishedValue?.ToString(), onlyMissingKeys); + var publishedValue = cultureVal.PublishedValue is JToken jsonPublishedValue ? jsonPublishedValue.ToString(Formatting.None) : cultureVal.PublishedValue?.ToString(); + var updatedPublishedVal = _formatPropertyValue(publishedValue, onlyMissingKeys).NullOrWhiteSpaceAsNull(); cultureVal.PublishedValue = updatedPublishedVal; // Remove keys from edited/draft value & any nested properties - var updatedEditedVal = _formatPropertyValue(cultureVal.EditedValue?.ToString(), onlyMissingKeys); + var editedValue = cultureVal.EditedValue is JToken jsonEditedValue ? jsonEditedValue.ToString(Formatting.None) : cultureVal.EditedValue?.ToString(); + var updatedEditedVal = _formatPropertyValue(editedValue, onlyMissingKeys).NullOrWhiteSpaceAsNull(); cultureVal.EditedValue = updatedEditedVal; } } diff --git a/src/Umbraco.Core/PropertyEditors/ConfigurationEditor.cs b/src/Umbraco.Core/PropertyEditors/ConfigurationEditor.cs index 8151753a43..82a23847a3 100644 --- a/src/Umbraco.Core/PropertyEditors/ConfigurationEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/ConfigurationEditor.cs @@ -126,6 +126,8 @@ namespace Umbraco.Core.PropertyEditors /// public static JsonSerializerSettings ConfigurationJsonSettings { get; } = new JsonSerializerSettings { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore, ContractResolver = new ConfigurationCustomContractResolver(), Converters = new List(new[]{new FuzzyBooleanConverter()}) }; diff --git a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs index fbcd5ec440..2484e8f830 100644 --- a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs @@ -145,8 +145,18 @@ namespace Umbraco.Core.PropertyEditors /// internal Attempt TryConvertValueToCrlType(object value) { - if (value is JValue) - value = value.ToString(); + if (value is JToken jsonValue) + { + if (jsonValue is JContainer && jsonValue.HasValues == false) + { + // Empty JSON array/object + value = null; + } + else + { + value = jsonValue.ToString(Formatting.None); + } + } //this is a custom check to avoid any errors, if it's a string and it's empty just make it null if (value is string s && string.IsNullOrWhiteSpace(s)) @@ -187,6 +197,7 @@ namespace Umbraco.Core.PropertyEditors default: throw new ArgumentOutOfRangeException(); } + return value.TryConvertTo(valueType); } @@ -222,6 +233,7 @@ namespace Umbraco.Core.PropertyEditors Current.Logger.Warn("The value {EditorValue} cannot be converted to the type {StorageTypeValue}", editorValue.Value, ValueTypes.ToStorageType(ValueType)); return null; } + return result.Result; } diff --git a/src/Umbraco.Core/Serialization/JsonToStringConverter.cs b/src/Umbraco.Core/Serialization/JsonToStringConverter.cs index 08c9a44d00..26d73b60ef 100644 --- a/src/Umbraco.Core/Serialization/JsonToStringConverter.cs +++ b/src/Umbraco.Core/Serialization/JsonToStringConverter.cs @@ -20,9 +20,10 @@ namespace Umbraco.Core.Serialization { return reader.Value; } + // Load JObject from stream JObject jObject = JObject.Load(reader); - return jObject.ToString(); + return jObject.ToString(Formatting.None); } public override bool CanConvert(Type objectType) diff --git a/src/Umbraco.Tests/PropertyEditors/BlockEditorComponentTests.cs b/src/Umbraco.Tests/PropertyEditors/BlockEditorComponentTests.cs index bfd8b8c77b..3fce47b718 100644 --- a/src/Umbraco.Tests/PropertyEditors/BlockEditorComponentTests.cs +++ b/src/Umbraco.Tests/PropertyEditors/BlockEditorComponentTests.cs @@ -14,8 +14,7 @@ namespace Umbraco.Tests.PropertyEditors private readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings { Formatting = Formatting.None, - NullValueHandling = NullValueHandling.Ignore, - + NullValueHandling = NullValueHandling.Ignore }; private const string _contentGuid1 = "036ce82586a64dfba2d523a99ed80f58"; diff --git a/src/Umbraco.Tests/PropertyEditors/NestedContentPropertyComponentTests.cs b/src/Umbraco.Tests/PropertyEditors/NestedContentPropertyComponentTests.cs index 5b7e220123..75c4403e2b 100644 --- a/src/Umbraco.Tests/PropertyEditors/NestedContentPropertyComponentTests.cs +++ b/src/Umbraco.Tests/PropertyEditors/NestedContentPropertyComponentTests.cs @@ -1,10 +1,7 @@ -using Newtonsoft.Json; +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Umbraco.Web.Compose; namespace Umbraco.Tests.PropertyEditors @@ -13,6 +10,11 @@ namespace Umbraco.Tests.PropertyEditors [TestFixture] public class NestedContentPropertyComponentTests { + private static void AreEqualJson(string expected, string actual) + { + Assert.AreEqual(JToken.Parse(expected), JToken.Parse(actual)); + } + [Test] public void Invalid_Json() { @@ -29,17 +31,18 @@ namespace Umbraco.Tests.PropertyEditors Func guidFactory = () => guids[guidCounter++]; var json = @"[ - {""key"":""04a6dba8-813c-4144-8aca-86a3f24ebf08"",""name"":""Item 1"",""ncContentTypeAlias"":""nested"",""text"":""woot""}, - {""key"":""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"",""name"":""Item 2"",""ncContentTypeAlias"":""nested"",""text"":""zoot""} -]"; + {""key"":""04a6dba8-813c-4144-8aca-86a3f24ebf08"",""name"":""Item 1"",""ncContentTypeAlias"":""nested"",""text"":""woot""}, + {""key"":""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"",""name"":""Item 2"",""ncContentTypeAlias"":""nested"",""text"":""zoot""} + ]"; + var expected = json .Replace("04a6dba8-813c-4144-8aca-86a3f24ebf08", guids[0].ToString()) .Replace("d8e214d8-c5a5-4b45-9b51-4050dd47f5fa", guids[1].ToString()); var component = new NestedContentPropertyComponent(); - var result = component.CreateNestedContentKeys(json, false, guidFactory); + var actual = component.CreateNestedContentKeys(json, false, guidFactory); - Assert.AreEqual(JsonConvert.DeserializeObject(expected).ToString(), JsonConvert.DeserializeObject(result).ToString()); + AreEqualJson(expected, actual); } [Test] @@ -50,29 +53,27 @@ namespace Umbraco.Tests.PropertyEditors Func guidFactory = () => guids[guidCounter++]; var json = @"[{ - ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", - ""name"": ""Item 1"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""woot"" - }, { - ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", - ""name"": ""Item 2"", - ""ncContentTypeAlias"": ""list"", - ""text"": ""zoot"", - ""subItems"": [{ - ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", - ""name"": ""Item 1"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""woot"" - }, { - ""key"": ""fbde4288-8382-4e13-8933-ed9c160de050"", - ""name"": ""Item 2"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""zoot"" - } - ] - } -]"; + ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", + ""name"": ""Item 1"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""woot"" + }, { + ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", + ""name"": ""Item 2"", + ""ncContentTypeAlias"": ""list"", + ""text"": ""zoot"", + ""subItems"": [{ + ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", + ""name"": ""Item 1"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""woot"" + }, { + ""key"": ""fbde4288-8382-4e13-8933-ed9c160de050"", + ""name"": ""Item 2"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""zoot"" + }] + }]"; var expected = json .Replace("04a6dba8-813c-4144-8aca-86a3f24ebf08", guids[0].ToString()) @@ -81,9 +82,9 @@ namespace Umbraco.Tests.PropertyEditors .Replace("fbde4288-8382-4e13-8933-ed9c160de050", guids[3].ToString()); var component = new NestedContentPropertyComponent(); - var result = component.CreateNestedContentKeys(json, false, guidFactory); + var actual = component.CreateNestedContentKeys(json, false, guidFactory); - Assert.AreEqual(JsonConvert.DeserializeObject(expected).ToString(), JsonConvert.DeserializeObject(result).ToString()); + AreEqualJson(expected, actual); } [Test] @@ -95,7 +96,8 @@ namespace Umbraco.Tests.PropertyEditors // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing // and this is how to do that, the result will also include quotes around it. - var subJsonEscaped = JsonConvert.ToString(JsonConvert.DeserializeObject(@"[{ + var subJsonEscaped = JsonConvert.ToString(JToken.Parse(@" + [{ ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", ""name"": ""Item 1"", ""ncContentTypeAlias"": ""text"", @@ -105,22 +107,20 @@ namespace Umbraco.Tests.PropertyEditors ""name"": ""Item 2"", ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" - } - ]").ToString()); + }]").ToString(Formatting.None)); var json = @"[{ - ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", - ""name"": ""Item 1"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""woot"" - }, { - ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", - ""name"": ""Item 2"", - ""ncContentTypeAlias"": ""list"", - ""text"": ""zoot"", - ""subItems"":" + subJsonEscaped + @" - } -]"; + ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", + ""name"": ""Item 1"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""woot"" + }, { + ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", + ""name"": ""Item 2"", + ""ncContentTypeAlias"": ""list"", + ""text"": ""zoot"", + ""subItems"":" + subJsonEscaped + @" + }]"; var expected = json .Replace("04a6dba8-813c-4144-8aca-86a3f24ebf08", guids[0].ToString()) @@ -129,9 +129,9 @@ namespace Umbraco.Tests.PropertyEditors .Replace("fbde4288-8382-4e13-8933-ed9c160de050", guids[3].ToString()); var component = new NestedContentPropertyComponent(); - var result = component.CreateNestedContentKeys(json, false, guidFactory); + var actual = component.CreateNestedContentKeys(json, false, guidFactory); - Assert.AreEqual(JsonConvert.DeserializeObject(expected).ToString(), JsonConvert.DeserializeObject(result).ToString()); + AreEqualJson(expected, actual); } [Test] @@ -143,7 +143,7 @@ namespace Umbraco.Tests.PropertyEditors // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing // and this is how to do that, the result will also include quotes around it. - var subJsonEscaped = JsonConvert.ToString(JsonConvert.DeserializeObject(@"[{ + var subJsonEscaped = JsonConvert.ToString(JToken.Parse(@"[{ ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", ""name"": ""Item 1"", ""ncContentTypeAlias"": ""text"", @@ -153,79 +153,74 @@ namespace Umbraco.Tests.PropertyEditors ""name"": ""Item 2"", ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" - } - ]").ToString()); + }]").ToString(Formatting.None)); // Complex editor such as the grid var complexEditorJsonEscaped = @"{ - ""name"": ""1 column layout"", - ""sections"": [ - { - ""grid"": ""12"", - ""rows"": [ - { - ""name"": ""Article"", - ""id"": ""b4f6f651-0de3-ef46-e66a-464f4aaa9c57"", - ""areas"": [ - { - ""grid"": ""4"", - ""controls"": [ + ""name"": ""1 column layout"", + ""sections"": [ { - ""value"": ""I am quote"", - ""editor"": { - ""alias"": ""quote"", - ""view"": ""textstring"" - }, - ""styles"": null, - ""config"": null - }], - ""styles"": null, - ""config"": null - }, - { - ""grid"": ""8"", - ""controls"": [ - { - ""value"": ""Header"", - ""editor"": { - ""alias"": ""headline"", - ""view"": ""textstring"" - }, - ""styles"": null, - ""config"": null - }, - { - ""value"": " + subJsonEscaped + @", - ""editor"": { - ""alias"": ""madeUpNestedContent"", - ""view"": ""madeUpNestedContentInGrid"" - }, - ""styles"": null, - ""config"": null - }], - ""styles"": null, - ""config"": null - }], - ""styles"": null, - ""config"": null - }] - }] -}"; - + ""grid"": ""12"", + ""rows"": [ + { + ""name"": ""Article"", + ""id"": ""b4f6f651-0de3-ef46-e66a-464f4aaa9c57"", + ""areas"": [ + { + ""grid"": ""4"", + ""controls"": [{ + ""value"": ""I am quote"", + ""editor"": { + ""alias"": ""quote"", + ""view"": ""textstring"" + }, + ""styles"": null, + ""config"": null + }], + ""styles"": null, + ""config"": null + }, + { + ""grid"": ""8"", + ""controls"": [{ + ""value"": ""Header"", + ""editor"": { + ""alias"": ""headline"", + ""view"": ""textstring"" + }, + ""styles"": null, + ""config"": null + }, + { + ""value"": " + subJsonEscaped + @", + ""editor"": { + ""alias"": ""madeUpNestedContent"", + ""view"": ""madeUpNestedContentInGrid"" + }, + ""styles"": null, + ""config"": null + }], + ""styles"": null, + ""config"": null + }], + ""styles"": null, + ""config"": null + }] + }] + }"; var json = @"[{ - ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", - ""name"": ""Item 1"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""woot"" - }, { - ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", - ""name"": ""Item 2"", - ""ncContentTypeAlias"": ""list"", - ""text"": ""zoot"", - ""subItems"":" + complexEditorJsonEscaped + @" - } -]"; + ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", + ""name"": ""Item 1"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""woot"" + }, { + ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", + ""name"": ""Item 2"", + ""ncContentTypeAlias"": ""list"", + ""text"": ""zoot"", + ""subItems"":" + complexEditorJsonEscaped + @" + }]"; var expected = json .Replace("04a6dba8-813c-4144-8aca-86a3f24ebf08", guids[0].ToString()) @@ -234,12 +229,11 @@ namespace Umbraco.Tests.PropertyEditors .Replace("fbde4288-8382-4e13-8933-ed9c160de050", guids[3].ToString()); var component = new NestedContentPropertyComponent(); - var result = component.CreateNestedContentKeys(json, false, guidFactory); + var actual = component.CreateNestedContentKeys(json, false, guidFactory); - Assert.AreEqual(JsonConvert.DeserializeObject(expected).ToString(), JsonConvert.DeserializeObject(result).ToString()); + AreEqualJson(expected, actual); } - [Test] public void No_Nesting_Generates_Keys_For_Missing_Items() { @@ -248,18 +242,18 @@ namespace Umbraco.Tests.PropertyEditors Func guidFactory = () => guids[guidCounter++]; var json = @"[ - {""key"":""04a6dba8-813c-4144-8aca-86a3f24ebf08"",""name"":""Item 1 my key wont change"",""ncContentTypeAlias"":""nested"",""text"":""woot""}, - {""name"":""Item 2 was copied and has no key prop"",""ncContentTypeAlias"":""nested"",""text"":""zoot""} -]"; + {""key"":""04a6dba8-813c-4144-8aca-86a3f24ebf08"",""name"":""Item 1 my key wont change"",""ncContentTypeAlias"":""nested"",""text"":""woot""}, + {""name"":""Item 2 was copied and has no key prop"",""ncContentTypeAlias"":""nested"",""text"":""zoot""} + ]"; var component = new NestedContentPropertyComponent(); var result = component.CreateNestedContentKeys(json, true, guidFactory); // Ensure the new GUID is put in a key into the JSON - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[0].ToString())); + Assert.IsTrue(result.Contains(guids[0].ToString())); // Ensure that the original key is NOT changed/modified & still exists - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains("04a6dba8-813c-4144-8aca-86a3f24ebf08")); + Assert.IsTrue(result.Contains("04a6dba8-813c-4144-8aca-86a3f24ebf08")); } [Test] @@ -271,7 +265,7 @@ namespace Umbraco.Tests.PropertyEditors // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing // and this is how to do that, the result will also include quotes around it. - var subJsonEscaped = JsonConvert.ToString(JsonConvert.DeserializeObject(@"[{ + var subJsonEscaped = JsonConvert.ToString(JToken.Parse(@"[{ ""name"": ""Item 1"", ""ncContentTypeAlias"": ""text"", ""text"": ""woot"" @@ -279,29 +273,27 @@ namespace Umbraco.Tests.PropertyEditors ""name"": ""Nested Item 2 was copied and has no key"", ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" - } - ]").ToString()); + }]").ToString(Formatting.None)); var json = @"[{ - ""name"": ""Item 1 was copied and has no key"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""woot"" - }, { - ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", - ""name"": ""Item 2"", - ""ncContentTypeAlias"": ""list"", - ""text"": ""zoot"", - ""subItems"":" + subJsonEscaped + @" - } -]"; + ""name"": ""Item 1 was copied and has no key"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""woot"" + }, { + ""key"": ""d8e214d8-c5a5-4b45-9b51-4050dd47f5fa"", + ""name"": ""Item 2"", + ""ncContentTypeAlias"": ""list"", + ""text"": ""zoot"", + ""subItems"":" + subJsonEscaped + @" + }]"; var component = new NestedContentPropertyComponent(); var result = component.CreateNestedContentKeys(json, true, guidFactory); // Ensure the new GUID is put in a key into the JSON for each item - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[0].ToString())); - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[1].ToString())); - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[2].ToString())); + Assert.IsTrue(result.Contains(guids[0].ToString())); + Assert.IsTrue(result.Contains(guids[1].ToString())); + Assert.IsTrue(result.Contains(guids[2].ToString())); } [Test] @@ -313,7 +305,7 @@ namespace Umbraco.Tests.PropertyEditors // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing // and this is how to do that, the result will also include quotes around it. - var subJsonEscaped = JsonConvert.ToString(JsonConvert.DeserializeObject(@"[{ + var subJsonEscaped = JsonConvert.ToString(JToken.Parse(@"[{ ""key"": ""dccf550c-3a05-469e-95e1-a8f560f788c2"", ""name"": ""Item 1"", ""ncContentTypeAlias"": ""text"", @@ -322,85 +314,80 @@ namespace Umbraco.Tests.PropertyEditors ""name"": ""Nested Item 2 was copied and has no key"", ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" - } - ]").ToString()); + }]").ToString(Formatting.None)); // Complex editor such as the grid var complexEditorJsonEscaped = @"{ - ""name"": ""1 column layout"", - ""sections"": [ - { - ""grid"": ""12"", - ""rows"": [ - { - ""name"": ""Article"", - ""id"": ""b4f6f651-0de3-ef46-e66a-464f4aaa9c57"", - ""areas"": [ - { - ""grid"": ""4"", - ""controls"": [ + ""name"": ""1 column layout"", + ""sections"": [ { - ""value"": ""I am quote"", - ""editor"": { - ""alias"": ""quote"", - ""view"": ""textstring"" - }, - ""styles"": null, - ""config"": null - }], - ""styles"": null, - ""config"": null - }, - { - ""grid"": ""8"", - ""controls"": [ - { - ""value"": ""Header"", - ""editor"": { - ""alias"": ""headline"", - ""view"": ""textstring"" - }, - ""styles"": null, - ""config"": null - }, - { - ""value"": " + subJsonEscaped + @", - ""editor"": { - ""alias"": ""madeUpNestedContent"", - ""view"": ""madeUpNestedContentInGrid"" - }, - ""styles"": null, - ""config"": null - }], - ""styles"": null, - ""config"": null - }], - ""styles"": null, - ""config"": null - }] - }] -}"; - + ""grid"": ""12"", + ""rows"": [ + { + ""name"": ""Article"", + ""id"": ""b4f6f651-0de3-ef46-e66a-464f4aaa9c57"", + ""areas"": [ + { + ""grid"": ""4"", + ""controls"": [{ + ""value"": ""I am quote"", + ""editor"": { + ""alias"": ""quote"", + ""view"": ""textstring"" + }, + ""styles"": null, + ""config"": null + }], + ""styles"": null, + ""config"": null + }, + { + ""grid"": ""8"", + ""controls"": [{ + ""value"": ""Header"", + ""editor"": { + ""alias"": ""headline"", + ""view"": ""textstring"" + }, + ""styles"": null, + ""config"": null + }, + { + ""value"": " + subJsonEscaped + @", + ""editor"": { + ""alias"": ""madeUpNestedContent"", + ""view"": ""madeUpNestedContentInGrid"" + }, + ""styles"": null, + ""config"": null + }], + ""styles"": null, + ""config"": null + }], + ""styles"": null, + ""config"": null + }] + }] + }"; var json = @"[{ - ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", - ""name"": ""Item 1"", - ""ncContentTypeAlias"": ""text"", - ""text"": ""woot"" - }, { - ""name"": ""Item 2 was copied and has no key"", - ""ncContentTypeAlias"": ""list"", - ""text"": ""zoot"", - ""subItems"":" + complexEditorJsonEscaped + @" - } -]"; + ""key"": ""04a6dba8-813c-4144-8aca-86a3f24ebf08"", + ""name"": ""Item 1"", + ""ncContentTypeAlias"": ""text"", + ""text"": ""woot"" + }, { + ""name"": ""Item 2 was copied and has no key"", + ""ncContentTypeAlias"": ""list"", + ""text"": ""zoot"", + ""subItems"":" + complexEditorJsonEscaped + @" + }]"; var component = new NestedContentPropertyComponent(); var result = component.CreateNestedContentKeys(json, true, guidFactory); // Ensure the new GUID is put in a key into the JSON for each item - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[0].ToString())); - Assert.IsTrue(JsonConvert.DeserializeObject(result).ToString().Contains(guids[1].ToString())); + Assert.IsTrue(result.Contains(guids[0].ToString())); + Assert.IsTrue(result.Contains(guids[1].ToString())); } } } diff --git a/src/Umbraco.Web/Compose/BlockEditorComponent.cs b/src/Umbraco.Web/Compose/BlockEditorComponent.cs index ac92aa6918..a2dd772cf7 100644 --- a/src/Umbraco.Web/Compose/BlockEditorComponent.cs +++ b/src/Umbraco.Web/Compose/BlockEditorComponent.cs @@ -63,7 +63,7 @@ namespace Umbraco.Web.Compose UpdateBlockListRecursively(blockListValue, createGuid); - return JsonConvert.SerializeObject(blockListValue.BlockValue); + return JsonConvert.SerializeObject(blockListValue.BlockValue, Formatting.None); } private void UpdateBlockListRecursively(BlockEditorData blockListData, Func createGuid) diff --git a/src/Umbraco.Web/Compose/NestedContentPropertyComponent.cs b/src/Umbraco.Web/Compose/NestedContentPropertyComponent.cs index 633e814bd9..3abf962f4d 100644 --- a/src/Umbraco.Web/Compose/NestedContentPropertyComponent.cs +++ b/src/Umbraco.Web/Compose/NestedContentPropertyComponent.cs @@ -1,14 +1,10 @@ using System; -using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Core; using Umbraco.Core.Composing; -using Umbraco.Core.Events; -using Umbraco.Core.Models; using Umbraco.Core.PropertyEditors; -using Umbraco.Core.Services; -using Umbraco.Core.Services.Implement; using Umbraco.Web.PropertyEditors; namespace Umbraco.Web.Compose @@ -47,7 +43,7 @@ namespace Umbraco.Web.Compose UpdateNestedContentKeysRecursively(complexEditorValue, onlyMissingKeys, createGuid); - return complexEditorValue.ToString(); + return complexEditorValue.ToString(Formatting.None); } private void UpdateNestedContentKeysRecursively(JToken json, bool onlyMissingKeys, Func createGuid) @@ -80,7 +76,7 @@ namespace Umbraco.Web.Compose var parsed = JToken.Parse(propVal); UpdateNestedContentKeysRecursively(parsed, onlyMissingKeys, createGuid); // set the value to the updated one - prop.Value = parsed.ToString(); + prop.Value = parsed.ToString(Formatting.None); } } } diff --git a/src/Umbraco.Web/PropertyEditors/BlockEditorPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/BlockEditorPropertyEditor.cs index 8d50792b71..fab0115d70 100644 --- a/src/Umbraco.Web/PropertyEditors/BlockEditorPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/BlockEditorPropertyEditor.cs @@ -234,7 +234,7 @@ namespace Umbraco.Web.PropertyEditors MapBlockItemData(blockEditorData.BlockValue.SettingsData); // return json - return JsonConvert.SerializeObject(blockEditorData.BlockValue); + return JsonConvert.SerializeObject(blockEditorData.BlockValue, Formatting.None); } #endregion diff --git a/src/Umbraco.Web/PropertyEditors/ColorPickerConfigurationEditor.cs b/src/Umbraco.Web/PropertyEditors/ColorPickerConfigurationEditor.cs index a4d894c551..f163fa898c 100644 --- a/src/Umbraco.Web/PropertyEditors/ColorPickerConfigurationEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ColorPickerConfigurationEditor.cs @@ -130,7 +130,7 @@ namespace Umbraco.Web.PropertyEditors if (id >= nextId) nextId = id + 1; var label = item.Property("label")?.Value?.Value(); - value = JsonConvert.SerializeObject(new { value, label }); + value = JsonConvert.SerializeObject(new { value, label }, Formatting.None); output.Items.Add(new ValueListConfiguration.ValueListItem { Id = id, Value = value }); } diff --git a/src/Umbraco.Web/PropertyEditors/GridPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/GridPropertyEditor.cs index f9eacd9e73..6f919868f7 100644 --- a/src/Umbraco.Web/PropertyEditors/GridPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/GridPropertyEditor.cs @@ -142,7 +142,7 @@ namespace Umbraco.Web.PropertyEditors } // Convert back to raw JSON for persisting - return JsonConvert.SerializeObject(grid); + return JsonConvert.SerializeObject(grid, Formatting.None); } /// diff --git a/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyEditor.cs index e66af480f8..0c70bae0c5 100644 --- a/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyEditor.cs @@ -1,16 +1,14 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; +using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Core; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.IO; using Umbraco.Core.Logging; -using Umbraco.Core.Media; using Umbraco.Core.Models; using Umbraco.Core.PropertyEditors; -using Umbraco.Core.PropertyEditors.ValueConverters; using Umbraco.Core.Services; using Umbraco.Web.Media; @@ -170,7 +168,7 @@ namespace Umbraco.Web.PropertyEditors var sourcePath = _mediaFileSystem.GetRelativePath(src); var copyPath = _mediaFileSystem.CopyFile(args.Copy, property.PropertyType, sourcePath); jo["src"] = _mediaFileSystem.GetUrl(copyPath); - args.Copy.SetValue(property.Alias, jo.ToString(), propertyValue.Culture, propertyValue.Segment); + args.Copy.SetValue(property.Alias, jo.ToString(Formatting.None), propertyValue.Culture, propertyValue.Segment); isUpdated = true; } } @@ -241,17 +239,12 @@ namespace Umbraco.Web.PropertyEditors // it can happen when an image is uploaded via the folder browser, in which case // the property value will be the file source eg '/media/23454/hello.jpg' and we // are fixing that anomaly here - does not make any sense at all but... bah... - - var dt = _dataTypeService.GetDataType(property.PropertyType.DataTypeId); - var config = dt?.ConfigurationAs(); src = svalue; - var json = new - { - src = svalue, - crops = config == null ? Array.Empty() : config.Crops - }; - property.SetValue(JsonConvert.SerializeObject(json), pvalue.Culture, pvalue.Segment); + property.SetValue(JsonConvert.SerializeObject(new + { + src = svalue + }, Formatting.None), pvalue.Culture, pvalue.Segment); } else { diff --git a/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyValueEditor.cs b/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyValueEditor.cs index 8e13d1bb5a..e6c6040325 100644 --- a/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyValueEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyValueEditor.cs @@ -190,6 +190,10 @@ namespace Umbraco.Web.PropertyEditors { src = val, crops = crops + }, new JsonSerializerSettings() + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore }); } } diff --git a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs index aae691f624..69c8b26c6f 100644 --- a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs @@ -123,22 +123,26 @@ namespace Umbraco.Web.PropertyEditors private static readonly JsonSerializerSettings LinkDisplayJsonSerializerSettings = new JsonSerializerSettings { + Formatting = Formatting.None, NullValueHandling = NullValueHandling.Ignore }; public override object FromEditor(ContentPropertyData editorValue, object currentValue) { var value = editorValue.Value?.ToString(); - if (string.IsNullOrEmpty(value)) { - return string.Empty; + return null; } try { + var links = JsonConvert.DeserializeObject>(value); + if (links.Count == 0) + return null; + return JsonConvert.SerializeObject( - from link in JsonConvert.DeserializeObject>(value) + from link in links select new MultiUrlPickerValueEditor.LinkDto { Name = link.Name, @@ -146,8 +150,8 @@ namespace Umbraco.Web.PropertyEditors Target = link.Target, Udi = link.Udi, Url = link.Udi == null ? link.Url : null, // only save the URL for external links - }, LinkDisplayJsonSerializerSettings - ); + }, + LinkDisplayJsonSerializerSettings); } catch (Exception ex) { diff --git a/src/Umbraco.Web/PropertyEditors/MultipleTextStringPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MultipleTextStringPropertyEditor.cs index fa82bc555c..99a57500cc 100644 --- a/src/Umbraco.Web/PropertyEditors/MultipleTextStringPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MultipleTextStringPropertyEditor.cs @@ -60,7 +60,7 @@ namespace Umbraco.Web.PropertyEditors public override object FromEditor(ContentPropertyData editorValue, object currentValue) { var asArray = editorValue.Value as JArray; - if (asArray == null) + if (asArray == null || asArray.HasValues == false) { return null; } diff --git a/src/Umbraco.Web/PropertyEditors/MultipleValueEditor.cs b/src/Umbraco.Web/PropertyEditors/MultipleValueEditor.cs index bbeaff184e..e7123b2147 100644 --- a/src/Umbraco.Web/PropertyEditors/MultipleValueEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MultipleValueEditor.cs @@ -49,14 +49,18 @@ namespace Umbraco.Web.PropertyEditors public override object FromEditor(Core.Models.Editors.ContentPropertyData editorValue, object currentValue) { var json = editorValue.Value as JArray; - if (json == null) + if (json == null || json.HasValues == false) { return null; } var values = json.Select(item => item.Value()).ToArray(); + if (values.Length == 0) + { + return null; + } - return JsonConvert.SerializeObject(values); + return JsonConvert.SerializeObject(values, Formatting.None); } } } diff --git a/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs index b0eeacacd9..2569ab5688 100644 --- a/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs @@ -103,7 +103,7 @@ namespace Umbraco.Web.PropertyEditors var rows = _nestedContentValues.GetPropertyValues(propertyValue); if (rows.Count == 0) - return string.Empty; + return null; foreach (var row in rows.ToList()) { @@ -134,7 +134,7 @@ namespace Umbraco.Web.PropertyEditors } } - return JsonConvert.SerializeObject(rows).ToXmlString(); + return JsonConvert.SerializeObject(rows, Formatting.None).ToXmlString(); } #endregion @@ -229,7 +229,7 @@ namespace Umbraco.Web.PropertyEditors var rows = _nestedContentValues.GetPropertyValues(editorValue.Value); if (rows.Count == 0) - return string.Empty; + return null; foreach (var row in rows.ToList()) { @@ -254,8 +254,9 @@ namespace Umbraco.Web.PropertyEditors } // return json - return JsonConvert.SerializeObject(rows); + return JsonConvert.SerializeObject(rows, Formatting.None); } + #endregion public IEnumerable GetReferences(object value) diff --git a/src/Umbraco.Web/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/RichTextPropertyEditor.cs index 42777f11ad..2d698835b0 100644 --- a/src/Umbraco.Web/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/RichTextPropertyEditor.cs @@ -142,7 +142,7 @@ namespace Umbraco.Web.PropertyEditors var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); var parsed = MacroTagParser.FormatRichTextContentForPersistence(editorValueWithMediaUrlsRemoved); - return parsed; + return parsed.NullOrWhiteSpaceAsNull(); } /// diff --git a/src/Umbraco.Web/PropertyEditors/TagsPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/TagsPropertyEditor.cs index 6066bf7dfb..41e22541c8 100644 --- a/src/Umbraco.Web/PropertyEditors/TagsPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/TagsPropertyEditor.cs @@ -52,7 +52,7 @@ namespace Umbraco.Web.PropertyEditors if (editorValue.Value is JArray json) { - return json.Select(x => x.Value()); + return json.HasValues ? json.Select(x => x.Value()) : null; } if (string.IsNullOrWhiteSpace(value) == false) From eb4acba08118b74a09d7bb23fcdc86d312f75e56 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Wed, 12 Jan 2022 12:35:48 +0100 Subject: [PATCH 34/53] Set default tree group for core trees (#11801) Co-authored-by: Elitsa Marinovska --- src/Umbraco.Core/Extensions/TypeExtensions.cs | 10 ++++++++-- .../Trees/TreeCollectionBuilder.cs | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Core/Extensions/TypeExtensions.cs b/src/Umbraco.Core/Extensions/TypeExtensions.cs index 67a6dd1dce..c5bc99cae8 100644 --- a/src/Umbraco.Core/Extensions/TypeExtensions.cs +++ b/src/Umbraco.Core/Extensions/TypeExtensions.cs @@ -430,10 +430,16 @@ namespace Umbraco.Extensions where T : Attribute { if (type == null) return Enumerable.Empty(); - return type.GetCustomAttributes(typeof (T), inherited).OfType(); + return type.GetCustomAttributes(typeof(T), inherited).OfType(); } - /// + public static bool HasCustomAttribute(this Type type, bool inherit) + where T : Attribute + { + return type.GetCustomAttribute(inherit) != null; + } + + /// /// Tries to return a value based on a property name for an object but ignores case sensitivity /// /// diff --git a/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs b/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs index d8d8afe13a..08f6d7b400 100644 --- a/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs +++ b/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs @@ -48,6 +48,12 @@ namespace Umbraco.Cms.Web.BackOffice.Trees var attribute = controllerType.GetCustomAttribute(false); if (attribute == null) return; + + bool isCoreTree = controllerType.HasCustomAttribute(false); + + // Use section as tree group if core tree, so it isn't grouped by empty key and thus end up in "Third Party" tree group if adding custom tree nodes in other groups, e.g. "Settings" tree group. + attribute.TreeGroup = attribute.TreeGroup ?? (isCoreTree ? attribute.SectionAlias : attribute.TreeGroup); + var tree = new Tree(attribute.SortOrder, attribute.SectionAlias, attribute.TreeGroup, attribute.TreeAlias, attribute.TreeTitle, attribute.TreeUse, controllerType, attribute.IsSingleNodeTree); _trees.Add(tree); } From 0eed6412359f9359f42b3cc0725d4764bb4429dc Mon Sep 17 00:00:00 2001 From: Mole Date: Thu, 13 Jan 2022 13:12:44 +0100 Subject: [PATCH 35/53] Delete temp document type file if validation fails (#11836) Co-authored-by: Elitsa Marinovska --- .../Editors/ContentTypeController.cs | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Umbraco.Web/Editors/ContentTypeController.cs b/src/Umbraco.Web/Editors/ContentTypeController.cs index 62787fbedb..f425599d99 100644 --- a/src/Umbraco.Web/Editors/ContentTypeController.cs +++ b/src/Umbraco.Web/Editors/ContentTypeController.cs @@ -284,9 +284,9 @@ namespace Umbraco.Web.Editors public DocumentTypeDisplay PostSave(DocumentTypeSave contentTypeSave) { - //Before we send this model into this saving/mapping pipeline, we need to do some cleanup on variations. - //If the doc type does not allow content variations, we need to update all of it's property types to not allow this either - //else we may end up with ysods. I'm unsure if the service level handles this but we'll make sure it is updated here + //Before we send this model into this saving/mapping pipeline, we need to do some cleanup on variations. + //If the doc type does not allow content variations, we need to update all of it's property types to not allow this either + //else we may end up with ysods. I'm unsure if the service level handles this but we'll make sure it is updated here if (!contentTypeSave.AllowCultureVariant) { foreach (var prop in contentTypeSave.Groups.SelectMany(x => x.Properties)) @@ -301,16 +301,16 @@ namespace Umbraco.Web.Editors saveContentType: type => Services.ContentTypeService.Save(type), beforeCreateNew: ctSave => { - //create a default template if it doesn't exist -but only if default template is == to the content type + //create a default template if it doesn't exist -but only if default template is == to the content type if (ctSave.DefaultTemplate.IsNullOrWhiteSpace() == false && ctSave.DefaultTemplate == ctSave.Alias) { var template = CreateTemplateForContentType(ctSave.Alias, ctSave.Name); - // If the alias has been manually updated before the first save, - // make sure to also update the first allowed template, as the - // name will come back as a SafeAlias of the document type name, - // not as the actual document type alias. - // For more info: http://issues.umbraco.org/issue/U4-11059 + // If the alias has been manually updated before the first save, + // make sure to also update the first allowed template, as the + // name will come back as a SafeAlias of the document type name, + // not as the actual document type alias. + // For more info: http://issues.umbraco.org/issue/U4-11059 if (ctSave.DefaultTemplate != template.Alias) { var allowedTemplates = ctSave.AllowedTemplates.ToArray(); @@ -319,7 +319,7 @@ namespace Umbraco.Web.Editors ctSave.AllowedTemplates = allowedTemplates; } - //make sure the template alias is set on the default and allowed template so we can map it back + //make sure the template alias is set on the default and allowed template so we can map it back ctSave.DefaultTemplate = template.Alias; } @@ -611,6 +611,8 @@ namespace Umbraco.Web.Editors } else { + // Cleanup the temp file + System.IO.File.Delete(destFileName); model.Notifications.Add(new Notification( Services.TextService.Localize("speechBubbles", "operationFailedHeader"), Services.TextService.Localize("media", "disallowedFileType"), @@ -619,6 +621,8 @@ namespace Umbraco.Web.Editors } else { + // Cleanup the temp file + System.IO.File.Delete(result.FileData[0].LocalFileName); model.Notifications.Add(new Notification( Services.TextService.Localize("speechBubbles", "operationFailedHeader"), Services.TextService.Localize("media", "invalidFileName"), From 73e99b75b28a06376ace326431a22c77d0e179c1 Mon Sep 17 00:00:00 2001 From: Erik-Jan Westendorp Date: Sun, 16 Jan 2022 05:36:45 +0100 Subject: [PATCH 36/53] Add missing read more translation (#11850) --- src/Umbraco.Web.UI/umbraco/config/lang/nl.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml index 23678534d2..98f395413e 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml @@ -770,6 +770,7 @@ Een ogenblik geduld aub... Vorige Eigenschappen + Lees meer Opnieuw opbouwen E-mail om formulier resultaten te ontvangen Prullenbak From 294ea7373ef24d073b6785445b674fefa5c12c3f Mon Sep 17 00:00:00 2001 From: Erik-Jan Westendorp Date: Sun, 16 Jan 2022 12:13:17 +0100 Subject: [PATCH 37/53] Localization: Rename RelatieTypen to RelatieTypes (Dutch) (#11849) * Change RelatieTypen to RelatieTypes * Small 't' --- src/Umbraco.Web.UI/umbraco/config/lang/nl.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml index 98f395413e..00ae98c40d 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml @@ -1795,7 +1795,7 @@ Echter, Runway biedt een gemakkelijke basis om je snel op weg te helpen. Als je Rollen Ledentypes Documenttypes - RelatieTypen + Relatietypes Packages Packages Partial Views From ee1153ee56d7105353dd32561d035533b65b3087 Mon Sep 17 00:00:00 2001 From: Erik-Jan Westendorp Date: Sun, 16 Jan 2022 12:33:49 +0100 Subject: [PATCH 38/53] Localization: Translate history cleanup labels/descriptions to Dutch (#11835) * Update nl.xml to show labels in dutch in info content app * Update nl.xml to show labels in dutch in permissions app --- src/Umbraco.Web.UI/umbraco/config/lang/nl.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml index 00ae98c40d..4c3557b5d1 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/nl.xml @@ -1252,6 +1252,10 @@ Echter, Runway biedt een gemakkelijke basis om je snel op weg te helpen. Als je Terugzetten naar Selecteer versie Bekijk + + Versies + Conceptversie + Gepubliceerde versie Bewerk script-bestand @@ -1671,6 +1675,12 @@ Echter, Runway biedt een gemakkelijke basis om je snel op weg te helpen. Als je weggooien? Tabblad toevoegen + Geschiedenis opschonen + Overschrijf de standaard geschiedenis opschonen instellingen. + Bewaar alle versies nieuwer dan dagen + Bewaar de laatste versie per dag voor dagen + Voorkom opschonen + Geschiedenis opschonen is globaal uitgeschakeld. Deze instellingen worden pas van kracht nadat ze zijn ingeschakeld. Taal toevoegen From 8cd0e5a0031e2cf5d063c9f59aa7e820cff5bff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Kottal?= Date: Sat, 8 Jan 2022 11:27:02 +0100 Subject: [PATCH 39/53] Change logmessages --- src/Umbraco.Core/Runtime/MainDom.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Core/Runtime/MainDom.cs b/src/Umbraco.Core/Runtime/MainDom.cs index ec4e56df1b..08d11db5cd 100644 --- a/src/Umbraco.Core/Runtime/MainDom.cs +++ b/src/Umbraco.Core/Runtime/MainDom.cs @@ -154,11 +154,11 @@ namespace Umbraco.Cms.Core.Runtime // the handler is not installed so that would be the hosting environment if (_signaled) { - _logger.LogInformation("Cannot acquire (signaled)."); + _logger.LogInformation("Cannot acquire MainDom (signaled)."); return false; } - _logger.LogInformation("Acquiring."); + _logger.LogInformation("Acquiring MainDom."); // Get the lock var acquired = false; @@ -168,12 +168,12 @@ namespace Umbraco.Cms.Core.Runtime } catch (Exception ex) { - _logger.LogError(ex, "Error while acquiring"); + _logger.LogError(ex, "Error while acquiring MainDom"); } if (!acquired) { - _logger.LogInformation("Cannot acquire (timeout)."); + _logger.LogInformation("Cannot acquire MainDom (timeout)."); // In previous versions we'd let a TimeoutException be thrown // and the appdomain would not start. We have the opportunity to allow it to @@ -209,7 +209,7 @@ namespace Umbraco.Cms.Core.Runtime _logger.LogWarning(ex, ex.Message); } - _logger.LogInformation("Acquired."); + _logger.LogInformation("Acquired MainDom."); return true; } From 0e9525d216ea66ea4eaf2528ca25717e5e7b9cc1 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 17 Jan 2022 08:32:13 +0100 Subject: [PATCH 40/53] Added null check --- src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs b/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs index 3df6ffb47b..5d6d53ab52 100644 --- a/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs +++ b/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs @@ -12,7 +12,12 @@ namespace Umbraco.Web.WebApi { public override void OnActionExecuting(HttpActionContext actionContext) { - actionContext.ControllerContext.Configuration.IncludeErrorDetailPolicy = HttpContext.Current.IsDebuggingEnabled ? IncludeErrorDetailPolicy.Always : IncludeErrorDetailPolicy.Default; + if (HttpContext.Current?.IsDebuggingEnabled ?? false) + { + actionContext.ControllerContext.Configuration.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Always; + } + + actionContext.ControllerContext.Configuration.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Default; } } } From 364b8f1c72925b81224a535b1f1ab1e03fd460f2 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Tue, 18 Jan 2022 13:58:14 +0100 Subject: [PATCH 41/53] Add UmbracoApplicationStartedNotification and UmbracoApplicationStoppedNotification (#11857) * Add UmbracoApplicationStartedNotification and UmbracoApplicationStoppedNotification * Include cancellation token when publishing unattended install/upgrade notifications --- .../UmbracoApplicationStartedNotification.cs | 9 ++ .../UmbracoApplicationStartingNotification.cs | 14 +-- .../UmbracoApplicationStoppedNotification.cs | 9 ++ .../UmbracoApplicationStoppingNotification.cs | 7 +- .../Runtime/CoreRuntime.cs | 90 ++++++++++++++----- 5 files changed, 99 insertions(+), 30 deletions(-) create mode 100644 src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs create mode 100644 src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs new file mode 100644 index 0000000000..a3d38720d7 --- /dev/null +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Notifications +{ + /// + /// Notification that occurs when Umbraco has completely booted up and the request processing pipeline is configured. + /// + /// + public class UmbracoApplicationStartedNotification : INotification + { } +} diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs index 4cbf0a55c6..dd60f9431c 100644 --- a/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs @@ -1,23 +1,23 @@ -// Copyright (c) Umbraco. -// See LICENSE for more details. - namespace Umbraco.Cms.Core.Notifications { /// - /// Notification that occurs at the very end of the Umbraco boot - /// process and after all initialize. + /// Notification that occurs at the very end of the Umbraco boot process (after all s are initialized). /// + /// public class UmbracoApplicationStartingNotification : INotification { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The runtime level public UmbracoApplicationStartingNotification(RuntimeLevel runtimeLevel) => RuntimeLevel = runtimeLevel; /// - /// Gets the runtime level of execution. + /// Gets the runtime level. /// + /// + /// The runtime level. + /// public RuntimeLevel RuntimeLevel { get; } } } diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs new file mode 100644 index 0000000000..be4c6ccfd4 --- /dev/null +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Notifications +{ + /// + /// Notification that occurs when Umbraco has completely shutdown. + /// + /// + public class UmbracoApplicationStoppedNotification : INotification + { } +} diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs index db86a1e614..6d5234bbcc 100644 --- a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs @@ -1,4 +1,9 @@ namespace Umbraco.Cms.Core.Notifications { - public class UmbracoApplicationStoppingNotification : INotification { } + /// + /// Notification that occurs when Umbraco is shutting down (after all s are terminated). + /// + /// + public class UmbracoApplicationStoppingNotification : INotification + { } } diff --git a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs index 175bceb9e0..5dbe78c2f5 100644 --- a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs +++ b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs @@ -2,6 +2,8 @@ using System; using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; @@ -16,12 +18,13 @@ using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; using ComponentCollection = Umbraco.Cms.Core.Composing.ComponentCollection; +using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Cms.Infrastructure.Runtime { + /// public class CoreRuntime : IRuntime { - private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; private readonly ComponentCollection _components; private readonly IApplicationShutdownRegistry _applicationShutdownRegistry; @@ -32,14 +35,16 @@ namespace Umbraco.Cms.Infrastructure.Runtime private readonly IHostingEnvironment _hostingEnvironment; private readonly IUmbracoVersion _umbracoVersion; private readonly IServiceProvider _serviceProvider; + private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILogger _logger; private CancellationToken _cancellationToken; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// public CoreRuntime( - ILoggerFactory loggerFactory, IRuntimeState state, + ILoggerFactory loggerFactory, ComponentCollection components, IApplicationShutdownRegistry applicationShutdownRegistry, IProfilingLogger profilingLogger, @@ -48,9 +53,11 @@ namespace Umbraco.Cms.Infrastructure.Runtime IEventAggregator eventAggregator, IHostingEnvironment hostingEnvironment, IUmbracoVersion umbracoVersion, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + IHostApplicationLifetime hostApplicationLifetime) { State = state; + _loggerFactory = loggerFactory; _components = components; _applicationShutdownRegistry = applicationShutdownRegistry; @@ -61,6 +68,7 @@ namespace Umbraco.Cms.Infrastructure.Runtime _hostingEnvironment = hostingEnvironment; _umbracoVersion = umbracoVersion; _serviceProvider = serviceProvider; + _hostApplicationLifetime = hostApplicationLifetime; _logger = _loggerFactory.CreateLogger(); } @@ -76,23 +84,49 @@ namespace Umbraco.Cms.Infrastructure.Runtime IUmbracoDatabaseFactory databaseFactory, IEventAggregator eventAggregator, IHostingEnvironment hostingEnvironment, - IUmbracoVersion umbracoVersion - ):this( - loggerFactory, - state, - components, - applicationShutdownRegistry, - profilingLogger, - mainDom, - databaseFactory, - eventAggregator, - hostingEnvironment, - umbracoVersion, - null - ) - { + IUmbracoVersion umbracoVersion, + IServiceProvider serviceProvider) + : this( + state, + loggerFactory, + components, + applicationShutdownRegistry, + profilingLogger, + mainDom, + databaseFactory, + eventAggregator, + hostingEnvironment, + umbracoVersion, + serviceProvider, + serviceProvider?.GetRequiredService()) + { } - } + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete] + public CoreRuntime( + ILoggerFactory loggerFactory, + IRuntimeState state, + ComponentCollection components, + IApplicationShutdownRegistry applicationShutdownRegistry, + IProfilingLogger profilingLogger, + IMainDom mainDom, + IUmbracoDatabaseFactory databaseFactory, + IEventAggregator eventAggregator, + IHostingEnvironment hostingEnvironment, + IUmbracoVersion umbracoVersion) + : this( + loggerFactory, + state, + components, + applicationShutdownRegistry, + profilingLogger, + mainDom, + databaseFactory, + eventAggregator, + hostingEnvironment, + umbracoVersion, + null) + { } /// /// Gets the state of the Umbraco runtime. @@ -103,13 +137,17 @@ namespace Umbraco.Cms.Infrastructure.Runtime public async Task RestartAsync() { await StopAsync(_cancellationToken); + await _eventAggregator.PublishAsync(new UmbracoApplicationStoppedNotification(), _cancellationToken); await StartAsync(_cancellationToken); + await _eventAggregator.PublishAsync(new UmbracoApplicationStartedNotification(), _cancellationToken); } /// public async Task StartAsync(CancellationToken cancellationToken) { + // Store token, so we can re-use this during restart _cancellationToken = cancellationToken; + StaticApplicationLogging.Initialize(_loggerFactory); StaticServiceProvider.Instance = _serviceProvider; @@ -130,6 +168,13 @@ namespace Umbraco.Cms.Infrastructure.Runtime _logger.LogError(exception, msg); }; + // Add application started and stopped notifications (only on initial startup, not restarts) + if (_hostApplicationLifetime.ApplicationStarted.IsCancellationRequested == false) + { + _hostApplicationLifetime.ApplicationStarted.Register(() => _eventAggregator.Publish(new UmbracoApplicationStartedNotification())); + _hostApplicationLifetime.ApplicationStopped.Register(() => _eventAggregator.Publish(new UmbracoApplicationStoppedNotification())); + } + // acquire the main domain - if this fails then anything that should be registered with MainDom will not operate AcquireMainDom(); @@ -137,7 +182,7 @@ namespace Umbraco.Cms.Infrastructure.Runtime await _eventAggregator.PublishAsync(new UmbracoApplicationMainDomAcquiredNotification(), cancellationToken); // notify for unattended install - await _eventAggregator.PublishAsync(new RuntimeUnattendedInstallNotification()); + await _eventAggregator.PublishAsync(new RuntimeUnattendedInstallNotification(), cancellationToken); DetermineRuntimeLevel(); if (!State.UmbracoCanBoot()) @@ -153,7 +198,7 @@ namespace Umbraco.Cms.Infrastructure.Runtime // if level is Run and reason is UpgradeMigrations, that means we need to perform an unattended upgrade var unattendedUpgradeNotification = new RuntimeUnattendedUpgradeNotification(); - await _eventAggregator.PublishAsync(unattendedUpgradeNotification); + await _eventAggregator.PublishAsync(unattendedUpgradeNotification, cancellationToken); switch (unattendedUpgradeNotification.UnattendedUpgradeResult) { case RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors: @@ -161,6 +206,7 @@ namespace Umbraco.Cms.Infrastructure.Runtime { throw new InvalidOperationException($"Unattended upgrade result was {RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors} but no {nameof(BootFailedException)} was registered"); } + // we cannot continue here, the exception will be rethrown by BootFailedMiddelware return; case RuntimeUnattendedUpgradeNotification.UpgradeResult.CoreUpgradeComplete: From 72f30eb937c63433c83eefb35e534eacb0f77df4 Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska Date: Tue, 18 Jan 2022 15:19:05 +0100 Subject: [PATCH 42/53] Adding else case --- src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs b/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs index 5d6d53ab52..ef07dfcb79 100644 --- a/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs +++ b/src/Umbraco.Web/WebApi/EnableDetailedErrorsAttribute.cs @@ -16,8 +16,10 @@ namespace Umbraco.Web.WebApi { actionContext.ControllerContext.Configuration.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Always; } - - actionContext.ControllerContext.Configuration.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Default; + else + { + actionContext.ControllerContext.Configuration.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Default; + } } } } From 1b5830a9d705c29cae65831821ae606b1114e8eb Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Date: Tue, 18 Jan 2022 15:36:25 +0100 Subject: [PATCH 43/53] V9: Visiting a page without a template returns "Page Not Found" (#11852) * Returns "Page Not Found" when template doesn't exist for a docType * Fix comment * Use IsNullOrWhiteSpace instead of IsNullOrEmpty Co-authored-by: Mole --- .../Controllers/RenderController.cs | 22 ++++++++++++++++++- .../Controllers/UmbracoPageController.cs | 10 +++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.Common/Controllers/RenderController.cs b/src/Umbraco.Web.Common/Controllers/RenderController.cs index 84354e4988..4d74dd1767 100644 --- a/src/Umbraco.Web.Common/Controllers/RenderController.cs +++ b/src/Umbraco.Web.Common/Controllers/RenderController.cs @@ -53,6 +53,26 @@ namespace Umbraco.Cms.Web.Common.Controllers /// public virtual IActionResult Index() => CurrentTemplate(new ContentModel(CurrentPage)); + /// + /// Gets an action result based on the template name found in the route values and a model. + /// + /// The type of the model. + /// The model. + /// The action result. + /// + /// If the template found in the route values doesn't physically exist, Umbraco not found result is returned. + /// + protected override IActionResult CurrentTemplate(T model) + { + if (EnsurePhsyicalViewExists(UmbracoRouteValues.TemplateName) == false) + { + // no physical template file was found + return new PublishedContentNotFoundResult(UmbracoContext); + } + + return View(UmbracoRouteValues.TemplateName, model); + } + /// /// Before the controller executes we will handle redirects and not founds /// @@ -123,6 +143,6 @@ namespace Umbraco.Cms.Web.Common.Controllers { return new PublishedContentNotFoundResult(UmbracoContext); } - } + } } } diff --git a/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs b/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs index 0e6b6d0d0c..f9840df370 100644 --- a/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs +++ b/src/Umbraco.Web.Common/Controllers/UmbracoPageController.cs @@ -74,7 +74,7 @@ namespace Umbraco.Cms.Web.Common.Controllers /// The model. /// The action result. /// If the template found in the route values doesn't physically exist and exception is thrown - protected IActionResult CurrentTemplate(T model) + protected virtual IActionResult CurrentTemplate(T model) { if (EnsurePhsyicalViewExists(UmbracoRouteValues.TemplateName) == false) { @@ -90,6 +90,13 @@ namespace Umbraco.Cms.Web.Common.Controllers /// The view name. protected bool EnsurePhsyicalViewExists(string template) { + if (string.IsNullOrWhiteSpace(template)) + { + string docTypeAlias = UmbracoRouteValues.PublishedRequest.PublishedContent.ContentType.Alias; + _logger.LogWarning("No physical template file was found for document type with alias {Alias}", docTypeAlias); + return false; + } + ViewEngineResult result = _compositeViewEngine.FindView(ControllerContext, template, false); if (result.View != null) { @@ -99,6 +106,5 @@ namespace Umbraco.Cms.Web.Common.Controllers _logger.LogWarning("No physical template file was found for template {Template}", template); return false; } - } } From 229ca989eb2a97c72c7dd4e2a54ced28f493fbd1 Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Date: Tue, 18 Jan 2022 15:37:31 +0100 Subject: [PATCH 44/53] V9: Allowlisting remote URLs for displaying content on the content dashboard (#11825) * Fixing ContentDashboardSettings to work when set in the config * Moving file in Models folder and adding ContentDashboardUrlAllowlist setting * Implementing allowlist for content dashboard base url * Cleanup * Error msg vs log msg --- src/JsonSchema/AppSettings.cs | 2 + .../{ => Models}/ContentDashboardSettings.cs | 18 +++++-- src/Umbraco.Core/Constants-Configuration.cs | 3 +- .../UmbracoBuilder.Configuration.cs | 3 +- .../Controllers/DashboardController.cs | 51 +++++++++++++++++-- 5 files changed, 67 insertions(+), 10 deletions(-) rename src/Umbraco.Core/Configuration/{ => Models}/ContentDashboardSettings.cs (55%) diff --git a/src/JsonSchema/AppSettings.cs b/src/JsonSchema/AppSettings.cs index a9b70fc4a1..048513a5da 100644 --- a/src/JsonSchema/AppSettings.cs +++ b/src/JsonSchema/AppSettings.cs @@ -86,6 +86,8 @@ namespace JsonSchema public PackageMigrationSettings PackageMigration { get; set; } public LegacyPasswordMigrationSettings LegacyPasswordMigration { get; set; } + + public ContentDashboardSettings ContentDashboard { get; set; } } /// diff --git a/src/Umbraco.Core/Configuration/ContentDashboardSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs similarity index 55% rename from src/Umbraco.Core/Configuration/ContentDashboardSettings.cs rename to src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs index 7bef36dba4..3f8546a1ad 100644 --- a/src/Umbraco.Core/Configuration/ContentDashboardSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs @@ -1,6 +1,11 @@ - -namespace Umbraco.Cms.Core.Configuration +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models { + /// + /// Typed configuration options for content dashboard settings. + /// + [UmbracoOptions(Constants.Configuration.ConfigContentDashboard)] public class ContentDashboardSettings { private const string DefaultContentDashboardPath = "cms"; @@ -18,6 +23,13 @@ namespace Umbraco.Cms.Core.Configuration /// Gets the path to use when constructing the URL for retrieving data for the content dashboard. /// /// The URL path. - public string ContentDashboardPath { get; set; } = DefaultContentDashboardPath; + [DefaultValue(DefaultContentDashboardPath)] + public string ContentDashboardPath { get; set; } = DefaultContentDashboardPath; + + /// + /// Gets the allowed addresses to retrieve data for the content dashboard. + /// + /// The URLs. + public string[] ContentDashboardUrlAllowlist { get; set; } } } diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 063d733821..ab951618e3 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core +namespace Umbraco.Cms.Core { public static partial class Constants { @@ -54,6 +54,7 @@ public const string ConfigUserPassword = ConfigPrefix + "Security:UserPassword"; public const string ConfigRichTextEditor = ConfigPrefix + "RichTextEditor"; public const string ConfigPackageMigration = ConfigPrefix + "PackageMigration"; + public const string ConfigContentDashboard = ConfigPrefix + "ContentDashboard"; } } } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index ce2e4f2304..7bc5ae57c8 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -82,7 +82,8 @@ namespace Umbraco.Cms.Core.DependencyInjection .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() - .AddUmbracoOptions(); + .AddUmbracoOptions() + .AddUmbracoOptions(); return builder; } diff --git a/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs b/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs index 7b64d05633..955081fa73 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; @@ -8,9 +9,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Dashboards; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; @@ -40,7 +43,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers private readonly IDashboardService _dashboardService; private readonly IUmbracoVersion _umbracoVersion; private readonly IShortStringHelper _shortStringHelper; - private readonly IOptions _dashboardSettings; + private readonly ContentDashboardSettings _dashboardSettings; /// /// Initializes a new instance of the with all its dependencies. /// @@ -60,12 +63,13 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers _dashboardService = dashboardService; _umbracoVersion = umbracoVersion; _shortStringHelper = shortStringHelper; - _dashboardSettings = dashboardSettings; + _dashboardSettings = dashboardSettings.Value; } //we have just one instance of HttpClient shared for the entire application private static readonly HttpClient HttpClient = new HttpClient(); + // TODO(V10) : change return type to Task> and consider removing baseUrl as parameter //we have baseurl as a param to make previewing easier, so we can test with a dev domain from client side [ValidateAngularAntiForgeryToken] public async Task GetRemoteDashboardContent(string section, string baseUrl = "https://dashboard.umbraco.com/") @@ -76,9 +80,19 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers var version = _umbracoVersion.SemanticVersion.ToSemanticStringWithoutBuild(); var isAdmin = user.IsAdmin(); + if (!IsAllowedUrl(baseUrl)) + { + _logger.LogError($"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {baseUrl}"); + HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + + // Hacking the response - can't set the HttpContext.Response.Body, so instead returning the error as JSON + var errorJson = JsonConvert.SerializeObject(new { Error = "Dashboard source not permitted" }); + return JObject.Parse(errorJson); + } + var url = string.Format("{0}{1}?section={2}&allowed={3}&lang={4}&version={5}&admin={6}", baseUrl, - _dashboardSettings.Value.ContentDashboardPath, + _dashboardSettings.ContentDashboardPath, section, allowedSections, language, @@ -116,8 +130,15 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers return result; } + // TODO(V10) : consider removing baseUrl as parameter public async Task GetRemoteDashboardCss(string section, string baseUrl = "https://dashboard.umbraco.org/") { + if (!IsAllowedUrl(baseUrl)) + { + _logger.LogError($"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {baseUrl}"); + return BadRequest("Dashboard source not permitted"); + } + var url = string.Format(baseUrl + "css/dashboard.css?section={0}", section); var key = "umbraco-dynamic-dashboard-css-" + section; @@ -152,12 +173,18 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers } - return Content(result,"text/css", Encoding.UTF8); + return Content(result, "text/css", Encoding.UTF8); } public async Task GetRemoteXml(string site, string url) { + if (!IsAllowedUrl(url)) + { + _logger.LogError($"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {url}"); + return BadRequest("Dashboard source not permitted"); + } + // This is used in place of the old feedproxy.config // Which was used to grab data from our.umbraco.com, umbraco.com or umbraco.tv // for certain dashboards or the help drawer @@ -214,7 +241,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers } } - return Content(result,"text/xml", Encoding.UTF8); + return Content(result, "text/xml", Encoding.UTF8); } @@ -240,5 +267,19 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers }) }).ToList(); } + + // Checks if the passed URL is part of the configured allowlist of addresses + private bool IsAllowedUrl(string url) + { + // No addresses specified indicates that any URL is allowed + if (_dashboardSettings.ContentDashboardUrlAllowlist is null || _dashboardSettings.ContentDashboardUrlAllowlist.Contains(url, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + else + { + return false; + } + } } } From 24519f6dad121e846395d58179804647688291f4 Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Date: Tue, 18 Jan 2022 15:38:03 +0100 Subject: [PATCH 45/53] Allowlisting remote URLs for displaying content on the content dashboard (#11822) * Implement allowlisting of urls when fetching data for the content dashboard * Adding a new setting in the config & removing inexistent one * Adding description * Adding description * Tidy up code --- src/Umbraco.Core/Constants-AppSettings.cs | 5 +++ .../Dashboards/ContentDashboardSettings.cs | 11 ++++++- .../Dashboards/IContentDashboardSettings.cs | 6 ++++ src/Umbraco.Web.UI/web.Template.config | 2 +- .../Editors/DashboardController.cs | 31 ++++++++++++++++++- 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Core/Constants-AppSettings.cs b/src/Umbraco.Core/Constants-AppSettings.cs index de7799c165..4e5619813e 100644 --- a/src/Umbraco.Core/Constants-AppSettings.cs +++ b/src/Umbraco.Core/Constants-AppSettings.cs @@ -120,6 +120,11 @@ namespace Umbraco.Core /// public const string ContentDashboardPath = "Umbraco.Core.ContentDashboardPath"; + /// + /// A list of allowed addresses to fetch content for the content dashboard. + /// + public const string ContentDashboardUrlAllowlist = "Umbraco.Core.ContentDashboardUrl-Allowlist"; + /// /// TODO: FILL ME IN /// diff --git a/src/Umbraco.Core/Dashboards/ContentDashboardSettings.cs b/src/Umbraco.Core/Dashboards/ContentDashboardSettings.cs index b370f93eca..24daecf0b8 100644 --- a/src/Umbraco.Core/Dashboards/ContentDashboardSettings.cs +++ b/src/Umbraco.Core/Dashboards/ContentDashboardSettings.cs @@ -2,7 +2,7 @@ namespace Umbraco.Core.Dashboards { - public class ContentDashboardSettings: IContentDashboardSettings + public class ContentDashboardSettings : IContentDashboardSettings { private const string DefaultContentDashboardPath = "cms"; @@ -30,5 +30,14 @@ namespace Umbraco.Core.Dashboards ConfigurationManager.AppSettings.ContainsKey(Constants.AppSettings.ContentDashboardPath) ? ConfigurationManager.AppSettings[Constants.AppSettings.ContentDashboardPath] : DefaultContentDashboardPath; + + /// + /// Gets the allowed addresses to retrieve data for the content dashboard. + /// + /// The URLs. + public string ContentDashboardUrlAllowlist => + ConfigurationManager.AppSettings.ContainsKey(Constants.AppSettings.ContentDashboardUrlAllowlist) + ? ConfigurationManager.AppSettings[Constants.AppSettings.ContentDashboardUrlAllowlist] + : null; } } diff --git a/src/Umbraco.Core/Dashboards/IContentDashboardSettings.cs b/src/Umbraco.Core/Dashboards/IContentDashboardSettings.cs index f5c4e3da78..518a217bf8 100644 --- a/src/Umbraco.Core/Dashboards/IContentDashboardSettings.cs +++ b/src/Umbraco.Core/Dashboards/IContentDashboardSettings.cs @@ -16,5 +16,11 @@ /// /// The URL path. string ContentDashboardPath { get; } + + /// + /// Gets the allowed addresses to retrieve data for the content dashboard. + /// + /// The URLs. + string ContentDashboardUrlAllowlist { get; } } } diff --git a/src/Umbraco.Web.UI/web.Template.config b/src/Umbraco.Web.UI/web.Template.config index f19ab5d3b6..e4e3e19bcb 100644 --- a/src/Umbraco.Web.UI/web.Template.config +++ b/src/Umbraco.Web.UI/web.Template.config @@ -38,7 +38,7 @@ - + diff --git a/src/Umbraco.Web/Editors/DashboardController.cs b/src/Umbraco.Web/Editors/DashboardController.cs index cf56cc4be8..1073bef413 100644 --- a/src/Umbraco.Web/Editors/DashboardController.cs +++ b/src/Umbraco.Web/Editors/DashboardController.cs @@ -17,9 +17,9 @@ using Umbraco.Core.Logging; using Umbraco.Core.Persistence; using Umbraco.Core.Services; using Umbraco.Core.Dashboards; -using Umbraco.Core.IO; using Umbraco.Core.Models; using Umbraco.Web.Services; +using System.Web.Http; namespace Umbraco.Web.Editors { @@ -61,6 +61,8 @@ namespace Umbraco.Web.Editors var version = UmbracoVersion.SemanticVersion.ToSemanticString(); var isAdmin = user.IsAdmin(); + VerifyDashboardSource(baseUrl); + var url = string.Format("{0}{1}?section={2}&allowed={3}&lang={4}&version={5}&admin={6}", baseUrl, _dashboardSettings.ContentDashboardPath, @@ -103,6 +105,8 @@ namespace Umbraco.Web.Editors public async Task GetRemoteDashboardCss(string section, string baseUrl = "https://dashboard.umbraco.org/") { + VerifyDashboardSource(baseUrl); + var url = string.Format(baseUrl + "css/dashboard.css?section={0}", section); var key = "umbraco-dynamic-dashboard-css-" + section; @@ -144,6 +148,8 @@ namespace Umbraco.Web.Editors public async Task GetRemoteXml(string site, string url) { + VerifyDashboardSource(url); + // This is used in place of the old feedproxy.config // Which was used to grab data from our.umbraco.com, umbraco.com or umbraco.tv // for certain dashboards or the help drawer @@ -228,5 +234,28 @@ namespace Umbraco.Web.Editors }) }).ToList(); } + + // Checks if the passed URL is part of the configured allowlist of addresses + private bool IsAllowedUrl(string url) + { + // No addresses specified indicates that any URL is allowed + if (string.IsNullOrEmpty(_dashboardSettings.ContentDashboardUrlAllowlist) || _dashboardSettings.ContentDashboardUrlAllowlist.Contains(url)) + { + return true; + } + else + { + return false; + } + } + + private void VerifyDashboardSource(string url) + { + if(!IsAllowedUrl(url)) + { + Logger.Error($"The following URL is not listed in the allowlist for ContentDashboardUrl in the Web.config: {url}"); + throw new HttpResponseException(Request.CreateErrorResponse(HttpStatusCode.BadRequest, "Dashboard source not permitted")); + } + } } } From c60d8c8ab8836f21369a11ce5496f643db7a1556 Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Wed, 19 Jan 2022 07:32:33 +0000 Subject: [PATCH 46/53] Added EntityController.GetUrlsByIds support for int & guid + update MNTP (#11680) Fixes issue with MNTP (for 8.18) in a partial view macro - GH #11631 Renamed GetUrlsByUdis to match, don't do this in v9 as it would be breaking there, instead mark it obsolete. TODO: v9 ensure integration test coverage, more painful here as no WebApplicationFactory. --- .../common/mocks/resources/entity.mocks.js | 6 +- .../src/common/resources/entity.resource.js | 10 +- .../contentpicker/contentpicker.controller.js | 2 +- src/Umbraco.Web/Editors/EntityController.cs | 141 +++++++++++++++--- 4 files changed, 130 insertions(+), 29 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/mocks/resources/entity.mocks.js b/src/Umbraco.Web.UI.Client/src/common/mocks/resources/entity.mocks.js index 05594115e1..08c28fcbd1 100644 --- a/src/Umbraco.Web.UI.Client/src/common/mocks/resources/entity.mocks.js +++ b/src/Umbraco.Web.UI.Client/src/common/mocks/resources/entity.mocks.js @@ -34,7 +34,7 @@ angular.module('umbraco.mocks'). return [200, nodes, null]; } - function returnUrlsbyUdis(status, data, headers) { + function returnUrlsByIds(status, data, headers) { if (!mocksUtils.checkAuth()) { return [401, null, null]; @@ -83,8 +83,8 @@ angular.module('umbraco.mocks'). .respond(returnEntitybyIdsPost); $httpBackend - .whenPOST(mocksUtils.urlRegex('/umbraco/UmbracoApi/Entity/GetUrlsByUdis')) - .respond(returnUrlsbyUdis); + .whenPOST(mocksUtils.urlRegex('/umbraco/UmbracoApi/Entity/GetUrlsByIds')) + .respond(returnUrlsByIds); $httpBackend .whenGET(mocksUtils.urlRegex('/umbraco/UmbracoApi/Entity/GetAncestors')) diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js index 44be85b8fd..6e7ace9a8d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js @@ -127,19 +127,19 @@ function entityResource($q, $http, umbRequestHelper) { 'Failed to retrieve url for id:' + id); }, - getUrlsByUdis: function(udis, culture) { - var query = "culture=" + (culture || ""); + getUrlsByIds: function(ids, type, culture) { + var query = `type=${type}&culture=${culture || ""}`; return umbRequestHelper.resourcePromise( $http.post( umbRequestHelper.getApiUrl( "entityApiBaseUrl", - "GetUrlsByUdis", + "GetUrlsByIds", query), { - udis: udis + ids: ids }), - 'Failed to retrieve url map for udis ' + udis); + 'Failed to retrieve url map for ids ' + ids); }, getUrlByUdi: function (udi, culture) { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js index 1ecd6bdf26..d2a1710e49 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js @@ -421,7 +421,7 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso var requests = [ entityResource.getByIds(missingIds, entityType), - entityResource.getUrlsByUdis(missingIds) + entityResource.getUrlsByIds(missingIds, entityType) ]; return $q.all(requests).then(function ([data, urlMap]) { diff --git a/src/Umbraco.Web/Editors/EntityController.cs b/src/Umbraco.Web/Editors/EntityController.cs index 573246e1f0..c0535bf787 100644 --- a/src/Umbraco.Web/Editors/EntityController.cs +++ b/src/Umbraco.Web/Editors/EntityController.cs @@ -76,7 +76,8 @@ namespace Umbraco.Web.Editors new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetPath", "id", typeof(int), typeof(Guid), typeof(Udi)), new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetUrlAndAnchors", "id", typeof(int), typeof(Guid), typeof(Udi)), new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetById", "id", typeof(int), typeof(Guid), typeof(Udi)), - new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetByIds", "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[])))); + new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetByIds", "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[])), + new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetUrlsByIds", "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[])))); } } @@ -236,47 +237,147 @@ namespace Umbraco.Web.Editors } /// - /// Get entity URLs by UDIs + /// Get entity URLs by IDs /// - /// - /// A list of UDIs to lookup items by + /// + /// A list of IDs to lookup items by /// - /// The culture to fetch the URL for + /// The entity type to look for. + /// The culture to fetch the URL for. /// Dictionary mapping Udi -> Url /// /// We allow for POST because there could be quite a lot of Ids. /// [HttpGet] [HttpPost] - public IDictionary GetUrlsByUdis([FromJsonPath] Udi[] udis, string culture = null) + public IDictionary GetUrlsByIds([FromJsonPath] int[] ids, [FromUri] UmbracoEntityTypes type, [FromUri] string culture = null) { - if (udis == null || udis.Length == 0) + if (ids == null || !ids.Any()) + { + return new Dictionary(); + } + + string MediaOrDocumentUrl(int id) + { + switch (type) + { + case UmbracoEntityTypes.Document: + return UmbracoContext.UrlProvider.GetUrl(id, culture: culture ?? ClientCulture()); + case UmbracoEntityTypes.Media: { + var media = UmbracoContext.Media.GetById(id); + // NOTE: If culture is passed here we get an empty string rather than a media item URL. + return UmbracoContext.UrlProvider.GetMediaUrl(media, culture: null); + } + default: + return null; + } + } + + return ids + .Distinct() + .Select(id => new { + Id = id, + Url = MediaOrDocumentUrl(id) + }).ToDictionary(x => x.Id, x => x.Url); + } + + /// + /// Get entity URLs by IDs + /// + /// + /// A list of IDs to lookup items by + /// + /// The entity type to look for. + /// The culture to fetch the URL for. + /// Dictionary mapping Udi -> Url + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + public IDictionary GetUrlsByIds([FromJsonPath] Guid[] ids, [FromUri] UmbracoEntityTypes type, [FromUri] string culture = null) + { + if (ids == null || !ids.Any()) + { + return new Dictionary(); + } + + string MediaOrDocumentUrl(Guid id) + { + switch (type) + { + case UmbracoEntityTypes.Document: + return UmbracoContext.UrlProvider.GetUrl(id, culture: culture ?? ClientCulture()); + case UmbracoEntityTypes.Media: + { + var media = UmbracoContext.Media.GetById(id); + // NOTE: If culture is passed here we get an empty string rather than a media item URL. + return UmbracoContext.UrlProvider.GetMediaUrl(media, culture: null); + } + default: + return null; + } + } + + return ids + .Distinct() + .Select(id => new { + Id = id, + Url = MediaOrDocumentUrl(id) + }).ToDictionary(x => x.Id, x => x.Url); + } + + /// + /// Get entity URLs by IDs + /// + /// + /// A list of IDs to lookup items by + /// + /// The entity type to look for. + /// The culture to fetch the URL for. + /// Dictionary mapping Udi -> Url + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + // NOTE: V9 - can't rename GetUrlsByUdis in v9 as it's already released, it's OK to do here as 8.18 isn't out yet. + public IDictionary GetUrlsByIds([FromJsonPath] Udi[] ids, [FromUri] UmbracoEntityTypes type, [FromUri] string culture = null) + { + if (ids == null || !ids.Any()) { return new Dictionary(); } // TODO: PMJ 2021-09-27 - Should GetUrl(Udi) exist as an extension method on UrlProvider/IUrlProvider (in v9) - string MediaOrDocumentUrl(Udi udi) + string MediaOrDocumentUrl(Udi id) { - if (udi is not GuidUdi guidUdi) + if (id is not GuidUdi guidUdi) { return null; } - return guidUdi.EntityType switch + switch (type) { - Constants.UdiEntityType.Document => UmbracoContext.UrlProvider.GetUrl(guidUdi.Guid, culture: culture ?? ClientCulture()), - // NOTE: If culture is passed here we get an empty string rather than a media item URL WAT - Constants.UdiEntityType.Media => UmbracoContext.UrlProvider.GetMediaUrl(guidUdi.Guid, culture: null), - _ => null - }; + case UmbracoEntityTypes.Document: + return UmbracoContext.UrlProvider.GetUrl(guidUdi.Guid, culture: culture ?? ClientCulture()); + case UmbracoEntityTypes.Media: + { + var media = UmbracoContext.Media.GetById(id); + // NOTE: If culture is passed here we get an empty string rather than a media item URL. + return UmbracoContext.UrlProvider.GetMediaUrl(media, culture: null); + } + default: + return null; + } } - return udis - .Select(udi => new { - Udi = udi, - Url = MediaOrDocumentUrl(udi) - }).ToDictionary(x => x.Udi, x => x.Url); + return ids + .Distinct() + .Select(id => new { + Id = id, + Url = MediaOrDocumentUrl(id) + }).ToDictionary(x => x.Id, x => x.Url); } /// From 029a2614763504447bd379c3189a0626a1c1082c Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Wed, 19 Jan 2022 09:21:50 +0100 Subject: [PATCH 47/53] Improve functionality for external member logins (#11855) * Bugfix - Take ufprt from form data if the request has form content type, otherwise fallback to use the query * External linking for members * Changed migration to reuse old table * removed unnecessary web.config files * Cleanup * Extracted class to own file * Clean up * Rollback changes to Umbraco.Web.UI.csproj * Fixed migration for SqlCE * Change notification handler to be on deleted * Update src/Umbraco.Infrastructure/Security/MemberUserStore.cs Co-authored-by: Mole * Fixed issue with errors not shown on member linking * fixed issue with errors * clean up * Fix issue where external logins could not be used to upgrade Umbraco, because the externalLogin table was expected to look different. (Like after the migration) * Fixed issue in Ignore legacy column now using result column. Co-authored-by: Mole --- src/Umbraco.Core/Constants-Security.cs | 1 + .../Extensions/ClaimsIdentityExtensions.cs | 13 + .../Repositories/IExternalLoginRepository.cs | 7 +- .../IExternalLoginWithKeyRepository.cs | 28 ++ .../Security/IIdentityUserLogin.cs | 2 +- .../Services/IExternalLoginService.cs | 2 + .../Services/IExternalLoginWithKeyService.cs | 54 +++ .../UmbracoBuilder.Repositories.cs | 5 +- .../UmbracoBuilder.Services.cs | 12 +- .../Migrations/Upgrade/UmbracoPlan.cs | 6 + .../UpdateExternalLoginToUseKeyInsteadOfId.cs | 77 +++++ .../Persistence/Dtos/ExternalLoginDto.cs | 12 +- .../Factories/ExternalLoginFactory.cs | 16 +- .../Mappers/ExternalLoginMapper.cs | 3 +- .../Mappers/ExternalLoginTokenMapper.cs | 2 +- .../Implement/ExternalLoginRepository.cs | 38 ++- .../Security/BackOfficeUserStore.cs | 45 ++- ...eteExternalLoginsOnMemberDeletedHandler.cs | 30 ++ .../Security/MemberUserStore.cs | 105 ++++-- .../Security/UmbracoUserStore.cs | 6 + .../Implement/ExternalLoginService.cs | 80 +++-- .../UmbracoBuilder.BackOfficeIdentity.cs | 17 +- .../BackOfficeAuthenticationBuilder.cs | 5 + .../Security/ExternalSignInAutoLinkOptions.cs | 20 +- .../UmbracoBuilder.MembersIdentity.cs | 25 +- .../Extensions/IdentityBuilderExtensions.cs | 9 + .../Security/IMemberExternalLoginProviders.cs | 26 ++ .../Security/IMemberSignInManager.cs | 1 + .../IMemberSignInManagerExternalLogins.cs | 16 + .../Security/MemberExternalLoginProvider.cs | 36 ++ .../MemberExternalLoginProviderOptions.cs | 26 ++ .../MemberExternalLoginProviderScheme.cs | 20 ++ .../Security/MemberExternalLoginProviders.cs | 64 ++++ .../MemberExternalSignInAutoLinkOptions.cs | 76 +++++ .../Security/MemberSignInManager.cs | 307 +++++++++++++++++- .../src/assets/fonts/web.config | 8 - .../src/views/common/overlays/user/user.html | 2 +- src/Umbraco.Web.UI.Client/src/web.config | 8 - src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 2 - .../Templates/EditProfile.cshtml | 58 +++- .../PartialViewMacros/Templates/Login.cshtml | 77 +++-- .../Controllers/UmbExternalLoginController.cs | 279 ++++++++++++++++ .../UmbracoBuilder.MemberIdentity.cs | 23 ++ .../UmbracoBuilderExtensions.cs | 2 +- .../Models/ProfileModel.cs | 4 + .../Models/ProfileModelBuilder.cs | 5 +- .../Security/MemberAuthenticationBuilder.cs | 75 +++++ .../Security/MemberExternalLoginsBuilder.cs | 34 ++ .../Security/BackOfficeUserStoreTests.cs | 2 +- .../Services/ExternalLoginServiceTests.cs | 54 +-- .../Security/MemberManagerTests.cs | 2 +- .../Security/MemberUserStoreTests.cs | 4 +- .../Security/MemberSignInManagerTests.cs | 16 +- 53 files changed, 1657 insertions(+), 190 deletions(-) create mode 100644 src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs create mode 100644 src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/UpdateExternalLoginToUseKeyInsteadOfId.cs create mode 100644 src/Umbraco.Infrastructure/Security/DeleteExternalLoginsOnMemberDeletedHandler.cs create mode 100644 src/Umbraco.Web.Common/Security/IMemberExternalLoginProviders.cs create mode 100644 src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs create mode 100644 src/Umbraco.Web.Common/Security/MemberExternalLoginProvider.cs create mode 100644 src/Umbraco.Web.Common/Security/MemberExternalLoginProviderOptions.cs create mode 100644 src/Umbraco.Web.Common/Security/MemberExternalLoginProviderScheme.cs create mode 100644 src/Umbraco.Web.Common/Security/MemberExternalLoginProviders.cs create mode 100644 src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs delete mode 100644 src/Umbraco.Web.UI.Client/src/assets/fonts/web.config delete mode 100644 src/Umbraco.Web.UI.Client/src/web.config create mode 100644 src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs create mode 100644 src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilder.MemberIdentity.cs create mode 100644 src/Umbraco.Web.Website/Security/MemberAuthenticationBuilder.cs create mode 100644 src/Umbraco.Web.Website/Security/MemberExternalLoginsBuilder.cs diff --git a/src/Umbraco.Core/Constants-Security.cs b/src/Umbraco.Core/Constants-Security.cs index b509c12ff5..68601a78b0 100644 --- a/src/Umbraco.Core/Constants-Security.cs +++ b/src/Umbraco.Core/Constants-Security.cs @@ -50,6 +50,7 @@ namespace Umbraco.Cms.Core /// providers need to be setup differently and each auth type for the back office will be prefixed with this value /// public const string BackOfficeExternalAuthenticationTypePrefix = "Umbraco."; + public const string MemberExternalAuthenticationTypePrefix = "UmbracoMembers."; public const string StartContentNodeIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/startcontentnode"; public const string StartMediaNodeIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/startmedianode"; diff --git a/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs b/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs index 9b3674b07b..bceddf1fd6 100644 --- a/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs +++ b/src/Umbraco.Core/Extensions/ClaimsIdentityExtensions.cs @@ -62,6 +62,19 @@ namespace Umbraco.Extensions return username; } + public static string GetEmail(this IIdentity identity) + { + if (identity == null) throw new ArgumentNullException(nameof(identity)); + + string email = null; + if (identity is ClaimsIdentity claimsIdentity) + { + email = claimsIdentity.FindFirstValue(ClaimTypes.Email); + } + + return email; + } + /// /// Returns the first claim value found in the for the given claimType /// diff --git a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs index a685ab67f1..7d9594a3c6 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs @@ -1,15 +1,19 @@ +using System; using System.Collections.Generic; using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Core.Persistence.Repositories { + public interface IExternalLoginRepository : IReadWriteQueryRepository, IQueryRepository { + /// /// Replaces all external login providers for the user /// /// /// + [Obsolete("Use method that takes guid as param from IExternalLoginWithKeyRepository")] void Save(int userId, IEnumerable logins); /// @@ -17,8 +21,9 @@ namespace Umbraco.Cms.Core.Persistence.Repositories /// /// /// + [Obsolete("Use method that takes guid as param from IExternalLoginWithKeyRepository")] void Save(int userId, IEnumerable tokens); - + [Obsolete("Use method that takes guid as param from IExternalLoginWithKeyRepository")] void DeleteUserLogins(int memberId); } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs new file mode 100644 index 0000000000..0a4b9e76cf --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Persistence.Repositories +{ + + /// + /// Repository for external logins with Guid as key, so it can be shared for members and users + /// + public interface IExternalLoginWithKeyRepository : IReadWriteQueryRepository, IQueryRepository + { + /// + /// Replaces all external login providers for the user/member key + /// + void Save(Guid userOrMemberKey, IEnumerable logins); + + /// + /// Replaces all external login provider tokens for the providers specified for the user/member key + /// + void Save(Guid userOrMemberKey, IEnumerable tokens); + + /// + /// Deletes all external logins for the specified the user/member key + /// + void DeleteUserLogins(Guid userOrMemberKey); + } +} diff --git a/src/Umbraco.Core/Security/IIdentityUserLogin.cs b/src/Umbraco.Core/Security/IIdentityUserLogin.cs index 67ca739509..4e18771a17 100644 --- a/src/Umbraco.Core/Security/IIdentityUserLogin.cs +++ b/src/Umbraco.Core/Security/IIdentityUserLogin.cs @@ -19,7 +19,7 @@ namespace Umbraco.Cms.Core.Security string ProviderKey { get; set; } /// - /// Gets or sets user Id for the user who owns this login + /// Gets or sets user or member key (Guid) for the user/member who owns this login /// string UserId { get; set; } // TODO: This should be able to be used by both users and members diff --git a/src/Umbraco.Core/Services/IExternalLoginService.cs b/src/Umbraco.Core/Services/IExternalLoginService.cs index 787631d500..75f8069f0c 100644 --- a/src/Umbraco.Core/Services/IExternalLoginService.cs +++ b/src/Umbraco.Core/Services/IExternalLoginService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Umbraco.Cms.Core.Security; @@ -6,6 +7,7 @@ namespace Umbraco.Cms.Core.Services /// /// Used to store the external login info /// + [Obsolete("Use IExternalLoginServiceWithKey. This will be removed in Umbraco 10")] public interface IExternalLoginService : IService { /// diff --git a/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs b/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs new file mode 100644 index 0000000000..bc31f54f8b --- /dev/null +++ b/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Services +{ + public interface IExternalLoginWithKeyService : IService + { + /// + /// Returns all user logins assigned + /// + IEnumerable GetExternalLogins(Guid userOrMemberKey); + + /// + /// Returns all user login tokens assigned + /// + IEnumerable GetExternalLoginTokens(Guid userOrMemberKey); + + /// + /// Returns all logins matching the login info - generally there should only be one but in some cases + /// there might be more than one depending on if an administrator has been editing/removing members + /// + IEnumerable Find(string loginProvider, string providerKey); + + /// + /// Saves the external logins associated with the user + /// + /// + /// The user or member key associated with the logins + /// + /// + /// + /// This will replace all external login provider information for the user + /// + void Save(Guid userOrMemberKey, IEnumerable logins); + + /// + /// Saves the external login tokens associated with the user + /// + /// + /// The user or member key associated with the logins + /// + /// + /// + /// This will replace all external login tokens for the user + /// + void Save(Guid userOrMemberKey,IEnumerable tokens); + + /// + /// Deletes all user logins - normally used when a member is deleted + /// + void DeleteUserLogins(Guid userOrMemberKey); + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index d3ebb28f9c..ed2bf67e4a 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; @@ -29,7 +30,9 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); - builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(factory => factory.GetRequiredService()); + builder.Services.AddUnique(factory => factory.GetRequiredService()); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index 7d74ff13c8..c79cbf9d94 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -8,10 +8,13 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Implement; using Umbraco.Cms.Infrastructure.Packaging; @@ -64,7 +67,14 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); - builder.Services.AddUnique(); + builder.Services.AddUnique(factory => new ExternalLoginService( + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService() + )); + builder.Services.AddUnique(factory => factory.GetRequiredService()); + builder.Services.AddUnique(factory => factory.GetRequiredService()); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddTransient(SourcesFactory); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 2b6f2fe6d6..ac11666960 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -15,6 +15,7 @@ using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_8_9_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_1_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_2_0; +using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_3_0; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade @@ -269,6 +270,11 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade To("{0571C395-8F0B-44E9-8E3F-47BDD08D817B}"); To("{AD3D3B7F-8E74-45A4-85DB-7FFAD57F9243}"); To("{A2F22F17-5870-4179-8A8D-2362AA4A0A5F}"); + + + // TO 9.3.0 + To("{CA7A1D9D-C9D4-4914-BC0A-459E7B9C3C8C}"); + } } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/UpdateExternalLoginToUseKeyInsteadOfId.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/UpdateExternalLoginToUseKeyInsteadOfId.cs new file mode 100644 index 0000000000..4c7104e762 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/UpdateExternalLoginToUseKeyInsteadOfId.cs @@ -0,0 +1,77 @@ +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_3_0 +{ + public class UpdateExternalLoginToUseKeyInsteadOfId : MigrationBase + { + public UpdateExternalLoginToUseKeyInsteadOfId(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + if (!ColumnExists(ExternalLoginDto.TableName, "userOrMemberKey")) + { + var indexNameToRecreate = "IX_" + ExternalLoginDto.TableName + "_LoginProvider"; + var indexNameToDelete = "IX_" + ExternalLoginDto.TableName + "_userId"; + + if (IndexExists(indexNameToRecreate)) + { + // drop it since the previous migration index was wrong, and we + // need to modify a column that belons to it + Delete.Index(indexNameToRecreate).OnTable(ExternalLoginDto.TableName).Do(); + } + + if (IndexExists(indexNameToDelete)) + { + // drop it since the previous migration index was wrong, and we + // need to modify a column that belons to it + Delete.Index(indexNameToDelete).OnTable(ExternalLoginDto.TableName).Do(); + } + + //special trick to add the column without constraints and return the sql to add them later + AddColumn("userOrMemberKey", out var sqls); + + + if (DatabaseType.IsSqlCe()) + { + var userIds = Database.Fetch(Sql().Select("userId").From()); + + foreach (int userId in userIds) + { + Execute.Sql($"UPDATE {ExternalLoginDto.TableName} SET userOrMemberKey = '{userId.ToGuid()}' WHERE userId = {userId}").Do(); + } + } + else + { + //populate the new columns with the userId as a Guid. Same method as IntExtensions.ToGuid. + Execute.Sql($"UPDATE {ExternalLoginDto.TableName} SET userOrMemberKey = CAST(CONVERT(char(8), CONVERT(BINARY(4), userId), 2) + '-0000-0000-0000-000000000000' AS UNIQUEIDENTIFIER)").Do(); + + } + + //now apply constraints (NOT NULL) to new table + foreach (var sql in sqls) Execute.Sql(sql).Do(); + + //now remove these old columns + Delete.Column("userId").FromTable(ExternalLoginDto.TableName).Do(); + + // create index with the correct definition + Create + .Index(indexNameToRecreate) + .OnTable(ExternalLoginDto.TableName) + .OnColumn("loginProvider").Ascending() + .OnColumn("userOrMemberKey").Ascending() + .WithOptions() + .Unique() + .WithOptions() + .NonClustered() + .Do(); + } + } + + + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs index 69bf1b837e..0af1ff83c5 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs @@ -16,13 +16,13 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos [PrimaryKeyColumn] public int Id { get; set; } - // TODO: This is completely missing a FK!!? ... IIRC that is because we want to change this to a GUID - // to support both members and users for external logins and that will not have any referential integrity - // This should be part of the members task for enabling external logins. + [Obsolete("This only exists to ensure you can upgrade using external logins from umbraco version where this was used to the new where it is not used")] + [ResultColumn("userId")] + public int? UserId { get; set; } - [Column("userId")] + [Column("userOrMemberKey")] [Index(IndexTypes.NonClustered)] - public int UserId { get; set; } + public Guid UserOrMemberKey { get; set; } /// /// Used to store the name of the provider (i.e. Facebook, Google) @@ -30,7 +30,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos [Column("loginProvider")] [Length(400)] [NullSetting(NullSetting = NullSettings.NotNull)] - [Index(IndexTypes.UniqueNonClustered, ForColumns = "loginProvider,userId", Name = "IX_" + TableName + "_LoginProvider")] + [Index(IndexTypes.UniqueNonClustered, ForColumns = "loginProvider,userOrMemberKey", Name = "IX_" + TableName + "_LoginProvider")] public string LoginProvider { get; set; } /// diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs index 82bbb4a40a..1c74dcb8bd 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs @@ -2,6 +2,7 @@ using System; using System.Globalization; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.Factories { @@ -9,7 +10,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories { public static IIdentityUserToken BuildEntity(ExternalLoginTokenDto dto) { - var entity = new IdentityUserToken(dto.Id, dto.ExternalLoginDto.LoginProvider, dto.Name, dto.Value, dto.ExternalLoginDto.UserId.ToString(CultureInfo.InvariantCulture), dto.CreateDate); + var entity = new IdentityUserToken(dto.Id, dto.ExternalLoginDto.LoginProvider, dto.Name, dto.Value, dto.ExternalLoginDto.UserOrMemberKey.ToString(), dto.CreateDate); // reset dirty initial properties (U4-1946) entity.ResetDirtyProperties(false); @@ -18,7 +19,12 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories public static IIdentityUserLogin BuildEntity(ExternalLoginDto dto) { - var entity = new IdentityUserLogin(dto.Id, dto.LoginProvider, dto.ProviderKey, dto.UserId.ToString(CultureInfo.InvariantCulture), dto.CreateDate) + + //If there exists a UserId - this means the database is still not migrated. E.g on the upgrade state. + //At this point we have to manually set the key, to ensure external logins can be used to upgrade + var key = dto.UserId.HasValue ? dto.UserId.Value.ToGuid().ToString() : dto.UserOrMemberKey.ToString(); + + var entity = new IdentityUserLogin(dto.Id, dto.LoginProvider, dto.ProviderKey, key, dto.CreateDate) { UserData = dto.UserData }; @@ -36,19 +42,19 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories CreateDate = entity.CreateDate, LoginProvider = entity.LoginProvider, ProviderKey = entity.ProviderKey, - UserId = int.Parse(entity.UserId, CultureInfo.InvariantCulture), // TODO: This is temp until we change the ext logins to use GUIDs + UserOrMemberKey = entity.Key, UserData = entity.UserData }; return dto; } - public static ExternalLoginDto BuildDto(int userId, IExternalLogin entity, int? id = null) + public static ExternalLoginDto BuildDto(Guid userOrMemberKey, IExternalLogin entity, int? id = null) { var dto = new ExternalLoginDto { Id = id ?? default, - UserId = userId, + UserOrMemberKey = userOrMemberKey, LoginProvider = entity.LoginProvider, ProviderKey = entity.ProviderKey, UserData = entity.UserData, diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginMapper.cs index 2d47746baa..85db7bf553 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginMapper.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginMapper.cs @@ -18,7 +18,8 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Mappers DefineMap(nameof(IdentityUserLogin.CreateDate), nameof(ExternalLoginDto.CreateDate)); DefineMap(nameof(IdentityUserLogin.LoginProvider), nameof(ExternalLoginDto.LoginProvider)); DefineMap(nameof(IdentityUserLogin.ProviderKey), nameof(ExternalLoginDto.ProviderKey)); - DefineMap(nameof(IdentityUserLogin.UserId), nameof(ExternalLoginDto.UserId)); + DefineMap(nameof(IdentityUserLogin.Key), nameof(ExternalLoginDto.UserOrMemberKey)); + DefineMap(nameof(IdentityUserLogin.UserData), nameof(ExternalLoginDto.UserData)); } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginTokenMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginTokenMapper.cs index 4d03031ffd..ca8360c626 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginTokenMapper.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/ExternalLoginTokenMapper.cs @@ -19,7 +19,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Mappers DefineMap(nameof(IdentityUserToken.Name), nameof(ExternalLoginTokenDto.Name)); DefineMap(nameof(IdentityUserToken.Value), nameof(ExternalLoginTokenDto.Value)); // separate table - DefineMap(nameof(IdentityUserToken.UserId), nameof(ExternalLoginDto.UserId)); + DefineMap(nameof(IdentityUserLogin.Key), nameof(ExternalLoginDto.UserOrMemberKey)); } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs index 64200d3bbe..4bb6b8a4eb 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs @@ -6,7 +6,6 @@ using NPoco; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models.Entities; -using Umbraco.Cms.Core.Persistence; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; @@ -18,22 +17,34 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement { - // TODO: We should update this to support both users and members. It means we would remove referential integrity from users - // and the user/member key would be a GUID (we also need to add a GUID to users) - internal class ExternalLoginRepository : EntityRepositoryBase, IExternalLoginRepository + internal class ExternalLoginRepository : EntityRepositoryBase, IExternalLoginRepository, IExternalLoginWithKeyRepository { public ExternalLoginRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) : base(scopeAccessor, cache, logger) { } - public void DeleteUserLogins(int memberId) => Database.Delete("WHERE userId=@userId", new { userId = memberId }); + /// + [Obsolete("Use method that takes guid as param")] + public void DeleteUserLogins(int memberId) => DeleteUserLogins(memberId.ToGuid()); - public void Save(int userId, IEnumerable logins) + /// + [Obsolete("Use method that takes guid as param")] + public void Save(int userId, IEnumerable logins) => Save(userId.ToGuid(), logins); + + /// + [Obsolete("Use method that takes guid as param")] + public void Save(int userId, IEnumerable tokens) => Save(userId.ToGuid(), tokens); + + /// + public void DeleteUserLogins(Guid userOrMemberKey) => Database.Delete("WHERE userOrMemberKey=@userOrMemberKey", new { userOrMemberKey }); + + /// + public void Save(Guid userOrMemberKey, IEnumerable logins) { var sql = Sql() .Select() .From() - .Where(x => x.UserId == userId) + .Where(x => x.UserOrMemberKey == userOrMemberKey) .ForUpdate(); // deduplicate the logins @@ -71,10 +82,10 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement foreach (var u in toUpdate) { - Database.Update(ExternalLoginFactory.BuildDto(userId, u.Value, u.Key)); + Database.Update(ExternalLoginFactory.BuildDto(userOrMemberKey, u.Value, u.Key)); } - Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userId, i))); + Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userOrMemberKey, i))); } protected override IIdentityUserLogin PerformGet(int id) @@ -217,11 +228,12 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement return Database.ExecuteScalar(sql); } - public void Save(int userId, IEnumerable tokens) + /// + public void Save(Guid userOrMemberKey, IEnumerable tokens) { // get the existing logins (provider + id) var existingUserLogins = Database - .Fetch(GetBaseQuery(false).Where(x => x.UserId == userId)) + .Fetch(GetBaseQuery(false).Where(x => x.UserOrMemberKey == userOrMemberKey)) .ToDictionary(x => x.LoginProvider, x => x.Id); // deduplicate the tokens @@ -231,7 +243,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement Sql sql = GetBaseTokenQuery(true) .WhereIn(x => x.LoginProvider, providers) - .Where(x => x.UserId == userId); + .Where(x => x.UserOrMemberKey == userOrMemberKey); var toUpdate = new Dictionary(); var toDelete = new List(); @@ -289,7 +301,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .On(x => x.ExternalLoginId, x => x.Id) : Sql() .Select() - .AndSelect(x => x.LoginProvider, x => x.UserId) + .AndSelect(x => x.LoginProvider, x => x.UserOrMemberKey) .From() .InnerJoin() .On(x => x.ExternalLoginId, x => x.Id); diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 67e69c0788..32c0500a79 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -8,6 +8,7 @@ using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; @@ -16,6 +17,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Security @@ -29,7 +31,7 @@ namespace Umbraco.Cms.Core.Security private readonly IScopeProvider _scopeProvider; private readonly IUserService _userService; private readonly IEntityService _entityService; - private readonly IExternalLoginService _externalLoginService; + private readonly IExternalLoginWithKeyService _externalLoginService; private readonly GlobalSettings _globalSettings; private readonly IUmbracoMapper _mapper; private readonly AppCaches _appCaches; @@ -37,11 +39,12 @@ namespace Umbraco.Cms.Core.Security /// /// Initializes a new instance of the class. /// + [ActivatorUtilitiesConstructor] public BackOfficeUserStore( IScopeProvider scopeProvider, IUserService userService, IEntityService entityService, - IExternalLoginService externalLoginService, + IExternalLoginWithKeyService externalLoginService, IOptions globalSettings, IUmbracoMapper mapper, BackOfficeErrorDescriber describer, @@ -59,6 +62,29 @@ namespace Umbraco.Cms.Core.Security _externalLoginService = externalLoginService; } + [Obsolete("Use ctor injecting IExternalLoginWithKeyService ")] + public BackOfficeUserStore( + IScopeProvider scopeProvider, + IUserService userService, + IEntityService entityService, + IExternalLoginService externalLoginService, + IOptions globalSettings, + IUmbracoMapper mapper, + BackOfficeErrorDescriber describer, + AppCaches appCaches) + : this( + scopeProvider, + userService, + entityService, + StaticServiceProvider.Instance.GetRequiredService(), + globalSettings, + mapper, + describer, + appCaches) + { + + } + /// public override Task CreateAsync(BackOfficeIdentityUser user, CancellationToken cancellationToken = default) { @@ -104,7 +130,7 @@ namespace Umbraco.Cms.Core.Security if (isLoginsPropertyDirty) { _externalLoginService.Save( - userEntity.Id, + userEntity.Key, user.Logins.Select(x => new ExternalLogin( x.LoginProvider, x.ProviderKey, @@ -114,7 +140,7 @@ namespace Umbraco.Cms.Core.Security if (isTokensPropertyDirty) { _externalLoginService.Save( - userEntity.Id, + userEntity.Key, user.LoginTokens.Select(x => new ExternalLoginToken( x.LoginProvider, x.Name, @@ -156,7 +182,7 @@ namespace Umbraco.Cms.Core.Security if (isLoginsPropertyDirty) { _externalLoginService.Save( - found.Id, + found.Key, user.Logins.Select(x => new ExternalLogin( x.LoginProvider, x.ProviderKey, @@ -166,7 +192,7 @@ namespace Umbraco.Cms.Core.Security if (isTokensPropertyDirty) { _externalLoginService.Save( - found.Id, + found.Key, user.LoginTokens.Select(x => new ExternalLoginToken( x.LoginProvider, x.Name, @@ -190,13 +216,14 @@ namespace Umbraco.Cms.Core.Security throw new ArgumentNullException(nameof(user)); } - IUser found = _userService.GetUserById(UserIdToInt(user.Id)); + var userId = UserIdToInt(user.Id); + IUser found = _userService.GetUserById(userId); if (found != null) { _userService.Delete(found); } - _externalLoginService.DeleteUserLogins(UserIdToInt(user.Id)); + _externalLoginService.DeleteUserLogins(userId.ToGuid()); return Task.FromResult(IdentityResult.Success); } @@ -414,7 +441,7 @@ namespace Umbraco.Cms.Core.Security { if (user != null) { - var userId = UserIdToInt(user.Id); + var userId = UserIdToInt(user.Id).ToGuid(); user.SetLoginsCallback(new Lazy>(() => _externalLoginService.GetExternalLogins(userId))); user.SetTokensCallback(new Lazy>(() => _externalLoginService.GetExternalLoginTokens(userId))); } diff --git a/src/Umbraco.Infrastructure/Security/DeleteExternalLoginsOnMemberDeletedHandler.cs b/src/Umbraco.Infrastructure/Security/DeleteExternalLoginsOnMemberDeletedHandler.cs new file mode 100644 index 0000000000..b2b829d8e1 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/DeleteExternalLoginsOnMemberDeletedHandler.cs @@ -0,0 +1,30 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Security +{ + /// + /// Deletes the external logins for the deleted members. This cannot be handled by the database as there is not foreign keys. + /// + public class DeleteExternalLoginsOnMemberDeletedHandler : INotificationHandler + { + private readonly IExternalLoginWithKeyService _externalLoginWithKeyService; + + /// + /// Initializes a new instance of the class. + /// + public DeleteExternalLoginsOnMemberDeletedHandler(IExternalLoginWithKeyService externalLoginWithKeyService) + => _externalLoginWithKeyService = externalLoginWithKeyService; + + /// + public void Handle(MemberDeletedNotification notification) + { + foreach (IMember member in notification.DeletedEntities) + { + _externalLoginWithKeyService.DeleteUserLogins(member.Key); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index d757cfb088..da45e4d888 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -6,12 +6,14 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Security @@ -26,6 +28,7 @@ namespace Umbraco.Cms.Core.Security private readonly IUmbracoMapper _mapper; private readonly IScopeProvider _scopeProvider; private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IExternalLoginWithKeyService _externalLoginService; /// /// Initializes a new instance of the class for the members identity store @@ -34,18 +37,48 @@ namespace Umbraco.Cms.Core.Security /// The mapper for properties /// The scope provider /// The error describer + /// The external login service + [ActivatorUtilitiesConstructor] public MemberUserStore( IMemberService memberService, IUmbracoMapper mapper, IScopeProvider scopeProvider, IdentityErrorDescriber describer, - IPublishedSnapshotAccessor publishedSnapshotAccessor) + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IExternalLoginWithKeyService externalLoginService + ) : base(describer) { _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); _scopeProvider = scopeProvider ?? throw new ArgumentNullException(nameof(scopeProvider)); _publishedSnapshotAccessor = publishedSnapshotAccessor; + _externalLoginService = externalLoginService; + } + + [Obsolete("Use ctor with IExternalLoginWithKeyService param")] + public MemberUserStore( + IMemberService memberService, + IUmbracoMapper mapper, + IScopeProvider scopeProvider, + IdentityErrorDescriber describer, + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IExternalLoginService externalLoginService) + : this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService()) + { + + } + + [Obsolete("Use ctor with IExternalLoginWithKeyService param")] + public MemberUserStore( + IMemberService memberService, + IUmbracoMapper mapper, + IScopeProvider scopeProvider, + IdentityErrorDescriber describer, + IPublishedSnapshotAccessor publishedSnapshotAccessor) + : this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService()) + { + } /// @@ -83,18 +116,29 @@ namespace Umbraco.Cms.Core.Security user.Id = UserIdToString(memberEntity.Id); user.Key = memberEntity.Key; - // [from backofficeuser] we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. - // var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MembersIdentityUser.Logins)); - // TODO: confirm re externallogins implementation - //if (isLoginsPropertyDirty) - //{ - // _externalLoginService.Save( - // user.Id, - // user.Logins.Select(x => new ExternalLogin( - // x.LoginProvider, - // x.ProviderKey, - // x.UserData))); - //} + // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. + var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins)); + var isTokensPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.LoginTokens)); + + if (isLoginsPropertyDirty) + { + _externalLoginService.Save( + memberEntity.Key, + user.Logins.Select(x => new ExternalLogin( + x.LoginProvider, + x.ProviderKey, + x.UserData))); + } + + if (isTokensPropertyDirty) + { + _externalLoginService.Save( + memberEntity.Key, + user.LoginTokens.Select(x => new ExternalLoginToken( + x.LoginProvider, + x.Name, + x.Value))); + } return Task.FromResult(IdentityResult.Success); @@ -142,17 +186,15 @@ namespace Umbraco.Cms.Core.Security _memberService.SetLastLogin(found.Username, DateTime.Now); } - // TODO: when to implement external login service? - - //if (isLoginsPropertyDirty) - //{ - // _externalLoginService.Save( - // found.Id, - // user.Logins.Select(x => new ExternalLogin( - // x.LoginProvider, - // x.ProviderKey, - // x.UserData))); - //} + if (isLoginsPropertyDirty) + { + _externalLoginService.Save( + found.Key, + user.Logins.Select(x => new ExternalLogin( + x.LoginProvider, + x.ProviderKey, + x.UserData))); + } } return Task.FromResult(IdentityResult.Success); @@ -181,8 +223,7 @@ namespace Umbraco.Cms.Core.Security _memberService.Delete(found); } - // TODO: when to implement external login service? - //_externalLoginService.DeleteUserLogins(UserIdToInt(user.Id)); + _externalLoginService.DeleteUserLogins(user.Key); return Task.FromResult(IdentityResult.Success); } @@ -203,7 +244,8 @@ namespace Umbraco.Cms.Core.Security throw new ArgumentNullException(nameof(userId)); } - IMember user = _memberService.GetById(UserIdToInt(userId)); + + IMember user = Guid.TryParse(userId, out var key) ? _memberService.GetByKey(key) : _memberService.GetById(UserIdToInt(userId)); if (user == null) { return Task.FromResult((MemberIdentityUser)null); @@ -375,10 +417,7 @@ namespace Umbraco.Cms.Core.Security throw new ArgumentNullException(nameof(providerKey)); } - var logins = new List(); - - // TODO: external login needed - //_externalLoginService.Find(loginProvider, providerKey).ToList(); + var logins = _externalLoginService.Find(loginProvider, providerKey).ToList(); if (logins.Count == 0) { return Task.FromResult((IdentityUserLogin)null); @@ -492,8 +531,8 @@ namespace Umbraco.Cms.Core.Security { if (user != null) { - //TODO: implement - //user.SetLoginsCallback(new Lazy>(() => _externalLoginService.GetAll(UserIdToInt(user.Id)))); + user.SetLoginsCallback(new Lazy>(() => _externalLoginService.GetExternalLogins(user.Key))); + user.SetTokensCallback(new Lazy>(() => _externalLoginService.GetExternalLoginTokens(user.Key))); } return user; diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs index 6a2325c316..aaaaed55e7 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs @@ -34,6 +34,12 @@ namespace Umbraco.Cms.Core.Security return result; } + if(Guid.TryParse(userId, out var key)) + { + // Reverse the IntExtensions.ToGuid + return BitConverter.ToInt32(key.ToByteArray(), 0); + } + throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); } diff --git a/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs b/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs index 079971be24..589ac288c8 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs @@ -1,44 +1,77 @@ +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.DependencyInjection; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services.Implement { - public class ExternalLoginService : RepositoryService, IExternalLoginService + public class ExternalLoginService : RepositoryService, IExternalLoginService, IExternalLoginWithKeyService { - private readonly IExternalLoginRepository _externalLoginRepository; + private readonly IExternalLoginWithKeyRepository _externalLoginRepository; public ExternalLoginService(IScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, - IExternalLoginRepository externalLoginRepository) + IExternalLoginWithKeyRepository externalLoginRepository) : base(provider, loggerFactory, eventMessagesFactory) { _externalLoginRepository = externalLoginRepository; } + [Obsolete("Use ctor injecting IExternalLoginWithKeyRepository")] + public ExternalLoginService(IScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, + IExternalLoginRepository externalLoginRepository) + : this(provider, loggerFactory, eventMessagesFactory, StaticServiceProvider.Instance.GetRequiredService()) + { + } + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] public IEnumerable GetExternalLogins(int userId) + => GetExternalLogins(userId.ToGuid()); + + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] + public IEnumerable GetExternalLoginTokens(int userId) => + GetExternalLoginTokens(userId.ToGuid()); + + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] + public void Save(int userId, IEnumerable logins) + => Save(userId.ToGuid(), logins); + + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] + public void Save(int userId, IEnumerable tokens) + => Save(userId.ToGuid(), tokens); + + /// + [Obsolete("Use overload that takes a user/member key (Guid).")] + public void DeleteUserLogins(int userId) + => DeleteUserLogins(userId.ToGuid()); + + /// + public IEnumerable GetExternalLogins(Guid userOrMemberKey) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { - // TODO: This is temp until we update the external service to support guids for both users and members - var asString = userId.ToString(CultureInfo.InvariantCulture); - return _externalLoginRepository.Get(Query().Where(x => x.UserId == asString)) + return _externalLoginRepository.Get(Query().Where(x => x.Key == userOrMemberKey)) .ToList(); } } - public IEnumerable GetExternalLoginTokens(int userId) + /// + public IEnumerable GetExternalLoginTokens(Guid userOrMemberKey) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { - // TODO: This is temp until we update the external service to support guids for both users and members - var asString = userId.ToString(CultureInfo.InvariantCulture); - return _externalLoginRepository.Get(Query().Where(x => x.UserId == asString)) + return _externalLoginRepository.Get(Query().Where(x => x.Key == userOrMemberKey)) .ToList(); } } @@ -55,30 +88,31 @@ namespace Umbraco.Cms.Core.Services.Implement } /// - public void Save(int userId, IEnumerable logins) + public void Save(Guid userOrMemberKey, IEnumerable logins) { using (var scope = ScopeProvider.CreateScope()) { - _externalLoginRepository.Save(userId, logins); - scope.Complete(); - } - } - - public void Save(int userId, IEnumerable tokens) - { - using (var scope = ScopeProvider.CreateScope()) - { - _externalLoginRepository.Save(userId, tokens); + _externalLoginRepository.Save(userOrMemberKey, logins); scope.Complete(); } } /// - public void DeleteUserLogins(int userId) + public void Save(Guid userOrMemberKey, IEnumerable tokens) { using (var scope = ScopeProvider.CreateScope()) { - _externalLoginRepository.DeleteUserLogins(userId); + _externalLoginRepository.Save(userOrMemberKey, tokens); + scope.Complete(); + } + } + + /// + public void DeleteUserLogins(Guid userOrMemberKey) + { + using (var scope = ScopeProvider.CreateScope()) + { + _externalLoginRepository.DeleteUserLogins(userOrMemberKey); scope.Complete(); } } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index 4e95236b5f..e9cc213598 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -2,9 +2,15 @@ using System; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Net; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.AspNetCore; using Umbraco.Cms.Web.Common.Security; @@ -27,7 +33,16 @@ namespace Umbraco.Extensions builder.BuildUmbracoBackOfficeIdentity() .AddDefaultTokenProviders() - .AddUserStore() + .AddUserStore, BackOfficeUserStore>(factory => new BackOfficeUserStore( + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService>(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService() + )) .AddUserManager() .AddSignInManager() .AddClaimsPrincipalFactory() diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs index 80a9d920a1..d62edcc1f9 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs @@ -62,6 +62,11 @@ namespace Umbraco.Cms.Web.BackOffice.Security { public void PostConfigure(string name, TOptions options) { + if (!name.StartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix)) + { + return; + } + options.SignInScheme = Constants.Security.BackOfficeExternalAuthenticationType; } } diff --git a/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs index 504c74d90e..1f5a7fad33 100644 --- a/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs @@ -3,6 +3,7 @@ using System.Runtime.Serialization; using Microsoft.AspNetCore.Identity; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.Security; using SecurityConstants = Umbraco.Cms.Core.Constants.Security; namespace Umbraco.Cms.Web.BackOffice.Security @@ -13,11 +14,12 @@ namespace Umbraco.Cms.Web.BackOffice.Security public class ExternalSignInAutoLinkOptions { /// - /// Creates a new instance + /// Initializes a new instance of the class. /// /// /// If null, the default will be the 'editor' group /// + /// public ExternalSignInAutoLinkOptions( bool autoLinkExternalAccount = false, string[] defaultUserGroups = null, @@ -30,12 +32,6 @@ namespace Umbraco.Cms.Web.BackOffice.Security _defaultCulture = defaultCulture; } - /// - /// By default this is true which allows the user to manually link and unlink the external provider, if set to false the back office user - /// will not see and cannot perform manual linking or unlinking of the external provider. - /// - public bool AllowManualLinking { get; } - /// /// A callback executed during account auto-linking and before the user is persisted /// @@ -50,10 +46,16 @@ namespace Umbraco.Cms.Web.BackOffice.Security public Func OnExternalLogin { get; set; } /// - /// Flag indicating if logging in with the external provider should auto-link/create a local user + /// Gets a value indicating whether flag indicating if logging in with the external provider should auto-link/create a local user /// public bool AutoLinkExternalAccount { get; } + /// + /// By default this is true which allows the user to manually link and unlink the external provider, if set to false the back office user + /// will not see and cannot perform manual linking or unlinking of the external provider. + /// + public bool AllowManualLinking { get; protected set; } + /// /// The default user groups to assign to the created local user linked /// @@ -64,7 +66,7 @@ namespace Umbraco.Cms.Web.BackOffice.Security /// /// The default Culture to use for auto-linking users /// - // TODO: Should we use IDefaultCultureAccessor here intead? + // TODO: Should we use IDefaultCultureAccessor here instead? public string GetUserAutoLinkCulture(GlobalSettings globalSettings) => _defaultCulture ?? globalSettings.DefaultUILanguage; } } diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs index c6856f8f19..66badc479e 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs @@ -1,7 +1,18 @@ +using System; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Security; using Umbraco.Cms.Web.Common.Security; namespace Umbraco.Extensions @@ -34,14 +45,24 @@ namespace Umbraco.Extensions services.AddIdentity() .AddDefaultTokenProviders() - .AddUserStore() + .AddUserStore, MemberUserStore>(factory => new MemberUserStore( + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService() + )) .AddRoleStore() .AddRoleManager() .AddMemberManager() .AddSignInManager() + .AddSignInManager() .AddErrorDescriber() .AddUserConfirmation>(); + + builder.AddNotificationHandler(); services.ConfigureOptions(); services.AddScoped(x => (IMemberUserStore)x.GetRequiredService>()); @@ -50,6 +71,8 @@ namespace Umbraco.Extensions services.ConfigureOptions(); services.ConfigureOptions(); + services.AddUnique(); + return builder; } } diff --git a/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs b/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs index 77b9f6c8dd..f1d2ac4a3d 100644 --- a/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; @@ -50,5 +51,13 @@ namespace Umbraco.Extensions identityBuilder.Services.AddScoped(typeof(TInterface), typeof(TSignInManager)); return identityBuilder; } + + + public static IdentityBuilder AddUserStore(this IdentityBuilder identityBuilder, Func implementationFactory) + where TStore : class, TInterface + { + identityBuilder.Services.AddScoped(typeof(TInterface), implementationFactory); + return identityBuilder; + } } } diff --git a/src/Umbraco.Web.Common/Security/IMemberExternalLoginProviders.cs b/src/Umbraco.Web.Common/Security/IMemberExternalLoginProviders.cs new file mode 100644 index 0000000000..b3d6813c2f --- /dev/null +++ b/src/Umbraco.Web.Common/Security/IMemberExternalLoginProviders.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Umbraco.Cms.Web.Common.Security +{ + + /// + /// Service to return instances + /// + public interface IMemberExternalLoginProviders + { + /// + /// Get the for the specified scheme + /// + /// + /// + Task GetAsync(string authenticationType); + + /// + /// Get all registered + /// + /// + Task> GetMemberProvidersAsync(); + } + +} diff --git a/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs b/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs index cc6b0e88b9..4ba5caca9b 100644 --- a/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Umbraco.Cms.Core.Security; + namespace Umbraco.Cms.Web.Common.Security { public interface IMemberSignInManager diff --git a/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs b/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs new file mode 100644 index 0000000000..3599a028f4 --- /dev/null +++ b/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; + +namespace Umbraco.Cms.Web.Common.Security +{ + [Obsolete("This interface will be merged with IMemberSignInManager in Umbraco 10")] + public interface IMemberSignInManagerExternalLogins : IMemberSignInManager + { + AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string redirectUrl, string userId = null); + Task GetExternalLoginInfoAsync(string expectedXsrf = null); + Task UpdateExternalAuthenticationTokensAsync(ExternalLoginInfo externalLogin); + Task ExternalLoginSignInAsync(ExternalLoginInfo loginInfo, bool isPersistent, bool bypassTwoFactor = false); + } +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalLoginProvider.cs b/src/Umbraco.Web.Common/Security/MemberExternalLoginProvider.cs new file mode 100644 index 0000000000..9681d47413 --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalLoginProvider.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.Extensions.Options; + +namespace Umbraco.Cms.Web.Common.Security +{ + /// + /// An external login (OAuth) provider for the members + /// + public class MemberExternalLoginProvider : IEquatable + { + public MemberExternalLoginProvider( + string authenticationType, + IOptionsMonitor properties) + { + if (properties is null) + { + throw new ArgumentNullException(nameof(properties)); + } + + AuthenticationType = authenticationType ?? throw new ArgumentNullException(nameof(authenticationType)); + Options = properties.Get(authenticationType); + } + + /// + /// The authentication "Scheme" + /// + public string AuthenticationType { get; } + + public MemberExternalLoginProviderOptions Options { get; } + + public override bool Equals(object obj) => Equals(obj as MemberExternalLoginProvider); + public bool Equals(MemberExternalLoginProvider other) => other != null && AuthenticationType == other.AuthenticationType; + public override int GetHashCode() => HashCode.Combine(AuthenticationType); + } + +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderOptions.cs b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderOptions.cs new file mode 100644 index 0000000000..ea93a522da --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderOptions.cs @@ -0,0 +1,26 @@ +namespace Umbraco.Cms.Web.Common.Security +{ + /// + /// Options used to configure member external login providers + /// + public class MemberExternalLoginProviderOptions + { + public MemberExternalLoginProviderOptions( + MemberExternalSignInAutoLinkOptions autoLinkOptions = null, + bool autoRedirectLoginToExternalProvider = false, + string customBackOfficeView = null) + { + AutoLinkOptions = autoLinkOptions ?? new MemberExternalSignInAutoLinkOptions(); + } + + public MemberExternalLoginProviderOptions() + { + } + + /// + /// Options used to control how users can be auto-linked/created/updated based on the external login provider + /// + public MemberExternalSignInAutoLinkOptions AutoLinkOptions { get; set; } = new MemberExternalSignInAutoLinkOptions(); + + } +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderScheme.cs b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderScheme.cs new file mode 100644 index 0000000000..600405b638 --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviderScheme.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Authentication; + +namespace Umbraco.Cms.Web.Common.Security +{ + public class MemberExternalLoginProviderScheme + { + public MemberExternalLoginProviderScheme( + MemberExternalLoginProvider externalLoginProvider, + AuthenticationScheme authenticationScheme) + { + ExternalLoginProvider = externalLoginProvider ?? throw new ArgumentNullException(nameof(externalLoginProvider)); + AuthenticationScheme = authenticationScheme ?? throw new ArgumentNullException(nameof(authenticationScheme)); + } + + public MemberExternalLoginProvider ExternalLoginProvider { get; } + public AuthenticationScheme AuthenticationScheme { get; } + } + +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalLoginProviders.cs b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviders.cs new file mode 100644 index 0000000000..28102c434f --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalLoginProviders.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Common.Security +{ + + /// + public class MemberExternalLoginProviders : IMemberExternalLoginProviders + { + private readonly Dictionary _externalLogins; + private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; + + public MemberExternalLoginProviders( + IEnumerable externalLogins, + IAuthenticationSchemeProvider authenticationSchemeProvider) + { + _externalLogins = externalLogins.ToDictionary(x => x.AuthenticationType); + _authenticationSchemeProvider = authenticationSchemeProvider; + } + + /// + public async Task GetAsync(string authenticationType) + { + var schemaName = + authenticationType.EnsureStartsWith(Core.Constants.Security.MemberExternalAuthenticationTypePrefix); + + if (!_externalLogins.TryGetValue(schemaName, out MemberExternalLoginProvider provider)) + { + return null; + } + + // get the associated scheme + AuthenticationScheme associatedScheme = await _authenticationSchemeProvider.GetSchemeAsync(provider.AuthenticationType); + + if (associatedScheme == null) + { + throw new InvalidOperationException("No authentication scheme registered for " + provider.AuthenticationType); + } + + return new MemberExternalLoginProviderScheme(provider, associatedScheme); + } + + /// + public async Task> GetMemberProvidersAsync() + { + var providersWithSchemes = new List(); + foreach (MemberExternalLoginProvider login in _externalLogins.Values) + { + // get the associated scheme + AuthenticationScheme associatedScheme = await _authenticationSchemeProvider.GetSchemeAsync(login.AuthenticationType); + + providersWithSchemes.Add(new MemberExternalLoginProviderScheme(login, associatedScheme)); + } + + return providersWithSchemes; + } + + } + +} diff --git a/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs new file mode 100644 index 0000000000..42dcf6d56f --- /dev/null +++ b/src/Umbraco.Web.Common/Security/MemberExternalSignInAutoLinkOptions.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.Identity; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Security; +using SecurityConstants = Umbraco.Cms.Core.Constants.Security; + +namespace Umbraco.Cms.Web.Common.Security +{ + /// + /// Options used to configure auto-linking external OAuth providers + /// + public class MemberExternalSignInAutoLinkOptions + { + private readonly string _defaultCulture; + + /// + /// Initializes a new instance of the class. + /// + public MemberExternalSignInAutoLinkOptions( + bool autoLinkExternalAccount = false, + bool defaultIsApproved = true, + string defaultMemberTypeAlias = Core.Constants.Conventions.MemberTypes.DefaultAlias, + string defaultCulture = null, + IEnumerable defaultMemberGroups = null) + { + AutoLinkExternalAccount = autoLinkExternalAccount; + DefaultIsApproved = defaultIsApproved; + DefaultMemberTypeAlias = defaultMemberTypeAlias; + _defaultCulture = defaultCulture; + DefaultMemberGroups = defaultMemberGroups ?? Array.Empty(); + } + + /// + /// A callback executed during account auto-linking and before the user is persisted + /// + [IgnoreDataMember] + public Action OnAutoLinking { get; set; } + + /// + /// A callback executed during every time a user authenticates using an external login. + /// returns a boolean indicating if sign in should continue or not. + /// + [IgnoreDataMember] + public Func OnExternalLogin { get; set; } + + /// + /// Gets a value indicating whether flag indicating if logging in with the external provider should auto-link/create a + /// local user + /// + public bool AutoLinkExternalAccount { get; } + + /// + /// Gets the member type alias that auto linked members are created as + /// + public string DefaultMemberTypeAlias { get; } + + /// + /// Gets the IsApproved value for auto linked members. + /// + public bool DefaultIsApproved { get; } + + /// + /// Gets the default member groups to add the user in. + /// + public IEnumerable DefaultMemberGroups { get; } + + /// + /// The default Culture to use for auto-linking users + /// + // TODO: Should we use IDefaultCultureAccessor here instead? + public string GetUserAutoLinkCulture(GlobalSettings globalSettings) => + _defaultCulture ?? globalSettings.DefaultUILanguage; + } +} diff --git a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs index 40cc17667d..6407c4fac8 100644 --- a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs @@ -1,12 +1,17 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.DependencyInjection; +using Umbraco.Extensions; namespace Umbraco.Cms.Web.Common.Security { @@ -14,8 +19,25 @@ namespace Umbraco.Cms.Web.Common.Security /// /// The sign in manager for members /// - public class MemberSignInManager : UmbracoSignInManager, IMemberSignInManager + public class MemberSignInManager : UmbracoSignInManager, IMemberSignInManagerExternalLogins { + private readonly IMemberExternalLoginProviders _memberExternalLoginProviders; + + public MemberSignInManager( + UserManager memberManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IMemberExternalLoginProviders memberExternalLoginProviders) : + base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + { + _memberExternalLoginProviders = memberExternalLoginProviders; + } + + [Obsolete("Use ctor with all params")] public MemberSignInManager( UserManager memberManager, IHttpContextAccessor contextAccessor, @@ -24,7 +46,7 @@ namespace Umbraco.Cms.Web.Common.Security ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation) : - base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + this(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, StaticServiceProvider.Instance.GetRequiredService()) { } // use default scheme for members @@ -64,16 +86,289 @@ namespace Umbraco.Cms.Web.Common.Security => throw new NotImplementedException("Two factor is not yet implemented for members"); /// - public override Task GetExternalLoginInfoAsync(string expectedXsrf = null) - => throw new NotImplementedException("External login is not yet implemented for members"); + public override async Task GetExternalLoginInfoAsync(string expectedXsrf = null) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs#L422 + // to replace the auth scheme + + var auth = await Context.AuthenticateAsync(ExternalAuthenticationType); + var items = auth?.Properties?.Items; + if (auth?.Principal == null || items == null) + { + Logger.LogDebug(auth?.Failure ?? new NullReferenceException("Context.AuthenticateAsync(ExternalAuthenticationType) is null"), + "The external login authentication failed. No user Principal or authentication items was resolved."); + return null; + } + + if (!items.ContainsKey(UmbracoSignInMgrLoginProviderKey)) + { + throw new InvalidOperationException($"The external login authenticated successfully but the key {UmbracoSignInMgrLoginProviderKey} was not found in the authentication properties. Ensure you call SignInManager.ConfigureExternalAuthenticationProperties before issuing a ChallengeResult."); + } + + if (expectedXsrf != null) + { + if (!items.ContainsKey(UmbracoSignInMgrXsrfKey)) + { + return null; + } + var userId = items[UmbracoSignInMgrXsrfKey]; + if (userId != expectedXsrf) + { + return null; + } + } + + var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (providerKey == null || items[UmbracoSignInMgrLoginProviderKey] is not string provider) + { + return null; + } + + var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName ?? provider; + return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName) + { + AuthenticationTokens = auth.Properties.GetTokens(), + AuthenticationProperties = auth.Properties + }; + } + + /// + /// Custom ExternalLoginSignInAsync overload for handling external sign in with auto-linking + /// + public async Task ExternalLoginSignInAsync(ExternalLoginInfo loginInfo, bool isPersistent, bool bypassTwoFactor = false) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // to be able to deal with auto-linking and reduce duplicate lookups + + var autoLinkOptions = (await _memberExternalLoginProviders.GetAsync(loginInfo.LoginProvider))?.ExternalLoginProvider?.Options?.AutoLinkOptions; + var user = await UserManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); + if (user == null) + { + // user doesn't exist so see if we can auto link + return await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions); + } + + if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null) + { + var shouldSignIn = autoLinkOptions.OnExternalLogin(user, loginInfo); + if (shouldSignIn == false) + { + LogFailedExternalLogin(loginInfo, user); + return ExternalLoginSignInResult.NotAllowed; + } + } + + var error = await PreSignInCheck(user); + if (error != null) + { + return error; + } + return await SignInOrTwoFactorAsync(user, isPersistent, loginInfo.LoginProvider, bypassTwoFactor); + } + + + /// + /// Used for auto linking/creating user accounts for external logins + /// + /// + /// + /// + private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, MemberExternalSignInAutoLinkOptions autoLinkOptions) + { + // If there are no autolink options then the attempt is failed (user does not exist) + if (autoLinkOptions == null || !autoLinkOptions.AutoLinkExternalAccount) + { + return SignInResult.Failed; + } + + var email = loginInfo.Principal.FindFirstValue(ClaimTypes.Email); + + //we are allowing auto-linking/creating of local accounts + if (email.IsNullOrWhiteSpace()) + { + return AutoLinkSignInResult.FailedNoEmail; + } + else + { + //Now we need to perform the auto-link, so first we need to lookup/create a user with the email address + var autoLinkUser = await UserManager.FindByEmailAsync(email); + if (autoLinkUser != null) + { + try + { + //call the callback if one is assigned + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); + return AutoLinkSignInResult.FailedException(ex.Message); + } + + var shouldLinkUser = autoLinkOptions.OnExternalLogin == null || autoLinkOptions.OnExternalLogin(autoLinkUser, loginInfo); + if (shouldLinkUser) + { + return await LinkUser(autoLinkUser, loginInfo); + } + else + { + LogFailedExternalLogin(loginInfo, autoLinkUser); + return ExternalLoginSignInResult.NotAllowed; + } + } + else + { + var name = loginInfo.Principal?.Identity?.Name; + if (name.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Name value cannot be null"); + + autoLinkUser = MemberIdentityUser.CreateNew(email, email, autoLinkOptions.DefaultMemberTypeAlias, autoLinkOptions.DefaultIsApproved, name); + + foreach (var userGroup in autoLinkOptions.DefaultMemberGroups) + { + autoLinkUser.AddRole(userGroup); + } + + //call the callback if one is assigned + try + { + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); + return AutoLinkSignInResult.FailedException(ex.Message); + } + + var userCreationResult = await UserManager.CreateAsync(autoLinkUser); + + if (!userCreationResult.Succeeded) + { + return AutoLinkSignInResult.FailedCreatingUser(userCreationResult.Errors.Select(x => x.Description).ToList()); + } + else + { + var shouldLinkUser = autoLinkOptions.OnExternalLogin == null || autoLinkOptions.OnExternalLogin(autoLinkUser, loginInfo); + if (shouldLinkUser) + { + return await LinkUser(autoLinkUser, loginInfo); + } + else + { + LogFailedExternalLogin(loginInfo, autoLinkUser); + return ExternalLoginSignInResult.NotAllowed; + } + } + } + } + } + + // TODO in v10 we can share this with backoffice by moving the backoffice into common. + public class ExternalLoginSignInResult : SignInResult + { + public static ExternalLoginSignInResult NotAllowed { get; } = new ExternalLoginSignInResult() + { + Succeeded = false + }; + } + // TODO in v10 we can share this with backoffice by moving the backoffice into common. + public class AutoLinkSignInResult : SignInResult + { + public static AutoLinkSignInResult FailedNotLinked { get; } = new AutoLinkSignInResult() + { + Succeeded = false + }; + + public static AutoLinkSignInResult FailedNoEmail { get; } = new AutoLinkSignInResult() + { + Succeeded = false + }; + + public static AutoLinkSignInResult FailedException(string error) => new AutoLinkSignInResult(new[] { error }) + { + Succeeded = false + }; + + public static AutoLinkSignInResult FailedCreatingUser(IReadOnlyCollection errors) => new AutoLinkSignInResult(errors) + { + Succeeded = false + }; + + public static AutoLinkSignInResult FailedLinkingUser(IReadOnlyCollection errors) => new AutoLinkSignInResult(errors) + { + Succeeded = false + }; + + public AutoLinkSignInResult(IReadOnlyCollection errors) + { + Errors = errors ?? throw new ArgumentNullException(nameof(errors)); + } + + public AutoLinkSignInResult() + { + } + + public IReadOnlyCollection Errors { get; } = Array.Empty(); + } /// public override AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string redirectUrl, string userId = null) - => throw new NotImplementedException("External login is not yet implemented for members"); + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // to be able to use our own XsrfKey/LoginProviderKey because the default is private :/ + + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + properties.Items[UmbracoSignInMgrLoginProviderKey] = provider; + if (userId != null) + { + properties.Items[UmbracoSignInMgrXsrfKey] = userId; + } + return properties; + } /// public override Task> GetExternalAuthenticationSchemesAsync() - => throw new NotImplementedException("External login is not yet implemented for members"); + { + // That can be done by either checking the scheme (maybe) or comparing it to what we have registered in the collection of BackOfficeExternalLoginProvider + return base.GetExternalAuthenticationSchemesAsync(); + } + + private async Task LinkUser(MemberIdentityUser autoLinkUser, ExternalLoginInfo loginInfo) + { + var existingLogins = await UserManager.GetLoginsAsync(autoLinkUser); + var exists = existingLogins.FirstOrDefault(x => x.LoginProvider == loginInfo.LoginProvider && x.ProviderKey == loginInfo.ProviderKey); + + // if it already exists (perhaps it was added in the AutoLink callbak) then we just continue + if (exists != null) + { + //sign in + return await SignInOrTwoFactorAsync(autoLinkUser, isPersistent: false, loginInfo.LoginProvider); + } + + var linkResult = await UserManager.AddLoginAsync(autoLinkUser, loginInfo); + if (linkResult.Succeeded) + { + //we're good! sign in + return await SignInOrTwoFactorAsync(autoLinkUser, isPersistent: false, loginInfo.LoginProvider); + } + + //If this fails, we should really delete the user since it will be in an inconsistent state! + var deleteResult = await UserManager.DeleteAsync(autoLinkUser); + if (deleteResult.Succeeded) + { + var errors = linkResult.Errors.Select(x => x.Description).ToList(); + return AutoLinkSignInResult.FailedLinkingUser(errors); + } + else + { + //DOH! ... this isn't good, combine all errors to be shown + var errors = linkResult.Errors.Concat(deleteResult.Errors).Select(x => x.Description).ToList(); + return AutoLinkSignInResult.FailedLinkingUser(errors); + } + } + + private void LogFailedExternalLogin(ExternalLoginInfo loginInfo, MemberIdentityUser user) => + Logger.LogWarning("The AutoLinkOptions of the external authentication provider '{LoginProvider}' have refused the login based on the OnExternalLogin method. Affected user id: '{UserId}'", loginInfo.LoginProvider, user.Id); + } } diff --git a/src/Umbraco.Web.UI.Client/src/assets/fonts/web.config b/src/Umbraco.Web.UI.Client/src/assets/fonts/web.config deleted file mode 100644 index 42051b6de2..0000000000 --- a/src/Umbraco.Web.UI.Client/src/assets/fonts/web.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html index fa85785868..24acef995e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html @@ -69,7 +69,7 @@ ng-click="unlink($event, login.authType, login.linkedProviderKey)" ng-if="login.linkedProviderKey != undefined" value="{{login.authType}}"> - + Un-link your {{login.caption}} account diff --git a/src/Umbraco.Web.UI.Client/src/web.config b/src/Umbraco.Web.UI.Client/src/web.config deleted file mode 100644 index 6d14a9bab7..0000000000 --- a/src/Umbraco.Web.UI.Client/src/web.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index ac13badfaa..69f812b6e6 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -102,8 +102,6 @@ - - diff --git a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml index 09954b3f8d..095c3c050d 100644 --- a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml +++ b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml @@ -1,11 +1,15 @@ @inherits Umbraco.Cms.Web.Common.Macros.PartialViewMacroPage + @using Umbraco.Cms.Core @using Umbraco.Cms.Core.Security +@using Umbraco.Cms.Core.Services +@using Umbraco.Cms.Web.Common.Security @using Umbraco.Cms.Web.Website.Controllers @using Umbraco.Cms.Web.Website.Models @using Umbraco.Extensions @inject MemberModelBuilderFactory memberModelBuilderFactory; - +@inject IMemberExternalLoginProviders memberExternalLoginProviders +@inject IExternalLoginWithKeyService externalLoginWithKeyService @{ // Build a profile model to edit var profileModel = await memberModelBuilderFactory @@ -17,6 +21,13 @@ .BuildForCurrentMemberAsync(); var success = TempData["FormSuccess"] != null; + + var loginProviders = await memberExternalLoginProviders.GetMemberProvidersAsync(); + var externalSignInError = ViewData.GetExternalSignInProviderErrors(); + + var currentExternalLogin = profileModel is null + ? new Dictionary() + : externalLoginWithKeyService.GetExternalLogins(profileModel.Key).ToDictionary(x=>x.LoginProvider, x=>x.ProviderKey); } @@ -70,5 +81,50 @@ } + + if (loginProviders.Any()) + { +
+

Link external accounts

+ + if (externalSignInError?.AuthenticationType is null && externalSignInError?.Errors.Any() == true) + { + @Html.DisplayFor(x => externalSignInError.Errors); + } + + @foreach (var login in loginProviders) + { + if (currentExternalLogin.TryGetValue(login.ExternalLoginProvider.AuthenticationType, out var providerKey)) + { + @using (Html.BeginUmbracoForm(nameof(UmbExternalLoginController.Disassociate))) + { + + + + if (externalSignInError?.AuthenticationType == login.ExternalLoginProvider.AuthenticationType) + { + @Html.DisplayFor(x => externalSignInError.Errors); + } + } + } + else + { + @using (Html.BeginUmbracoForm(nameof(UmbExternalLoginController.LinkLogin))) + { + + + if (externalSignInError?.AuthenticationType == login.ExternalLoginProvider.AuthenticationType) + { + @Html.DisplayFor(x => externalSignInError.Errors); + } + } + } + + } + } } } diff --git a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml index d3c389c78d..85b7f53c24 100644 --- a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml +++ b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml @@ -1,9 +1,9 @@ @inherits Umbraco.Cms.Web.Common.Macros.PartialViewMacroPage -@using Microsoft.AspNetCore.Http.Extensions @using Umbraco.Cms.Web.Common.Models +@using Umbraco.Cms.Web.Common.Security @using Umbraco.Cms.Web.Website.Controllers @using Umbraco.Extensions - +@inject IMemberExternalLoginProviders memberExternalLoginProviders @{ var loginModel = new LoginModel(); // You can modify this to redirect to a different URL instead of the current one @@ -19,27 +19,58 @@ @using (Html.BeginUmbracoForm( "HandleLogin", new { RedirectUrl = loginModel.RedirectUrl })) { -

Log in with a local account.

-
-
-
- - - -
-
- - - -
-
- - -
+

Log in with a local account.

+
+
+
+ + + +
+
+ + + +
+
+ + +
- + + + + } +@{ + var loginProviders = await memberExternalLoginProviders.GetMemberProvidersAsync(); + var externalSignInError = ViewData.GetExternalSignInProviderErrors(); + + if (loginProviders.Any()) + { +
+

Or using external providers

+ if (externalSignInError?.AuthenticationType is null && externalSignInError?.Errors.Any() == true) + { + @Html.DisplayFor(x => externalSignInError.Errors); + } + + @foreach (var login in await memberExternalLoginProviders.GetMemberProvidersAsync()) + { + + @using (Html.BeginUmbracoForm(nameof(UmbExternalLoginController.ExternalLogin))) + { + + + if (externalSignInError?.AuthenticationType == login.ExternalLoginProvider.AuthenticationType) + { + @Html.DisplayFor(x => externalSignInError.Errors); + } + } + } } - +} diff --git a/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs b/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs new file mode 100644 index 0000000000..2d5ec250e9 --- /dev/null +++ b/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs @@ -0,0 +1,279 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Web.Common.ActionsResults; +using Umbraco.Cms.Web.Common.Filters; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; +using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; + +namespace Umbraco.Cms.Web.Website.Controllers +{ + [UmbracoMemberAuthorize] + public class UmbExternalLoginController : SurfaceController + { + private readonly IMemberManager _memberManager; + private readonly IMemberSignInManagerExternalLogins _memberSignInManager; + + public UmbExternalLoginController( + IUmbracoContextAccessor umbracoContextAccessor, + IUmbracoDatabaseFactory databaseFactory, + ServiceContext services, + AppCaches appCaches, + IProfilingLogger profilingLogger, + IPublishedUrlProvider publishedUrlProvider, + IMemberSignInManagerExternalLogins memberSignInManager, + IMemberManager memberManager) + : base( + umbracoContextAccessor, + databaseFactory, + services, + appCaches, + profilingLogger, + publishedUrlProvider) + { + _memberSignInManager = memberSignInManager; + _memberManager = memberManager; + } + + /// + /// Endpoint used to redirect to a specific login provider. This endpoint is used from the Login Macro snippet. + /// + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public ActionResult ExternalLogin(string provider, string returnUrl = null) + { + if (returnUrl.IsNullOrWhiteSpace()) + { + returnUrl = Request.GetEncodedPathAndQuery(); + } + + var wrappedReturnUrl = + Url.SurfaceAction(nameof(ExternalLoginCallback), this.GetControllerName(), new { returnUrl }); + + AuthenticationProperties properties = + _memberSignInManager.ConfigureExternalAuthenticationProperties(provider, wrappedReturnUrl); + + return Challenge(properties, provider); + } + + /// + /// Endpoint used my the login provider to call back to our solution. + /// + [HttpGet] + [AllowAnonymous] + public async Task ExternalLoginCallback(string returnUrl) + { + var errors = new List(); + + ExternalLoginInfo loginInfo = await _memberSignInManager.GetExternalLoginInfoAsync(); + if (loginInfo is null) + { + errors.Add("Invalid response from the login provider"); + } + else + { + SignInResult result = await _memberSignInManager.ExternalLoginSignInAsync(loginInfo, false); + + if (result == SignInResult.Success) + { + // Update any authentication tokens if succeeded + await _memberSignInManager.UpdateExternalAuthenticationTokensAsync(loginInfo); + + return RedirectToLocal(returnUrl); + } + + if (result == SignInResult.TwoFactorRequired) + { + MemberIdentityUser attemptedUser = + await _memberManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); + if (attemptedUser == null) + { + return new ValidationErrorResult( + $"No local user found for the login provider {loginInfo.LoginProvider} - {loginInfo.ProviderKey}"); + } + + // create a with information to display a custom two factor send code view + var verifyResponse = + new ObjectResult(new { userId = attemptedUser.Id }) + { + StatusCode = StatusCodes.Status402PaymentRequired + }; + + return verifyResponse; + } + + if (result == SignInResult.LockedOut) + { + errors.Add( + $"The local member {loginInfo.Principal.Identity.Name} for the external provider {loginInfo.ProviderDisplayName} is locked out."); + } + else if (result == SignInResult.NotAllowed) + { + // This occurs when SignInManager.CanSignInAsync fails which is when RequireConfirmedEmail , RequireConfirmedPhoneNumber or RequireConfirmedAccount fails + // however since we don't enforce those rules (yet) this shouldn't happen. + errors.Add( + $"The member {loginInfo.Principal.Identity.Name} for the external provider {loginInfo.ProviderDisplayName} has not confirmed their details and cannot sign in."); + } + else if (result == SignInResult.Failed) + { + // Failed only occurs when the user does not exist + errors.Add("The requested provider (" + loginInfo.LoginProvider + + ") has not been linked to an account, the provider must be linked before it can be used."); + } + else if (result == MemberSignInManager.ExternalLoginSignInResult.NotAllowed) + { + // This occurs when the external provider has approved the login but custom logic in OnExternalLogin has denined it. + errors.Add( + $"The user {loginInfo.Principal.Identity.Name} for the external provider {loginInfo.ProviderDisplayName} has not been accepted and cannot sign in."); + } + else if (result == MemberSignInManager.AutoLinkSignInResult.FailedNotLinked) + { + errors.Add("The requested provider (" + loginInfo.LoginProvider + + ") has not been linked to an account, the provider must be linked from the back office."); + } + else if (result == MemberSignInManager.AutoLinkSignInResult.FailedNoEmail) + { + errors.Add( + $"The requested provider ({loginInfo.LoginProvider}) has not provided the email claim {ClaimTypes.Email}, the account cannot be linked."); + } + else if (result is MemberSignInManager.AutoLinkSignInResult autoLinkSignInResult && + autoLinkSignInResult.Errors.Count > 0) + { + errors.AddRange(autoLinkSignInResult.Errors); + } + else if (!result.Succeeded) + { + // this shouldn't occur, the above should catch the correct error but we'll be safe just in case + errors.Add($"An unknown error with the requested provider ({loginInfo.LoginProvider}) occurred."); + } + } + + if (errors.Count > 0) + { + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo?.LoginProvider, + errors)); + + return CurrentUmbracoPage(); + } + + return RedirectToLocal(returnUrl); + } + + private void AddModelErrors(IdentityResult result, string prefix = "") + { + foreach (IdentityError error in result.Errors) + { + ModelState.AddModelError(prefix, error.Description); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult LinkLogin(string provider, string returnUrl = null) + { + if (returnUrl.IsNullOrWhiteSpace()) + { + returnUrl = Request.GetEncodedPathAndQuery(); + } + + var wrappedReturnUrl = + Url.SurfaceAction(nameof(ExternalLinkLoginCallback), this.GetControllerName(), new { returnUrl }); + + // Configures the redirect URL and user identifier for the specified external login including xsrf data + AuthenticationProperties properties = + _memberSignInManager.ConfigureExternalAuthenticationProperties(provider, wrappedReturnUrl, + _memberManager.GetUserId(User)); + + return Challenge(properties, provider); + } + + [HttpGet] + public async Task ExternalLinkLoginCallback(string returnUrl) + { + MemberIdentityUser user = await _memberManager.GetUserAsync(User); + string loginProvider = null; + var errors = new List(); + if (user == null) + { + // ... this should really not happen + errors.Add("Local user does not exist"); + } + else + { + ExternalLoginInfo info = + await _memberSignInManager.GetExternalLoginInfoAsync(await _memberManager.GetUserIdAsync(user)); + + if (info == null) + { + //Add error and redirect for it to be displayed + errors.Add( "An error occurred, could not get external login info"); + } + else + { + loginProvider = info.LoginProvider; + IdentityResult addLoginResult = await _memberManager.AddLoginAsync(user, info); + if (addLoginResult.Succeeded) + { + // Update any authentication tokens if succeeded + await _memberSignInManager.UpdateExternalAuthenticationTokensAsync(info); + + return RedirectToLocal(returnUrl); + } + + //Add errors and redirect for it to be displayed + errors.AddRange(addLoginResult.Errors.Select(x => x.Description)); + } + } + + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginProvider, + errors)); + return CurrentUmbracoPage(); + } + + private IActionResult RedirectToLocal(string returnUrl) => + Url.IsLocalUrl(returnUrl) ? Redirect(returnUrl) : RedirectToCurrentUmbracoPage(); + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Disassociate(string provider, string providerKey, string returnUrl = null) + { + if (returnUrl.IsNullOrWhiteSpace()) + { + returnUrl = Request.GetEncodedPathAndQuery(); + } + + MemberIdentityUser user = await _memberManager.FindByIdAsync(User.Identity.GetUserId()); + + IdentityResult result = await _memberManager.RemoveLoginAsync(user, provider, providerKey); + + if (result.Succeeded) + { + await _memberSignInManager.SignInAsync(user, false); + return RedirectToLocal(returnUrl); + } + + AddModelErrors(result); + return CurrentUmbracoPage(); + } + } +} diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilder.MemberIdentity.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilder.MemberIdentity.cs new file mode 100644 index 0000000000..c208d96972 --- /dev/null +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilder.MemberIdentity.cs @@ -0,0 +1,23 @@ +using System; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Web.Website.Security; + + +namespace Umbraco.Extensions +{ + /// + /// Extension methods for for the Umbraco back office + /// + public static partial class UmbracoBuilderExtensions + { + /// + /// Adds support for external login providers in Umbraco + /// + public static IUmbracoBuilder AddMemberExternalLogins(this IUmbracoBuilder umbracoBuilder, Action builder) + { + builder(new MemberExternalLoginsBuilder(umbracoBuilder.Services)); + return umbracoBuilder; + } + + } +} diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs index 797a4b2202..5f8f1d9b69 100644 --- a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs @@ -18,7 +18,7 @@ namespace Umbraco.Extensions /// /// extensions for umbraco front-end website /// - public static class UmbracoBuilderExtensions + public static partial class UmbracoBuilderExtensions { /// /// Add services for the umbraco front-end website diff --git a/src/Umbraco.Web.Website/Models/ProfileModel.cs b/src/Umbraco.Web.Website/Models/ProfileModel.cs index a435d22c06..5fc7ed2df8 100644 --- a/src/Umbraco.Web.Website/Models/ProfileModel.cs +++ b/src/Umbraco.Web.Website/Models/ProfileModel.cs @@ -12,6 +12,10 @@ namespace Umbraco.Cms.Web.Website.Models /// public class ProfileModel : PostRedirectModel { + + [ReadOnly(true)] + public Guid Key { get; set; } + [Required] [EmailAddress] [Display(Name = "Email")] diff --git a/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs b/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs index 00d4cebd1e..4a94fb094c 100644 --- a/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs +++ b/src/Umbraco.Web.Website/Models/ProfileModelBuilder.cs @@ -68,7 +68,8 @@ namespace Umbraco.Cms.Web.Website.Models CreatedDate = member.CreatedDateUtc.ToLocalTime(), LastLoginDate = member.LastLoginDateUtc?.ToLocalTime(), LastPasswordChangedDate = member.LastPasswordChangeDateUtc?.ToLocalTime(), - RedirectUrl = _redirectUrl + RedirectUrl = _redirectUrl, + Key = member.Key }; IMemberType memberType = MemberTypeService.Get(member.MemberTypeAlias); @@ -83,7 +84,7 @@ namespace Umbraco.Cms.Web.Website.Models { // should never happen throw new InvalidOperationException($"Could not find a member with key: {member.Key}."); - } + } if (_lookupProperties) { diff --git a/src/Umbraco.Web.Website/Security/MemberAuthenticationBuilder.cs b/src/Umbraco.Web.Website/Security/MemberAuthenticationBuilder.cs new file mode 100644 index 0000000000..d58abfc871 --- /dev/null +++ b/src/Umbraco.Web.Website/Security/MemberAuthenticationBuilder.cs @@ -0,0 +1,75 @@ +using System; +using System.Diagnostics; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; +using Constants = Umbraco.Cms.Core.Constants; + +namespace Umbraco.Cms.Web.Website.Security +{ + /// + /// Custom used to associate external logins with umbraco external login options + /// + public class MemberAuthenticationBuilder : AuthenticationBuilder + { + private readonly Action _loginProviderOptions; + + public MemberAuthenticationBuilder( + IServiceCollection services, + Action loginProviderOptions = null) + : base(services) + => _loginProviderOptions = loginProviderOptions ?? (x => { }); + + public string SchemeForMembers(string scheme) + => scheme?.EnsureStartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix); + + /// + /// Overridden to track the final authenticationScheme being registered for the external login + /// + /// + /// + /// + /// + /// + /// + public override AuthenticationBuilder AddRemoteScheme(string authenticationScheme, string displayName, Action configureOptions) + { + // Validate that the prefix is set + if (!authenticationScheme.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) + { + throw new InvalidOperationException($"The {nameof(authenticationScheme)} is not prefixed with {Constants.Security.BackOfficeExternalAuthenticationTypePrefix}. The scheme must be created with a call to the method {nameof(SchemeForMembers)}"); + } + + // add our login provider to the container along with a custom options configuration + Services.Configure(authenticationScheme, _loginProviderOptions); + base.Services.AddSingleton(services => + { + return new MemberExternalLoginProvider( + authenticationScheme, + services.GetRequiredService>()); + }); + Services.TryAddEnumerable(ServiceDescriptor.Singleton, EnsureMemberScheme>()); + + return base.AddRemoteScheme(authenticationScheme, displayName, configureOptions); + } + + // Ensures that the sign in scheme is always the Umbraco member external type + private class EnsureMemberScheme : IPostConfigureOptions where TOptions : RemoteAuthenticationOptions + { + public void PostConfigure(string name, TOptions options) + { + if (!name.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) + { + return; + } + + options.SignInScheme = IdentityConstants.ExternalScheme; + } + } + } + +} diff --git a/src/Umbraco.Web.Website/Security/MemberExternalLoginsBuilder.cs b/src/Umbraco.Web.Website/Security/MemberExternalLoginsBuilder.cs new file mode 100644 index 0000000000..4f8eb407be --- /dev/null +++ b/src/Umbraco.Web.Website/Security/MemberExternalLoginsBuilder.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Web.Common.Security; + +namespace Umbraco.Cms.Web.Website.Security +{ + /// + /// Used to add back office login providers + /// + public class MemberExternalLoginsBuilder + { + public MemberExternalLoginsBuilder(IServiceCollection services) + { + _services = services; + } + + private readonly IServiceCollection _services; + + /// + /// Add a back office login provider with options + /// + /// + /// + /// + public MemberExternalLoginsBuilder AddMemberLogin( + Action build, + Action loginProviderOptions = null) + { + build(new MemberAuthenticationBuilder(_services, loginProviderOptions)); + return this; + } + } + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs index 665bb2f079..ac0d19040e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs @@ -23,7 +23,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Security { private IUserService UserService => GetRequiredService(); private IEntityService EntityService => GetRequiredService(); - private IExternalLoginService ExternalLoginService => GetRequiredService(); + private IExternalLoginWithKeyService ExternalLoginService => GetRequiredService(); private IUmbracoMapper UmbracoMapper => GetRequiredService(); private ILocalizedTextService TextService => GetRequiredService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ExternalLoginServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ExternalLoginServiceTests.cs index b4f6c1ae5f..f0803b25d0 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ExternalLoginServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ExternalLoginServiceTests.cs @@ -20,7 +20,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services { private IUserService UserService => GetRequiredService(); - private IExternalLoginService ExternalLoginService => (IExternalLoginService)GetRequiredService(); + private IExternalLoginWithKeyService ExternalLoginService => GetRequiredService(); [Test] [Ignore("We don't support duplicates anymore, this removing on save was a breaking change work around, this needs to be ported to a migration")] @@ -38,14 +38,14 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services // insert duplicates manuall scope.Database.Insert(new ExternalLoginDto { - UserId = user.Id, + UserOrMemberKey = user.Key, LoginProvider = "test1", ProviderKey = providerKey, CreateDate = latest }); scope.Database.Insert(new ExternalLoginDto { - UserId = user.Id, + UserOrMemberKey = user.Key, LoginProvider = "test1", ProviderKey = providerKey, CreateDate = oldest @@ -60,9 +60,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", providerKey) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).ToList(); // duplicates will be removed, keeping the latest entries Assert.AreEqual(2, logins.Count); @@ -84,9 +84,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", providerKey) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).ToList(); Assert.AreEqual(1, logins.Count); } @@ -103,16 +103,16 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", providerKey1, "hello"), new ExternalLogin("test2", providerKey2, "world") }; - ExternalLoginService.Save(user.Id, extLogins); + ExternalLoginService.Save(user.Key, extLogins); extLogins = new[] { new ExternalLogin("test1", providerKey1, "123456"), new ExternalLogin("test2", providerKey2, "987654") }; - ExternalLoginService.Save(user.Id, extLogins); + ExternalLoginService.Save(user.Key, extLogins); - var found = ExternalLoginService.GetExternalLogins(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var found = ExternalLoginService.GetExternalLogins(user.Key).OrderBy(x => x.LoginProvider).ToList(); Assert.AreEqual(2, found.Count); Assert.AreEqual("123456", found[0].UserData); Assert.AreEqual("987654", found[1].UserData); @@ -131,7 +131,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", providerKey1, "hello"), new ExternalLogin("test2", providerKey2, "world") }; - ExternalLoginService.Save(user.Id, extLogins); + ExternalLoginService.Save(user.Key, extLogins); var found = ExternalLoginService.Find("test2", providerKey2).ToList(); Assert.AreEqual(1, found.Count); @@ -151,9 +151,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test2", Guid.NewGuid().ToString("N")) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).OrderBy(x => x.LoginProvider).ToList(); Assert.AreEqual(2, logins.Count); for (int i = 0; i < logins.Count; i++) { @@ -173,7 +173,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", Guid.NewGuid().ToString("N")) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); ExternalLoginToken[] externalTokens = new[] { @@ -181,9 +181,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLoginToken(externalLogins[0].LoginProvider, "hello2", "world2") }; - ExternalLoginService.Save(user.Id, externalTokens); + ExternalLoginService.Save(user.Key, externalTokens); - var tokens = ExternalLoginService.GetExternalLoginTokens(user.Id).ToList(); + var tokens = ExternalLoginService.GetExternalLoginTokens(user.Key).ToList(); Assert.AreEqual(2, tokens.Count); } @@ -201,18 +201,18 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test4", Guid.NewGuid().ToString("N")) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).OrderBy(x => x.LoginProvider).ToList(); logins.RemoveAt(0); // remove the first one logins.Add(new IdentityUserLogin("test5", Guid.NewGuid().ToString("N"), user.Id.ToString())); // add a new one logins[0].ProviderKey = "abcd123"; // update // save new list - ExternalLoginService.Save(user.Id, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey))); + ExternalLoginService.Save(user.Key, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey))); - var updatedLogins = ExternalLoginService.GetExternalLogins(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var updatedLogins = ExternalLoginService.GetExternalLogins(user.Key).OrderBy(x => x.LoginProvider).ToList(); Assert.AreEqual(4, updatedLogins.Count); for (int i = 0; i < updatedLogins.Count; i++) { @@ -233,7 +233,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test2", Guid.NewGuid().ToString("N")) }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); ExternalLoginToken[] externalTokens = new[] { @@ -243,18 +243,18 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLoginToken(externalLogins[1].LoginProvider, "hello2a", "world2a") }; - ExternalLoginService.Save(user.Id, externalTokens); + ExternalLoginService.Save(user.Key, externalTokens); - var tokens = ExternalLoginService.GetExternalLoginTokens(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var tokens = ExternalLoginService.GetExternalLoginTokens(user.Key).OrderBy(x => x.LoginProvider).ToList(); tokens.RemoveAt(0); // remove the first one tokens.Add(new IdentityUserToken(externalLogins[1].LoginProvider, "hello2b", "world2b", user.Id.ToString())); // add a new one tokens[0].Value = "abcd123"; // update // save new list - ExternalLoginService.Save(user.Id, tokens.Select(x => new ExternalLoginToken(x.LoginProvider, x.Name, x.Value))); + ExternalLoginService.Save(user.Key, tokens.Select(x => new ExternalLoginToken(x.LoginProvider, x.Name, x.Value))); - var updatedTokens = ExternalLoginService.GetExternalLoginTokens(user.Id).OrderBy(x => x.LoginProvider).ToList(); + var updatedTokens = ExternalLoginService.GetExternalLoginTokens(user.Key).OrderBy(x => x.LoginProvider).ToList(); Assert.AreEqual(4, updatedTokens.Count); for (int i = 0; i < updatedTokens.Count; i++) { @@ -275,9 +275,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services new ExternalLogin("test1", Guid.NewGuid().ToString("N"), "hello world") }; - ExternalLoginService.Save(user.Id, externalLogins); + ExternalLoginService.Save(user.Key, externalLogins); - var logins = ExternalLoginService.GetExternalLogins(user.Id).ToList(); + var logins = ExternalLoginService.GetExternalLogins(user.Key).ToList(); Assert.AreEqual("hello world", logins[0].UserData); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs index 05afd547e1..e09fb70d8e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs @@ -52,7 +52,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security new UmbracoMapper(new MapDefinitionCollection(() => mapDefinitions), scopeProvider), scopeProvider, new IdentityErrorDescriber(), - Mock.Of()); + Mock.Of(), Mock.Of()); _mockIdentityOptions = new Mock>(); var idOptions = new IdentityOptions { Lockout = { AllowedForNewUsers = false } }; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs index db58239e5f..4ed2f0895d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs @@ -37,7 +37,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security new UmbracoMapper(new MapDefinitionCollection(() => new List()), mockScopeProvider.Object), mockScopeProvider.Object, new IdentityErrorDescriber(), - Mock.Of()); + Mock.Of(), + Mock.Of() + ); } [Test] diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs index a49ba1a8ac..e616cafd08 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs @@ -37,7 +37,17 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security serviceCollection .AddLogging() .AddAuthentication() - .AddCookie(IdentityConstants.ApplicationScheme); + .AddCookie(IdentityConstants.ApplicationScheme) + .AddCookie(IdentityConstants.ExternalScheme, o => + { + o.Cookie.Name = IdentityConstants.ExternalScheme; + o.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }) + .AddCookie(IdentityConstants.TwoFactorUserIdScheme, o => + { + o.Cookie.Name = IdentityConstants.TwoFactorUserIdScheme; + o.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }); IServiceProvider serviceProvider = serviceProviderFactory.CreateServiceProvider(serviceCollection); var httpContextFactory = new DefaultHttpContextFactory(serviceProvider); IFeatureCollection features = new DefaultHttpContext().Features; @@ -55,7 +65,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security Mock.Of>(), _mockLogger.Object, Mock.Of(), - Mock.Of>()); + Mock.Of>(), + Mock.Of() + ); } private static Mock MockMemberManager() => new Mock( From e204c5bab97c71edc06dc679aeb5fbe8e5b5f2ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Thu, 20 Jan 2022 16:47:00 +0100 Subject: [PATCH 48/53] Media Previews (#11888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Niels Lyngsø Co-authored-by: Mads Rasmussen Co-authored-by: Paul Johnson --- .github/config/codeql-config.yml | 1 + .github/workflows/codeql-analysis.yml | 4 +- .../gulp/tasks/dependencies.js | 18 +- src/Umbraco.Web.UI.Client/gulpfile.js | 2 + src/Umbraco.Web.UI.Client/package.json | 1 + .../upload/umbpropertyfileupload.directive.js | 22 +- .../common/services/mediapreview.service.js | 94 +++++++ src/Umbraco.Web.UI.Client/src/less/belle.less | 5 + .../components/umb-property-file-upload.less | 4 + .../src/less/property-editors.less | 6 +- .../mediaentryeditor.controller.js | 2 + .../mediaentryeditor/mediaentryeditor.html | 251 ++++++++++-------- .../mediaentryeditor/mediaentryeditor.less | 18 +- .../umbaudiopreview/umb-audio-preview.html | 8 + .../umbaudiopreview/umb-audio-preview.less | 18 ++ .../umbaudiopreview.controller.js | 11 + .../umbfilepreview/umb-file-preview.html | 18 ++ .../umbfilepreview/umb-file-preview.less | 13 + .../umbimagepreview/umb-image-preview.html | 6 + .../umbimagepreview/umb-image-preview.less | 9 + .../umbimagepreview.controller.js | 18 ++ .../umbmediapreview/umb-media-preview.less | 14 + .../umbmediapreview.component.js | 38 +++ .../umbvideopreview/umb-video-preview.html | 8 + .../umbvideopreview/umb-video-preview.less | 10 + .../umbvideopreview.controller.js | 15 ++ .../components/mediacard/umb-media-card.html | 2 +- .../mediacard/umbMediaCard.component.js | 1 + .../upload/umb-property-file-upload.html | 114 ++++---- 29 files changed, 530 insertions(+), 201 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/common/services/mediapreview.service.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.less create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umbaudiopreview.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.less create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.less create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umbimagepreview.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umb-media-preview.less create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umbmediapreview.component.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.html create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.less create mode 100644 src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umbvideopreview.controller.js diff --git a/.github/config/codeql-config.yml b/.github/config/codeql-config.yml index 432be995a5..77b390d392 100644 --- a/.github/config/codeql-config.yml +++ b/.github/config/codeql-config.yml @@ -5,3 +5,4 @@ paths: paths-ignore: - '**/node_modules' + - 'src/Umbraco.Web.UI/wwwroot' \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 60292dba45..894ad709e9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,8 +22,8 @@ jobs: with: config-file: ./.github/config/codeql-config.yml - - name: Build - run: dotnet build umbraco-netcore-only.sln # also runs npm build + - name: dotnet build + run: dotnet build umbraco-netcore-only.sln - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 diff --git a/src/Umbraco.Web.UI.Client/gulp/tasks/dependencies.js b/src/Umbraco.Web.UI.Client/gulp/tasks/dependencies.js index e8a40e9d70..55390bb520 100644 --- a/src/Umbraco.Web.UI.Client/gulp/tasks/dependencies.js +++ b/src/Umbraco.Web.UI.Client/gulp/tasks/dependencies.js @@ -134,7 +134,7 @@ function dependencies() { "./node_modules/angular-messages/angular-messages.min.js.map" ], "base": "./node_modules/angular-messages" - }, + }, { "name": "angular-mocks", "src": ["./node_modules/angular-mocks/angular-mocks.js"], @@ -285,11 +285,11 @@ function dependencies() { // add streams for node modules nodeModules.forEach(module => { var task = gulp.src(module.src, { base: module.base, allowEmpty: true }); - + _.forEach(config.roots, function(root){ task = task.pipe(gulp.dest(root + config.targets.lib + "/" + module.name)) }); - + stream.add(task); }); @@ -299,12 +299,12 @@ function dependencies() { _.forEach(config.roots, function(root){ libTask = libTask.pipe(gulp.dest(root + config.targets.lib)) }); - + stream.add(libTask); //Copies all static assets into /root / assets folder //css, fonts and image files - + var assetsTask = gulp.src(config.sources.globs.assets, { allowEmpty: true }); assetsTask = assetsTask.pipe(imagemin([ imagemin.gifsicle({interlaced: true}), @@ -321,8 +321,8 @@ function dependencies() { _.forEach(config.roots, function(root){ assetsTask = assetsTask.pipe(gulp.dest(root + config.targets.assets)); }); - - + + stream.add(assetsTask); // Copies all the less files related to the preview into their folder @@ -342,13 +342,13 @@ function dependencies() { configTask = configTask.pipe(gulp.dest(root + config.targets.views + "/propertyeditors/grid/config")); }); stream.add(configTask); - + var dashboardTask = gulp.src("src/views/dashboard/default/*.jpg", { allowEmpty: true }); _.forEach(config.roots, function(root){ dashboardTask = dashboardTask .pipe(gulp.dest(root + config.targets.views + "/dashboard/default")); }); stream.add(dashboardTask); - + return stream; }; diff --git a/src/Umbraco.Web.UI.Client/gulpfile.js b/src/Umbraco.Web.UI.Client/gulpfile.js index ffc5f061cc..81fa1d79cd 100644 --- a/src/Umbraco.Web.UI.Client/gulpfile.js +++ b/src/Umbraco.Web.UI.Client/gulpfile.js @@ -31,6 +31,8 @@ const coreBuild = parallel(dependencies, js, less, views); // *********************************************************** exports.build = series(coreBuild, testUnit); +exports.buildDev = series(setDevelopmentMode, coreBuild); + exports.coreBuild = coreBuild; exports.dev = series(setDevelopmentMode, coreBuild, runUnitTestServer, watchTask); exports.watch = series(watchTask); diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 43d7a3cecd..6f451b89a7 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -7,6 +7,7 @@ "e2e": "gulp testE2e", "build": "gulp build", "build:skip-tests": "gulp coreBuild", + "build:dev": "gulp buildDev", "dev": "gulp dev", "fastdev": "gulp fastdev", "watch": "gulp watch" diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbpropertyfileupload.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbpropertyfileupload.directive.js index 5492fee1a0..6656f370d0 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbpropertyfileupload.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbpropertyfileupload.directive.js @@ -117,12 +117,11 @@ vm.files = _.map(files, function (file) { var f = { fileName: file, + fileSrc: file, isImage: mediaHelper.detectIfImageByExtension(file), extension: getExtension(file) }; - f.fileSrc = getThumbnail(f); - return f; }); @@ -190,21 +189,6 @@ } } - function getThumbnail(file) { - - if (file.extension === 'svg') { - return file.fileName; - } - - if (!file.isImage) { - return null; - } - - var thumbnailUrl = mediaHelper.getThumbnailFromPath(file.fileName); - - return thumbnailUrl; - } - function getExtension(fileName) { var extension = fileName.substring(fileName.lastIndexOf(".") + 1, fileName.length); return extension.toLowerCase(); @@ -238,7 +222,8 @@ isImage: isImage, extension: extension, fileName: files[i].name, - isClientSide: true + isClientSide: true, + fileData: files[i] }; // Save the file object to the files collection @@ -247,6 +232,7 @@ //special check for a comma in the name newVal += files[i].name.split(',').join('-') + ","; + // TODO: I would love to remove this part. But I'm affright it would be breaking if removed. Its not used by File upload anymore as each preview handles the client-side data on their own. if (isImage || extension === "svg") { var deferred = $q.defer(); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/mediapreview.service.js b/src/Umbraco.Web.UI.Client/src/common/services/mediapreview.service.js new file mode 100644 index 0000000000..b922e07c9c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/services/mediapreview.service.js @@ -0,0 +1,94 @@ +/** +* @ngdoc service +* @name umbraco.services.mediaPreview +* @description A service providing views used for dealing with previewing files. +* +* ##usage +* The service allows for registering and retrieving the view for one or more file extensions. +* +* You can register your own custom view in this way: +* +*
+*    angular.module('umbraco').run(['mediaPreview', function (mediaPreview) {
+*        mediaPreview.registerPreview(['docx'], "app_plugins/My_PACKAGE/preview.html");
+*    }]);
+* 
+* +* Here is a example of a preview template. (base on the audio-preview). +* +*
+*   
+*    
+* 
+* +* Notice that there often is a need to differentiate based on the file-data origin. In the state of the file still begin located locally its often needed to create an Object-URL for the data to be useable in HTML. As well you might want to provide links for the uploaded file when it is uploaded to the server. See 'vm.clientSide' and 'vm.clientSideData'. +* +**/ +function mediaPreview() { + + const DEFAULT_FILE_PREVIEW = "views/components/media/umbfilepreview/umb-file-preview.html"; + + var _mediaPreviews = []; + + function init(service) { + service.registerPreview(Umbraco.Sys.ServerVariables.umbracoSettings.imageFileTypes.split(","), "views/components/media/umbimagepreview/umb-image-preview.html"); + service.registerPreview(["svg"], "views/components/media/umbimagepreview/umb-image-preview.html"); + service.registerPreview(["mp4", "mov", "webm", "ogv"], "views/components/media/umbvideopreview/umb-video-preview.html"); + service.registerPreview(["mp3", "weba", "oga", "opus"], "views/components/media/umbaudiopreview/umb-audio-preview.html"); + } + + var service = { + + /** + * @ngdoc method + * @name umbraco.services.mediaPreview#getMediaPreview + * @methodOf umbraco.services.mediaPreview + * + * @param {string} fileExtension A string with the file extension, example: "pdf" + * + * @description + * The registered view matching this file extensions will be returned. + * + */ + getMediaPreview: function (fileExtension) { + + fileExtension = fileExtension.toLowerCase(); + + var previewObject = _mediaPreviews.find((preview) => preview.fileExtensions.indexOf(fileExtension) !== -1); + + if(previewObject !== undefined) { + return previewObject.view; + } + + return DEFAULT_FILE_PREVIEW; + }, + + /** + * @ngdoc method + * @name umbraco.services.mediaPreview#registerPreview + * @methodOf umbraco.services.mediaPreview + * + * @param {array} fileExtensions An array of file extensions, example: ["pdf", "jpg"] + * @param {array} view A URL to the view to be used for these file extensions. + * + * @description + * The registered view will be used when file extensions match the given file. + * + */ + registerPreview: function (fileExtensions, view) { + _mediaPreviews.push({ + fileExtensions: fileExtensions.map(e => e.toLowerCase()), + view: view + }) + } + + }; + + init(service); + + return service; +} angular.module('umbraco.services').factory('mediaPreview', mediaPreview); diff --git a/src/Umbraco.Web.UI.Client/src/less/belle.less b/src/Umbraco.Web.UI.Client/src/less/belle.less index ba4df33aa0..fa0543aeaf 100644 --- a/src/Umbraco.Web.UI.Client/src/less/belle.less +++ b/src/Umbraco.Web.UI.Client/src/less/belle.less @@ -202,6 +202,11 @@ @import "components/contextdialogs/umb-dialog-datatype-delete.less"; @import "components/umbemailmarketing.less"; +@import "../views/components/media/umbmediapreview/umb-media-preview.less"; +@import "../views/components/media/umbaudiopreview/umb-audio-preview.less"; +@import "../views/components/media/umbfilepreview/umb-file-preview.less"; +@import "../views/components/media/umbimagepreview/umb-image-preview.less"; +@import "../views/components/media/umbvideopreview/umb-video-preview.less"; // Editors @import "../views/common/infiniteeditors/rollback/rollback.less"; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-property-file-upload.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-property-file-upload.less index 75d171dd87..5856b0bd04 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-property-file-upload.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-property-file-upload.less @@ -38,4 +38,8 @@ border-color: @gray-1; } } + + .umb-property-file-upload--actions { + margin-top: 10px; + } } diff --git a/src/Umbraco.Web.UI.Client/src/less/property-editors.less b/src/Umbraco.Web.UI.Client/src/less/property-editors.less index 2805e7f79b..c3ad08b8f8 100644 --- a/src/Umbraco.Web.UI.Client/src/less/property-editors.less +++ b/src/Umbraco.Web.UI.Client/src/less/property-editors.less @@ -828,11 +828,15 @@ .umb-fileupload { display: flex; flex-direction: column; + padding: 20px; + border: 1px solid @inputBorder; + box-sizing: border-box; + width: 100%; + .umb-property-editor--limit-width(); } .umb-fileupload .preview { border-radius: 5px; - border: 1px solid @gray-6; padding: 3px; background: @gray-9; float: left; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js index 6c8a038536..eea8e87034 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js @@ -38,6 +38,8 @@ angular.module("umbraco") entityResource.getById(vm.mediaEntry.mediaKey, "Media").then(function (mediaEntity) { vm.media = mediaEntity; vm.imageSrc = mediaHelper.resolveFileFromEntity(mediaEntity, true); + vm.fileSrc = mediaHelper.resolveFileFromEntity(mediaEntity, false); + vm.fileExtension = mediaHelper.getFileExtension(vm.fileSrc); vm.loading = false; vm.hasDimensions = false; vm.isCroppable = false; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html index 938d719431..a56e3aeed6 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.html @@ -1,127 +1,148 @@ -
+
+ + + + - +
+
+ + This item is in the Recycle Bin +
- +
+
+ - - - -
- -
- This item is in the Recycle Bin -
- -
-
- - - - -
- -
- -
- - - - - -
- -
- - - - - - - -
- - - -
-
- -
-
+ +
+
+
+ + +
- +
+ + - + + - +
+ + + +
+
+
+
+
- - + + - - + + + - - - -
- + + + + + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.less b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.less index 139d7bef4a..982ef7bc63 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.less +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.less @@ -110,11 +110,23 @@ } .umb-media-entry-editor__imageholder { - display: flex; - align-items: center; - justify-content: center; + position: relative; height: calc(100% - 50px); + + display: block; +} + +.umb-media-entry-editor__previewholder { + + position: relative; + height: calc(100% - 50px); + + display: flex; + justify-content: center; + align-items: center; + + overflow-y: auto; } .umb-media-entry-editor__imageholder-actions { diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.html b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.html new file mode 100644 index 0000000000..4b7bd1c2a6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.html @@ -0,0 +1,8 @@ +
+ + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.less new file mode 100644 index 0000000000..5d69fa07f9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umb-audio-preview.less @@ -0,0 +1,18 @@ +.umb-audio-preview { + display: flex; + justify-content: center; + align-items: center; + audio { + max-width: 100%; + } + audio::-webkit-media-controls-panel { + background-color: white; + } + audio::-webkit-media-controls { + padding: 6px; + } + audio::-webkit-media-controls-enclosure { + &:extend(.shadow-depth-1); + border-radius: 6px; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umbaudiopreview.controller.js b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umbaudiopreview.controller.js new file mode 100644 index 0000000000..985c8540a3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbaudiopreview/umbaudiopreview.controller.js @@ -0,0 +1,11 @@ +angular.module("umbraco") + .controller("umbAudioPreviewController", + function () { + + var vm = this; + + vm.getClientSideUrl = function(source) { + return URL.createObjectURL(source); + } + + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.html b/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.html new file mode 100644 index 0000000000..2c72e27c82 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.html @@ -0,0 +1,18 @@ +
+ + +
{{vm.name}}
+
+
+ +
{{vm.name}}
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.less new file mode 100644 index 0000000000..427ac38244 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbfilepreview/umb-file-preview.less @@ -0,0 +1,13 @@ +.umb-file-preview { + display: flex; + justify-content: center; + align-items: center; + + .umb-file-preview--file { + display: block; + box-sizing: border-box; + text-align: center; + max-width: 320px; + padding: 10px; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.html b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.html new file mode 100644 index 0000000000..989f8ef093 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.html @@ -0,0 +1,6 @@ +
+ {{vm.name}} + + {{vm.name}} + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.less new file mode 100644 index 0000000000..13f934c251 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umb-image-preview.less @@ -0,0 +1,9 @@ +.umb-image-preview { + display: flex; + justify-content: center; + align-items: center; + + img { + width: 100%; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umbimagepreview.controller.js b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umbimagepreview.controller.js new file mode 100644 index 0000000000..36eb3958e2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbimagepreview/umbimagepreview.controller.js @@ -0,0 +1,18 @@ + + + + +angular.module("umbraco") + .controller("umbImagePreviewController", + function (mediaHelper) { + + var vm = this; + + vm.getThumbnail = function(source) { + return mediaHelper.getThumbnailFromPath(source) || source; + } + vm.getClientSideUrl = function(sourceData) { + return URL.createObjectURL(sourceData); + } + + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umb-media-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umb-media-preview.less new file mode 100644 index 0000000000..78c41eeab6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umb-media-preview.less @@ -0,0 +1,14 @@ +umb-media-preview { + position: relative; +} +.umb-media-preview { + position: relative; + width: 100%; + height: 100%; + + min-height: 240px; + + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umbmediapreview.component.js b/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umbmediapreview.component.js new file mode 100644 index 0000000000..b7b5536b0a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbmediapreview/umbmediapreview.component.js @@ -0,0 +1,38 @@ +(function () { + "use strict"; + + angular + .module("umbraco") + .component("umbMediaPreview", { + template: "
", + controller: UmbMediaPreviewController, + controllerAs: "vm", + bindings: { + extension: "<", + source: "<", + name: "<", + clientSide: " { + vm.loading = true; + }) + $scope.$on("mediaPreviewLoadingComplete", () => { + vm.loading = false; + }) + + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.html b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.html new file mode 100644 index 0000000000..26003ab4c1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.html @@ -0,0 +1,8 @@ +
+ + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.less b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.less new file mode 100644 index 0000000000..3dd4e2f589 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umb-video-preview.less @@ -0,0 +1,10 @@ +.umb-video-preview { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + video { + max-width: 100%; + max-height: 100%; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umbvideopreview.controller.js b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umbvideopreview.controller.js new file mode 100644 index 0000000000..9c8b32d8b7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/components/media/umbvideopreview/umbvideopreview.controller.js @@ -0,0 +1,15 @@ + + + + +angular.module("umbraco") + .controller("umbVideoPreviewController", + function () { + + var vm = this; + + vm.getClientSideUrl = function(source) { + return URL.createObjectURL(source); + } + + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.html b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.html index 76e1e99314..e54cc3e898 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umb-media-card.html @@ -30,7 +30,7 @@ diff --git a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umbMediaCard.component.js b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umbMediaCard.component.js index 24b20367aa..1014b95227 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umbMediaCard.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/components/mediacard/umbMediaCard.component.js @@ -74,6 +74,7 @@ vm.media = mediaEntity; checkErrorState(); vm.thumbnail = mediaHelper.resolveFileFromEntity(mediaEntity, true); + vm.fileExtension = mediaHelper.getFileExtension(vm.media.metaData.MediaPath); vm.loading = false; }, function () { diff --git a/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-property-file-upload.html b/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-property-file-upload.html index ce5d7292c9..a6df878997 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-property-file-upload.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-property-file-upload.html @@ -1,58 +1,68 @@ 
+ + - - +
+ +

Click to upload

+ +
-
- -

Click to upload

- +
+
+
+
- -
-
- -
- -
-
-
- {{file.fileName}} - - {{file.fileName}} - -
-
-
- -
- - - -
{{file.fileName}}
-
-
- - -
{{file.fileName}}
-
-
-
-
- - - -
- -
-
-
-
+
+ + +
- - +
+
+
+
+
+
From a8e069019971ef4d159d4fa1f3bb2ca2194233b3 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 21 Jan 2022 09:31:09 +0100 Subject: [PATCH 49/53] moved namespace to match umbraco version --- src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs | 3 ++- .../Upgrade/{V_9_2_0 => V_9_3_0}/MovePackageXMLToDb.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename src/Umbraco.Infrastructure/Migrations/Upgrade/{V_9_2_0 => V_9_3_0}/MovePackageXMLToDb.cs (97%) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index ac11666960..502a4a0e7c 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -269,10 +269,11 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade // TO 9.2.0 To("{0571C395-8F0B-44E9-8E3F-47BDD08D817B}"); To("{AD3D3B7F-8E74-45A4-85DB-7FFAD57F9243}"); - To("{A2F22F17-5870-4179-8A8D-2362AA4A0A5F}"); + // TO 9.3.0 + To("{A2F22F17-5870-4179-8A8D-2362AA4A0A5F}"); To("{CA7A1D9D-C9D4-4914-BC0A-459E7B9C3C8C}"); } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_2_0/MovePackageXMLToDb.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/MovePackageXMLToDb.cs similarity index 97% rename from src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_2_0/MovePackageXMLToDb.cs rename to src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/MovePackageXMLToDb.cs index e4a4af0cbb..3d003eb31d 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_2_0/MovePackageXMLToDb.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/MovePackageXMLToDb.cs @@ -9,7 +9,7 @@ using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Extensions; -namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_2_0 +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_3_0 { public class MovePackageXMLToDb : MigrationBase { From 4136adebc89ca873be86cdc5159622886e1884b2 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 5 Jan 2022 15:36:36 +1100 Subject: [PATCH 50/53] Bump ImageSharp.Web version --- src/Umbraco.Web.Common/Umbraco.Web.Common.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj index 031a7a782f..060151fdb2 100644 --- a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj +++ b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj @@ -34,7 +34,7 @@ - + From ecd32bf03305c0a23afccbf9b3f9fcfdd4e4d784 Mon Sep 17 00:00:00 2001 From: Sebastiaan Janssen Date: Fri, 21 Jan 2022 10:48:22 +0100 Subject: [PATCH 51/53] The `marked` dev dependency was unused --- src/Umbraco.Web.UI.Client/package-lock.json | 6 ------ src/Umbraco.Web.UI.Client/package.json | 1 - 2 files changed, 7 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index a9b706a677..0145421fa9 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -8256,12 +8256,6 @@ "object-visit": "^1.0.0" } }, - "marked": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", - "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==", - "dev": true - }, "matchdep": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 6f451b89a7..ef8135487a 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -88,7 +88,6 @@ "karma-spec-reporter": "0.0.32", "less": "3.10.3", "lodash": "4.17.21", - "marked": "^0.7.0", "merge-stream": "2.0.0", "run-sequence": "2.2.1" } From 12abd883a9fc10bcb38f2272cbd143ebe7d7eac2 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 21 Jan 2022 13:10:34 +0100 Subject: [PATCH 52/53] Member 2FA (#11889) * Bugfix - Take ufprt from form data if the request has form content type, otherwise fallback to use the query * External linking for members * Changed migration to reuse old table * removed unnecessary web.config files * Cleanup * Extracted class to own file * Clean up * Rollback changes to Umbraco.Web.UI.csproj * Fixed migration for SqlCE * Added 2fa for members * Change notification handler to be on deleted * Update src/Umbraco.Infrastructure/Security/MemberUserStore.cs Co-authored-by: Mole * updated snippets * Fixed issue with errors not shown on member linking * fixed issue with errors * clean up * Fix issue where external logins could not be used to upgrade Umbraco, because the externalLogin table was expected to look different. (Like after the migration) * Fixed issue in Ignore legacy column now using result column. * Updated 2fa for members + publish notification when 2fa is requested. * Changed so only Members out of box supports 2fa * Cleanup * rollback of csproj file, that should not have been changed * Removed confirmed flag from db. It was not used. Handle case where a user is signed up for 2fa, but the provider do not exist anymore. Then it is just ignored until it shows up again Reintroduced ProviderName on interface, to ensure the class can be renamed safely * Bugfix * Registering DeleteTwoFactorLoginsOnMemberDeletedHandler * Rollback nuget packages added by mistake * Update src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs Co-authored-by: Mole * Update src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TwoFactorLoginRepository.cs Co-authored-by: Mole * Added providername to snippet Co-authored-by: Mole --- src/Umbraco.Core/Models/ITwoFactorLogin.cs | 12 ++ src/Umbraco.Core/Models/TwoFactorLogin.cs | 13 ++ .../MemberTwoFactorRequestedNotification.cs | 14 ++ .../Persistence/Constants-DatabaseSchema.cs | 1 + .../Repositories/ITwoFactorLoginRepository.cs | 16 ++ .../Services/ITwoFactorLoginService.cs | 28 +++ .../UmbracoBuilder.Repositories.cs | 2 + .../UmbracoBuilder.Services.cs | 1 + .../Install/DatabaseSchemaCreator.cs | 1 + .../Migrations/Upgrade/UmbracoPlan.cs | 1 + .../Upgrade/V_9_3_0/AddTwoFactorLoginTable.cs | 24 +++ .../Persistence/Dtos/TwoFactorLoginDto.cs | 33 ++++ .../Implement/TwoFactorLoginRepository.cs | 137 +++++++++++++++ .../Security/BackOfficeIdentityUser.cs | 19 +++ ...teTwoFactorLoginsOnMemberDeletedHandler.cs | 33 ++++ .../Security/ITwoFactorProvider.cs | 22 +++ .../Security/MemberIdentityBuilder.cs | 63 +++++++ .../Security/MemberUserStore.cs | 44 ++++- .../Security/UmbracoUserManager.cs | 8 + .../Implement/TwoFactorLoginService.cs | 118 +++++++++++++ .../UmbracoBuilder.MembersIdentity.cs | 4 +- .../Extensions/IdentityBuilderExtensions.cs | 10 ++ .../UmbracoApplicationBuilder.Identity.cs | 28 +++ .../Extensions/ViewDataExtensions.cs | 14 ++ .../IMemberSignInManagerExternalLogins.cs | 3 + .../Security/MemberManager.cs | 3 + .../Security/MemberSignInManager.cs | 59 ++++--- .../Security/TwoFactorValidationProvider.cs | 91 ++++++++++ .../Templates/EditProfile.cshtml | 4 +- .../PartialViewMacros/Templates/Login.cshtml | 31 ++++ .../Controllers/UmbExternalLoginController.cs | 23 ++- .../Controllers/UmbLoginController.cs | 65 +++++-- .../UmbTwoFactorLoginController.cs | 160 ++++++++++++++++++ .../Security/MemberManagerTests.cs | 4 +- .../Security/MemberUserStoreTests.cs | 3 +- .../Security/MemberSignInManagerTests.cs | 9 +- 36 files changed, 1044 insertions(+), 57 deletions(-) create mode 100644 src/Umbraco.Core/Models/ITwoFactorLogin.cs create mode 100644 src/Umbraco.Core/Models/TwoFactorLogin.cs create mode 100644 src/Umbraco.Core/Notifications/MemberTwoFactorRequestedNotification.cs create mode 100644 src/Umbraco.Core/Persistence/Repositories/ITwoFactorLoginRepository.cs create mode 100644 src/Umbraco.Core/Services/ITwoFactorLoginService.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/AddTwoFactorLoginTable.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Dtos/TwoFactorLoginDto.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TwoFactorLoginRepository.cs create mode 100644 src/Umbraco.Infrastructure/Security/DeleteTwoFactorLoginsOnMemberDeletedHandler.cs create mode 100644 src/Umbraco.Infrastructure/Security/ITwoFactorProvider.cs create mode 100644 src/Umbraco.Infrastructure/Security/MemberIdentityBuilder.cs create mode 100644 src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs create mode 100644 src/Umbraco.Web.Common/Security/TwoFactorValidationProvider.cs create mode 100644 src/Umbraco.Web.Website/Controllers/UmbTwoFactorLoginController.cs diff --git a/src/Umbraco.Core/Models/ITwoFactorLogin.cs b/src/Umbraco.Core/Models/ITwoFactorLogin.cs new file mode 100644 index 0000000000..ca005309b2 --- /dev/null +++ b/src/Umbraco.Core/Models/ITwoFactorLogin.cs @@ -0,0 +1,12 @@ +using System; +using Umbraco.Cms.Core.Models.Entities; + +namespace Umbraco.Cms.Core.Models +{ + public interface ITwoFactorLogin: IEntity, IRememberBeingDirty + { + string ProviderName { get; } + string Secret { get; } + Guid UserOrMemberKey { get; } + } +} diff --git a/src/Umbraco.Core/Models/TwoFactorLogin.cs b/src/Umbraco.Core/Models/TwoFactorLogin.cs new file mode 100644 index 0000000000..6ede9606e8 --- /dev/null +++ b/src/Umbraco.Core/Models/TwoFactorLogin.cs @@ -0,0 +1,13 @@ +using System; +using Umbraco.Cms.Core.Models.Entities; + +namespace Umbraco.Cms.Core.Models +{ + public class TwoFactorLogin : EntityBase, ITwoFactorLogin + { + public string ProviderName { get; set; } + public string Secret { get; set; } + public Guid UserOrMemberKey { get; set; } + public bool Confirmed { get; set; } + } +} diff --git a/src/Umbraco.Core/Notifications/MemberTwoFactorRequestedNotification.cs b/src/Umbraco.Core/Notifications/MemberTwoFactorRequestedNotification.cs new file mode 100644 index 0000000000..980a531ffd --- /dev/null +++ b/src/Umbraco.Core/Notifications/MemberTwoFactorRequestedNotification.cs @@ -0,0 +1,14 @@ +using System; + +namespace Umbraco.Cms.Core.Notifications +{ + public class MemberTwoFactorRequestedNotification : INotification + { + public MemberTwoFactorRequestedNotification(Guid memberKey) + { + MemberKey = memberKey; + } + + public Guid MemberKey { get; } + } +} diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 37560b4c0a..de5b8c04ae 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -55,6 +55,7 @@ namespace Umbraco.Cms.Core public const string UserGroup2Node = TableNamePrefix + "UserGroup2Node"; public const string UserGroup2NodePermission = TableNamePrefix + "UserGroup2NodePermission"; public const string ExternalLogin = TableNamePrefix + "ExternalLogin"; + public const string TwoFactorLogin = TableNamePrefix + "TwoFactorLogin"; public const string ExternalLoginToken = TableNamePrefix + "ExternalLoginToken"; public const string Macro = /*TableNamePrefix*/ "cms" + "Macro"; diff --git a/src/Umbraco.Core/Persistence/Repositories/ITwoFactorLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ITwoFactorLoginRepository.cs new file mode 100644 index 0000000000..63622f8e82 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/ITwoFactorLoginRepository.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories +{ + public interface ITwoFactorLoginRepository: IReadRepository, IWriteRepository + { + Task DeleteUserLoginsAsync(Guid userOrMemberKey); + Task DeleteUserLoginsAsync(Guid userOrMemberKey, string providerName); + + Task> GetByUserOrMemberKeyAsync(Guid userOrMemberKey); + } + +} diff --git a/src/Umbraco.Core/Services/ITwoFactorLoginService.cs b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs new file mode 100644 index 0000000000..dd11f864fb --- /dev/null +++ b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services +{ + public interface ITwoFactorLoginService : IService + { + /// + /// Deletes all user logins - normally used when a member is deleted + /// + Task DeleteUserLoginsAsync(Guid userOrMemberKey); + + Task IsTwoFactorEnabledAsync(Guid userKey); + Task GetSecretForUserAndProviderAsync(Guid userKey, string providerName); + + Task GetSetupInfoAsync(Guid userOrMemberKey, string providerName); + + IEnumerable GetAllProviderNames(); + Task DisableAsync(Guid userOrMemberKey, string providerName); + + bool ValidateTwoFactorSetup(string providerName, string secret, string code); + Task SaveAsync(TwoFactorLogin twoFactorLogin); + Task> GetEnabledTwoFactorProviderNamesAsync(Guid userOrMemberKey); + } + +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index ed2bf67e4a..f9dc43cbd5 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; using Umbraco.Extensions; @@ -30,6 +31,7 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(factory => factory.GetRequiredService()); builder.Services.AddUnique(factory => factory.GetRequiredService()); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index c79cbf9d94..aeec82a94e 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -75,6 +75,7 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection )); builder.Services.AddUnique(factory => factory.GetRequiredService()); builder.Services.AddUnique(factory => factory.GetRequiredService()); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddTransient(SourcesFactory); diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index 8fb9767eb7..9dab0bd14a 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -60,6 +60,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Install typeof(CacheInstructionDto), typeof(ExternalLoginDto), typeof(ExternalLoginTokenDto), + typeof(TwoFactorLoginDto), typeof(RedirectUrlDto), typeof(LockDto), typeof(UserGroupDto), diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 502a4a0e7c..2080034554 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -275,6 +275,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade // TO 9.3.0 To("{A2F22F17-5870-4179-8A8D-2362AA4A0A5F}"); To("{CA7A1D9D-C9D4-4914-BC0A-459E7B9C3C8C}"); + To("{0828F206-DCF7-4F73-ABBB-6792275532EB}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/AddTwoFactorLoginTable.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/AddTwoFactorLoginTable.cs new file mode 100644 index 0000000000..c5e569282a --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_3_0/AddTwoFactorLoginTable.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_3_0 +{ + public class AddTwoFactorLoginTable : MigrationBase + { + public AddTwoFactorLoginTable(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + IEnumerable tables = SqlSyntax.GetTablesInSchema(Context.Database); + if (tables.InvariantContains(TwoFactorLoginDto.TableName)) + { + return; + } + + Create.Table().Do(); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/TwoFactorLoginDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/TwoFactorLoginDto.cs new file mode 100644 index 0000000000..1202fe2a19 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/TwoFactorLoginDto.cs @@ -0,0 +1,33 @@ +using System; +using NPoco; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos +{ + [TableName(TableName)] + [ExplicitColumns] + [PrimaryKey("Id")] + internal class TwoFactorLoginDto + { + public const string TableName = Cms.Core.Constants.DatabaseSchema.Tables.TwoFactorLogin; + + [Column("id")] + [PrimaryKeyColumn] + public int Id { get; set; } + + [Column("userOrMemberKey")] + [Index(IndexTypes.NonClustered)] + public Guid UserOrMemberKey { get; set; } + + [Column("providerName")] + [Length(400)] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Index(IndexTypes.UniqueNonClustered, ForColumns = "providerName,userOrMemberKey", Name = "IX_" + TableName + "_ProviderName")] + public string ProviderName { get; set; } + + [Column("secret")] + [Length(400)] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string Secret { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TwoFactorLoginRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TwoFactorLoginRepository.cs new file mode 100644 index 0000000000..18063edf16 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TwoFactorLoginRepository.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NPoco; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Querying; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement +{ + internal class TwoFactorLoginRepository : EntityRepositoryBase, ITwoFactorLoginRepository + { + public TwoFactorLoginRepository(IScopeAccessor scopeAccessor, AppCaches cache, + ILogger logger) + : base(scopeAccessor, cache, logger) + { + } + + + protected override Sql GetBaseQuery(bool isCount) + { + var sql = SqlContext.Sql(); + + sql = isCount + ? sql.SelectCount() + : sql.Select(); + + sql.From(); + + return sql; + } + + protected override string GetBaseWhereClause() => + Core.Constants.DatabaseSchema.Tables.TwoFactorLogin + ".id = @id"; + + protected override IEnumerable GetDeleteClauses() => Enumerable.Empty(); + + protected override ITwoFactorLogin PerformGet(int id) + { + var sql = GetBaseQuery(false).Where(x => x.Id == id); + var dto = Database.Fetch(sql).FirstOrDefault(); + return dto == null ? null : Map(dto); + } + + protected override IEnumerable PerformGetAll(params int[] ids) + { + var sql = GetBaseQuery(false).WhereIn(x => x.Id, ids); + var dtos = Database.Fetch(sql); + return dtos.WhereNotNull().Select(Map); + } + + protected override IEnumerable PerformGetByQuery(IQuery query) + { + var sqlClause = GetBaseQuery(false); + var translator = new SqlTranslator(sqlClause, query); + var sql = translator.Translate(); + return Database.Fetch(sql).Select(Map); + } + + protected override void PersistNewItem(ITwoFactorLogin entity) + { + var dto = Map(entity); + Database.Insert(dto); + } + + protected override void PersistUpdatedItem(ITwoFactorLogin entity) + { + var dto = Map(entity); + Database.Update(dto); + } + + private static TwoFactorLoginDto Map(ITwoFactorLogin entity) + { + if (entity == null) return null; + + return new TwoFactorLoginDto + { + Id = entity.Id, + UserOrMemberKey = entity.UserOrMemberKey, + ProviderName = entity.ProviderName, + Secret = entity.Secret, + }; + } + + private static ITwoFactorLogin Map(TwoFactorLoginDto dto) + { + if (dto == null) return null; + + return new TwoFactorLogin + { + Id = dto.Id, + UserOrMemberKey = dto.UserOrMemberKey, + ProviderName = dto.ProviderName, + Secret = dto.Secret, + }; + } + + public async Task DeleteUserLoginsAsync(Guid userOrMemberKey) + { + return await DeleteUserLoginsAsync(userOrMemberKey, null); + } + + public async Task DeleteUserLoginsAsync(Guid userOrMemberKey, string providerName) + { + var sql = Sql() + .Delete() + .From() + .Where(x => x.UserOrMemberKey == userOrMemberKey); + + if (providerName is not null) + { + sql = sql.Where(x => x.ProviderName == providerName); + } + + var deletedRows = await Database.ExecuteAsync(sql); + + return deletedRows > 0; + } + + public async Task> GetByUserOrMemberKeyAsync(Guid userOrMemberKey) + { + var sql = Sql() + .Select() + .From() + .Where(x => x.UserOrMemberKey == userOrMemberKey); + var dtos = await Database.FetchAsync(sql); + return dtos.WhereNotNull().Select(Map); + } + } +} diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs index ebd12719e1..df4d704781 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs @@ -159,5 +159,24 @@ namespace Umbraco.Cms.Core.Security } private static string UserIdToString(int userId) => string.Intern(userId.ToString(CultureInfo.InvariantCulture)); + + public Guid Key => UserIdToInt(Id).ToGuid(); + + + private static int UserIdToInt(string userId) + { + if(int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + + if(Guid.TryParse(userId, out var key)) + { + // Reverse the IntExtensions.ToGuid + return BitConverter.ToInt32(key.ToByteArray(), 0); + } + + throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); + } } } diff --git a/src/Umbraco.Infrastructure/Security/DeleteTwoFactorLoginsOnMemberDeletedHandler.cs b/src/Umbraco.Infrastructure/Security/DeleteTwoFactorLoginsOnMemberDeletedHandler.cs new file mode 100644 index 0000000000..7fe4a7c506 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/DeleteTwoFactorLoginsOnMemberDeletedHandler.cs @@ -0,0 +1,33 @@ +using System.Threading; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Security +{ + /// + /// Deletes the two factor for the deleted members. This cannot be handled by the database as there is not foreign keys. + /// + public class DeleteTwoFactorLoginsOnMemberDeletedHandler : INotificationAsyncHandler + { + private readonly ITwoFactorLoginService _twoFactorLoginService; + + /// + /// Initializes a new instance of the class. + /// + public DeleteTwoFactorLoginsOnMemberDeletedHandler(ITwoFactorLoginService twoFactorLoginService) + => _twoFactorLoginService = twoFactorLoginService; + + /// + public async Task HandleAsync(MemberDeletedNotification notification, CancellationToken cancellationToken) + { + foreach (IMember member in notification.DeletedEntities) + { + await _twoFactorLoginService.DeleteUserLoginsAsync(member.Key); + } + } + + } +} diff --git a/src/Umbraco.Infrastructure/Security/ITwoFactorProvider.cs b/src/Umbraco.Infrastructure/Security/ITwoFactorProvider.cs new file mode 100644 index 0000000000..f0da6c314a --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/ITwoFactorProvider.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading.Tasks; + +namespace Umbraco.Cms.Core.Security +{ + public interface ITwoFactorProvider + { + string ProviderName { get; } + + Task GetSetupDataAsync(Guid userOrMemberKey, string secret); + + bool ValidateTwoFactorPIN(string secret, string token); + + /// + /// + /// + /// Called to confirm the setup of two factor on the user. + bool ValidateTwoFactorSetup(string secret, string token); + } + + +} diff --git a/src/Umbraco.Infrastructure/Security/MemberIdentityBuilder.cs b/src/Umbraco.Infrastructure/Security/MemberIdentityBuilder.cs new file mode 100644 index 0000000000..c0df423638 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/MemberIdentityBuilder.cs @@ -0,0 +1,63 @@ +using System; +using System.Reflection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Umbraco.Cms.Core.Net; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.Security +{ + + public class MemberIdentityBuilder : IdentityBuilder + { + /// + /// Initializes a new instance of the class. + /// + public MemberIdentityBuilder(IServiceCollection services) + : base(typeof(MemberIdentityUser), services) + => InitializeServices(services); + + /// + /// Initializes a new instance of the class. + /// + public MemberIdentityBuilder(Type role, IServiceCollection services) + : base(typeof(MemberIdentityUser), role, services) + => InitializeServices(services); + + private void InitializeServices(IServiceCollection services) + { + + } + + // override to add itself, by default identity only wants a single IdentityErrorDescriber + public override IdentityBuilder AddErrorDescriber() + { + if (!typeof(MembersErrorDescriber).IsAssignableFrom(typeof(TDescriber))) + { + throw new InvalidOperationException($"The type {typeof(TDescriber)} does not inherit from {typeof(MembersErrorDescriber)}"); + } + + Services.AddScoped(); + return this; + } + + /// + /// Adds a token provider for the . + /// + /// The name of the provider to add. + /// The type of the to add. + /// The current instance. + public override IdentityBuilder AddTokenProvider(string providerName, Type provider) + { + if (!typeof(IUserTwoFactorTokenProvider<>).MakeGenericType(UserType).GetTypeInfo().IsAssignableFrom(provider.GetTypeInfo())) + { + throw new InvalidOperationException($"Invalid Type for TokenProvider: {provider.FullName}"); + } + + Services.Configure(options => options.Tokens.ProviderMap[providerName] = new TokenProviderDescriptor(provider)); + Services.AddTransient(provider); + return this; + } + } +} diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index da45e4d888..4fba880e81 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -29,6 +29,7 @@ namespace Umbraco.Cms.Core.Security private readonly IScopeProvider _scopeProvider; private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IExternalLoginWithKeyService _externalLoginService; + private readonly ITwoFactorLoginService _twoFactorLoginService; /// /// Initializes a new instance of the class for the members identity store @@ -37,7 +38,9 @@ namespace Umbraco.Cms.Core.Security /// The mapper for properties /// The scope provider /// The error describer + /// The published snapshot accessor /// The external login service + /// The two factor login service [ActivatorUtilitiesConstructor] public MemberUserStore( IMemberService memberService, @@ -45,7 +48,8 @@ namespace Umbraco.Cms.Core.Security IScopeProvider scopeProvider, IdentityErrorDescriber describer, IPublishedSnapshotAccessor publishedSnapshotAccessor, - IExternalLoginWithKeyService externalLoginService + IExternalLoginWithKeyService externalLoginService, + ITwoFactorLoginService twoFactorLoginService ) : base(describer) { @@ -54,9 +58,10 @@ namespace Umbraco.Cms.Core.Security _scopeProvider = scopeProvider ?? throw new ArgumentNullException(nameof(scopeProvider)); _publishedSnapshotAccessor = publishedSnapshotAccessor; _externalLoginService = externalLoginService; + _twoFactorLoginService = twoFactorLoginService; } - [Obsolete("Use ctor with IExternalLoginWithKeyService param")] + [Obsolete("Use ctor with IExternalLoginWithKeyService and ITwoFactorLoginService param")] public MemberUserStore( IMemberService memberService, IUmbracoMapper mapper, @@ -64,19 +69,19 @@ namespace Umbraco.Cms.Core.Security IdentityErrorDescriber describer, IPublishedSnapshotAccessor publishedSnapshotAccessor, IExternalLoginService externalLoginService) - : this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService()) + : this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService()) { } - [Obsolete("Use ctor with IExternalLoginWithKeyService param")] + [Obsolete("Use ctor with IExternalLoginWithKeyService and ITwoFactorLoginService param")] public MemberUserStore( IMemberService memberService, IUmbracoMapper mapper, IScopeProvider scopeProvider, IdentityErrorDescriber describer, IPublishedSnapshotAccessor publishedSnapshotAccessor) - : this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService()) + : this(memberService, mapper, scopeProvider, describer, publishedSnapshotAccessor, StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService()) { } @@ -678,5 +683,34 @@ namespace Umbraco.Cms.Core.Security LoginOnly, FullSave } + + /// + /// Overridden to support Umbraco's own data storage requirements + /// + /// + /// The base class's implementation of this calls into FindTokenAsync, RemoveUserTokenAsync and AddUserTokenAsync, both methods will only work with ORMs that are change + /// tracking ORMs like EFCore. + /// + /// + public override Task GetTokenAsync(MemberIdentityUser user, string loginProvider, string name, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + IIdentityUserToken token = user.LoginTokens.FirstOrDefault(x => x.LoginProvider.InvariantEquals(loginProvider) && x.Name.InvariantEquals(name)); + + return Task.FromResult(token?.Value); + } + + /// + public override async Task GetTwoFactorEnabledAsync(MemberIdentityUser user, + CancellationToken cancellationToken = default(CancellationToken)) + { + return await _twoFactorLoginService.IsTwoFactorEnabledAsync(user.Key); + } } } diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs index dfef27242b..1410473f6a 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs @@ -263,5 +263,13 @@ namespace Umbraco.Cms.Core.Security return await VerifyPasswordAsync(userPasswordStore, user, password) == PasswordVerificationResult.Success; } + + /// + public virtual async Task> GetValidTwoFactorProvidersAsync(TUser user) + { + var results = await base.GetValidTwoFactorProvidersAsync(user); + + return results; + } } } diff --git a/src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs b/src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs new file mode 100644 index 0000000000..713a73c1df --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.Services +{ + public class TwoFactorLoginService : ITwoFactorLoginService + { + private readonly ITwoFactorLoginRepository _twoFactorLoginRepository; + private readonly IScopeProvider _scopeProvider; + private readonly IOptions _identityOptions; + private readonly IDictionary _twoFactorSetupGenerators; + + public TwoFactorLoginService( + ITwoFactorLoginRepository twoFactorLoginRepository, + IScopeProvider scopeProvider, + IEnumerable twoFactorSetupGenerators, + IOptions identityOptions) + { + _twoFactorLoginRepository = twoFactorLoginRepository; + _scopeProvider = scopeProvider; + _identityOptions = identityOptions; + _twoFactorSetupGenerators = twoFactorSetupGenerators.ToDictionary(x=>x.ProviderName); + } + + public async Task DeleteUserLoginsAsync(Guid userOrMemberKey) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + await _twoFactorLoginRepository.DeleteUserLoginsAsync(userOrMemberKey); + } + + public async Task> GetEnabledTwoFactorProviderNamesAsync(Guid userOrMemberKey) + { + return await GetEnabledProviderNamesAsync(userOrMemberKey); + } + + private async Task> GetEnabledProviderNamesAsync(Guid userOrMemberKey) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + var providersOnUser = (await _twoFactorLoginRepository.GetByUserOrMemberKeyAsync(userOrMemberKey)) + .Select(x => x.ProviderName).ToArray(); + + return providersOnUser.Where(x => _identityOptions.Value.Tokens.ProviderMap.ContainsKey(x)); + } + + + public async Task IsTwoFactorEnabledAsync(Guid userOrMemberKey) + { + return (await GetEnabledProviderNamesAsync(userOrMemberKey)).Any(); + } + + public async Task GetSecretForUserAndProviderAsync(Guid userOrMemberKey, string providerName) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + return (await _twoFactorLoginRepository.GetByUserOrMemberKeyAsync(userOrMemberKey)).FirstOrDefault(x=>x.ProviderName == providerName)?.Secret; + } + + public async Task GetSetupInfoAsync(Guid userOrMemberKey, string providerName) + { + var secret = await GetSecretForUserAndProviderAsync(userOrMemberKey, providerName); + + //Dont allow to generate a new secrets if user already has one + if (!string.IsNullOrEmpty(secret)) + { + return default; + } + + secret = GenerateSecret(); + + if (!_twoFactorSetupGenerators.TryGetValue(providerName, out ITwoFactorProvider generator)) + { + throw new InvalidOperationException($"No ITwoFactorSetupGenerator found for provider: {providerName}"); + } + + return await generator.GetSetupDataAsync(userOrMemberKey, secret); + } + + public IEnumerable GetAllProviderNames() => _twoFactorSetupGenerators.Keys; + public async Task DisableAsync(Guid userOrMemberKey, string providerName) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + return (await _twoFactorLoginRepository.DeleteUserLoginsAsync(userOrMemberKey, providerName)); + + } + + public bool ValidateTwoFactorSetup(string providerName, string secret, string code) + { + if (!_twoFactorSetupGenerators.TryGetValue(providerName, out ITwoFactorProvider generator)) + { + throw new InvalidOperationException($"No ITwoFactorSetupGenerator found for provider: {providerName}"); + } + + return generator.ValidateTwoFactorSetup(secret, code); + } + + public Task SaveAsync(TwoFactorLogin twoFactorLogin) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + _twoFactorLoginRepository.Save(twoFactorLogin); + + return Task.CompletedTask; + } + + + /// + /// Generates a new random unique secret. + /// + /// The random secret + protected virtual string GenerateSecret() => Guid.NewGuid().ToString(); + } +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs index 66badc479e..98391d7590 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.MembersIdentity.cs @@ -51,7 +51,8 @@ namespace Umbraco.Extensions factory.GetRequiredService(), factory.GetRequiredService(), factory.GetRequiredService(), - factory.GetRequiredService() + factory.GetRequiredService(), + factory.GetRequiredService() )) .AddRoleStore() .AddRoleManager() @@ -63,6 +64,7 @@ namespace Umbraco.Extensions builder.AddNotificationHandler(); + builder.AddNotificationAsyncHandler(); services.ConfigureOptions(); services.AddScoped(x => (IMemberUserStore)x.GetRequiredService>()); diff --git a/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs b/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs index f1d2ac4a3d..9b80f3e82a 100644 --- a/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/IdentityBuilderExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Infrastructure.Security; namespace Umbraco.Extensions { @@ -59,5 +60,14 @@ namespace Umbraco.Extensions identityBuilder.Services.AddScoped(typeof(TInterface), implementationFactory); return identityBuilder; } + + public static MemberIdentityBuilder AddTwoFactorProvider(this MemberIdentityBuilder identityBuilder, string providerName) where T : class, ITwoFactorProvider + { + identityBuilder.Services.AddSingleton(); + identityBuilder.Services.AddSingleton(); + identityBuilder.AddTokenProvider>(providerName); + + return identityBuilder; + } } } diff --git a/src/Umbraco.Web.Common/Extensions/UmbracoApplicationBuilder.Identity.cs b/src/Umbraco.Web.Common/Extensions/UmbracoApplicationBuilder.Identity.cs index 64fde06ac8..e7c0246f40 100644 --- a/src/Umbraco.Web.Common/Extensions/UmbracoApplicationBuilder.Identity.cs +++ b/src/Umbraco.Web.Common/Extensions/UmbracoApplicationBuilder.Identity.cs @@ -20,5 +20,33 @@ namespace Umbraco.Extensions builder.Services.Replace(ServiceDescriptor.Scoped(userManagerType, customType)); return builder; } + + public static IUmbracoBuilder SetBackOfficeUserStore(this IUmbracoBuilder builder) + where TUserStore : BackOfficeUserStore + { + Type customType = typeof(TUserStore); + builder.Services.Replace(ServiceDescriptor.Scoped(typeof(IUserStore<>).MakeGenericType(typeof(BackOfficeIdentityUser)), customType)); + return builder; + } + + public static IUmbracoBuilder SetMemberManager(this IUmbracoBuilder builder) + where TUserManager : UserManager, IMemberManager + { + + Type customType = typeof(TUserManager); + Type userManagerType = typeof(UserManager); + builder.Services.Replace(ServiceDescriptor.Scoped(typeof(IMemberManager), customType)); + builder.Services.AddScoped(customType, services => services.GetRequiredService(userManagerType)); + builder.Services.Replace(ServiceDescriptor.Scoped(userManagerType, customType)); + return builder; + } + + public static IUmbracoBuilder SetMemberUserStore(this IUmbracoBuilder builder) + where TUserStore : MemberUserStore + { + Type customType = typeof(TUserStore); + builder.Services.Replace(ServiceDescriptor.Scoped(typeof(IUserStore<>).MakeGenericType(typeof(MemberIdentityUser)), customType)); + return builder; + } } } diff --git a/src/Umbraco.Web.Common/Extensions/ViewDataExtensions.cs b/src/Umbraco.Web.Common/Extensions/ViewDataExtensions.cs index 36adacc2d2..8e62ca09cf 100644 --- a/src/Umbraco.Web.Common/Extensions/ViewDataExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/ViewDataExtensions.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Semver; using Umbraco.Cms.Core.Serialization; @@ -16,6 +18,7 @@ namespace Umbraco.Extensions public const string TokenUmbracoVersion = "UmbracoVersion"; public const string TokenExternalSignInError = "ExternalSignInError"; public const string TokenPasswordResetCode = "PasswordResetCode"; + public const string TokenTwoFactorRequired = "TwoFactorRequired"; public static bool FromTempData(this ViewDataDictionary viewData, ITempDataDictionary tempData, string token) { @@ -135,5 +138,16 @@ namespace Umbraco.Extensions { viewData[TokenPasswordResetCode] = value; } + + public static void SetTwoFactorProviderNames(this ViewDataDictionary viewData, IEnumerable providerNames) + { + viewData[TokenTwoFactorRequired] = providerNames; + } + + public static bool TryGetTwoFactorProviderNames(this ViewDataDictionary viewData, out IEnumerable providerNames) + { + providerNames = viewData[TokenTwoFactorRequired] as IEnumerable; + return providerNames is not null; + } } } diff --git a/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs b/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs index 3599a028f4..eb6a66a000 100644 --- a/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs +++ b/src/Umbraco.Web.Common/Security/IMemberSignInManagerExternalLogins.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; +using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Web.Common.Security { @@ -12,5 +13,7 @@ namespace Umbraco.Cms.Web.Common.Security Task GetExternalLoginInfoAsync(string expectedXsrf = null); Task UpdateExternalAuthenticationTokensAsync(ExternalLoginInfo externalLogin); Task ExternalLoginSignInAsync(ExternalLoginInfo loginInfo, bool isPersistent, bool bypassTwoFactor = false); + Task GetTwoFactorAuthenticationUserAsync(); + Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient); } } diff --git a/src/Umbraco.Web.Common/Security/MemberManager.cs b/src/Umbraco.Web.Common/Security/MemberManager.cs index 9a0f26aff4..93aad3a060 100644 --- a/src/Umbraco.Web.Common/Security/MemberManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberManager.cs @@ -45,6 +45,9 @@ namespace Umbraco.Cms.Web.Common.Security _httpContextAccessor = httpContextAccessor; } + /// + public override bool SupportsUserTwoFactor => true; + /// public async Task IsMemberAuthorizedAsync(IEnumerable allowTypes = null, IEnumerable allowGroups = null, IEnumerable allowMembers = null) { diff --git a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs index 6407c4fac8..e8bf1c2eb3 100644 --- a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; +using System.Security.Principal; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -9,6 +10,8 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; @@ -22,6 +25,7 @@ namespace Umbraco.Cms.Web.Common.Security public class MemberSignInManager : UmbracoSignInManager, IMemberSignInManagerExternalLogins { private readonly IMemberExternalLoginProviders _memberExternalLoginProviders; + private readonly IEventAggregator _eventAggregator; public MemberSignInManager( UserManager memberManager, @@ -31,10 +35,12 @@ namespace Umbraco.Cms.Web.Common.Security ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation, - IMemberExternalLoginProviders memberExternalLoginProviders) : + IMemberExternalLoginProviders memberExternalLoginProviders, + IEventAggregator eventAggregator) : base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) { _memberExternalLoginProviders = memberExternalLoginProviders; + _eventAggregator = eventAggregator; } [Obsolete("Use ctor with all params")] @@ -46,7 +52,9 @@ namespace Umbraco.Cms.Web.Common.Security ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation) : - this(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, StaticServiceProvider.Instance.GetRequiredService()) + this(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } // use default scheme for members @@ -61,30 +69,6 @@ namespace Umbraco.Cms.Web.Common.Security // use default scheme for members protected override string TwoFactorRememberMeAuthenticationType => IdentityConstants.TwoFactorRememberMeScheme; - /// - public override Task GetTwoFactorAuthenticationUserAsync() - => throw new NotImplementedException("Two factor is not yet implemented for members"); - - /// - public override Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) - => throw new NotImplementedException("Two factor is not yet implemented for members"); - - /// - public override Task IsTwoFactorClientRememberedAsync(MemberIdentityUser user) - => throw new NotImplementedException("Two factor is not yet implemented for members"); - - /// - public override Task RememberTwoFactorClientAsync(MemberIdentityUser user) - => throw new NotImplementedException("Two factor is not yet implemented for members"); - - /// - public override Task ForgetTwoFactorClientAsync() - => throw new NotImplementedException("Two factor is not yet implemented for members"); - - /// - public override Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) - => throw new NotImplementedException("Two factor is not yet implemented for members"); - /// public override async Task GetExternalLoginInfoAsync(string expectedXsrf = null) { @@ -369,6 +353,29 @@ namespace Umbraco.Cms.Web.Common.Security private void LogFailedExternalLogin(ExternalLoginInfo loginInfo, MemberIdentityUser user) => Logger.LogWarning("The AutoLinkOptions of the external authentication provider '{LoginProvider}' have refused the login based on the OnExternalLogin method. Affected user id: '{UserId}'", loginInfo.LoginProvider, user.Id); + protected override async Task SignInOrTwoFactorAsync(MemberIdentityUser user, bool isPersistent, + string loginProvider = null, bool bypassTwoFactor = false) + { + var result = await base.SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); + if (result.RequiresTwoFactor) + { + NotifyRequiresTwoFactor(user); + } + + return result; + } + + protected void NotifyRequiresTwoFactor(MemberIdentityUser user) => Notify(user, + (currentUser) => new MemberTwoFactorRequestedNotification(currentUser.Key) + ); + + private T Notify(MemberIdentityUser currentUser, Func createNotification) where T : INotification + { + + var notification = createNotification(currentUser); + _eventAggregator.Publish(notification); + return notification; + } } } diff --git a/src/Umbraco.Web.Common/Security/TwoFactorValidationProvider.cs b/src/Umbraco.Web.Common/Security/TwoFactorValidationProvider.cs new file mode 100644 index 0000000000..32b3226440 --- /dev/null +++ b/src/Umbraco.Web.Common/Security/TwoFactorValidationProvider.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +using Microsoft.Extensions.Logging; + +namespace Umbraco.Cms.Infrastructure.Security +{ + public class TwoFactorBackOfficeValidationProvider : TwoFactorValidationProvider + where TTwoFactorSetupGenerator : ITwoFactorProvider + { + protected TwoFactorBackOfficeValidationProvider(IDataProtectionProvider dataProtectionProvider, IOptions options, ILogger> logger, ITwoFactorLoginService twoFactorLoginService, TTwoFactorSetupGenerator generator) : base(dataProtectionProvider, options, logger, twoFactorLoginService, generator) + { + } + + } + + public class TwoFactorMemberValidationProvider : TwoFactorValidationProvider + where TTwoFactorSetupGenerator : ITwoFactorProvider + { + public TwoFactorMemberValidationProvider(IDataProtectionProvider dataProtectionProvider, IOptions options, ILogger> logger, ITwoFactorLoginService twoFactorLoginService, TTwoFactorSetupGenerator generator) : base(dataProtectionProvider, options, logger, twoFactorLoginService, generator) + { + } + + } + + public class TwoFactorValidationProvider + : DataProtectorTokenProvider + where TUmbracoIdentityUser : UmbracoIdentityUser + where TTwoFactorSetupGenerator : ITwoFactorProvider + { + private readonly ITwoFactorLoginService _twoFactorLoginService; + private readonly TTwoFactorSetupGenerator _generator; + + protected TwoFactorValidationProvider( + + IDataProtectionProvider dataProtectionProvider, + IOptions options, + ILogger> logger, + ITwoFactorLoginService twoFactorLoginService, + TTwoFactorSetupGenerator generator) + : base(dataProtectionProvider, options, logger) + { + _twoFactorLoginService = twoFactorLoginService; + _generator = generator; + } + + public override Task CanGenerateTwoFactorTokenAsync(UserManager manager, + TUmbracoIdentityUser user) => Task.FromResult(_generator is not null); + + public override async Task ValidateAsync(string purpose, string token, + UserManager manager, TUmbracoIdentityUser user) + { + var secret = + await _twoFactorLoginService.GetSecretForUserAndProviderAsync(GetUserKey(user), _generator.ProviderName); + + if (secret is null) + { + return false; + } + + var validToken = _generator.ValidateTwoFactorPIN(secret, token); + + + return validToken; + } + + protected Guid GetUserKey(TUmbracoIdentityUser user) + { + + switch (user) + { + case MemberIdentityUser memberIdentityUser: + return memberIdentityUser.Key; + case BackOfficeIdentityUser backOfficeIdentityUser: + return backOfficeIdentityUser.Key; + default: + throw new NotSupportedException( + "Current we only support MemberIdentityUser and BackOfficeIdentityUser"); + } + + } + + } +} diff --git a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml index 095c3c050d..1b1ebd7284 100644 --- a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml +++ b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/EditProfile.cshtml @@ -1,7 +1,4 @@ @inherits Umbraco.Cms.Web.Common.Macros.PartialViewMacroPage - -@using Umbraco.Cms.Core -@using Umbraco.Cms.Core.Security @using Umbraco.Cms.Core.Services @using Umbraco.Cms.Web.Common.Security @using Umbraco.Cms.Web.Website.Controllers @@ -11,6 +8,7 @@ @inject IMemberExternalLoginProviders memberExternalLoginProviders @inject IExternalLoginWithKeyService externalLoginWithKeyService @{ + // Build a profile model to edit var profileModel = await memberModelBuilderFactory .CreateProfileModel() diff --git a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml index 85b7f53c24..7ba7f2acca 100644 --- a/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml +++ b/src/Umbraco.Web.UI/umbraco/PartialViewMacros/Templates/Login.cshtml @@ -1,9 +1,12 @@ @inherits Umbraco.Cms.Web.Common.Macros.PartialViewMacroPage + @using Umbraco.Cms.Web.Common.Models @using Umbraco.Cms.Web.Common.Security @using Umbraco.Cms.Web.Website.Controllers +@using Umbraco.Cms.Core.Services @using Umbraco.Extensions @inject IMemberExternalLoginProviders memberExternalLoginProviders +@inject ITwoFactorLoginService twoFactorLoginService @{ var loginModel = new LoginModel(); // You can modify this to redirect to a different URL instead of the current one @@ -14,6 +17,33 @@ + +@if (ViewData.TryGetTwoFactorProviderNames(out var providerNames)) +{ + + foreach (var providerName in providerNames) + { +
+

Two factor with @providerName.

+
+ @using (Html.BeginUmbracoForm(nameof(UmbTwoFactorLoginController.Verify2FACode))) + { + + + + Input security code:
+ +
+
+ } +
+ } + +} +else +{ + + +} diff --git a/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs b/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs index 2d5ec250e9..c43754e170 100644 --- a/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs +++ b/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs @@ -4,12 +4,13 @@ using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -27,9 +28,12 @@ namespace Umbraco.Cms.Web.Website.Controllers public class UmbExternalLoginController : SurfaceController { private readonly IMemberManager _memberManager; + private readonly ITwoFactorLoginService _twoFactorLoginService; + private readonly ILogger _logger; private readonly IMemberSignInManagerExternalLogins _memberSignInManager; public UmbExternalLoginController( + ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, IUmbracoDatabaseFactory databaseFactory, ServiceContext services, @@ -37,7 +41,8 @@ namespace Umbraco.Cms.Web.Website.Controllers IProfilingLogger profilingLogger, IPublishedUrlProvider publishedUrlProvider, IMemberSignInManagerExternalLogins memberSignInManager, - IMemberManager memberManager) + IMemberManager memberManager, + ITwoFactorLoginService twoFactorLoginService) : base( umbracoContextAccessor, databaseFactory, @@ -46,8 +51,10 @@ namespace Umbraco.Cms.Web.Website.Controllers profilingLogger, publishedUrlProvider) { + _logger = logger; _memberSignInManager = memberSignInManager; _memberManager = memberManager; + _twoFactorLoginService = twoFactorLoginService; } /// @@ -108,14 +115,12 @@ namespace Umbraco.Cms.Web.Website.Controllers $"No local user found for the login provider {loginInfo.LoginProvider} - {loginInfo.ProviderKey}"); } - // create a with information to display a custom two factor send code view - var verifyResponse = - new ObjectResult(new { userId = attemptedUser.Id }) - { - StatusCode = StatusCodes.Status402PaymentRequired - }; - return verifyResponse; + var providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(attemptedUser.Key); + ViewData.SetTwoFactorProviderNames(providerNames); + + return CurrentUmbracoPage(); + } if (result == SignInResult.LockedOut) diff --git a/src/Umbraco.Web.Website/Controllers/UmbLoginController.cs b/src/Umbraco.Web.Website/Controllers/UmbLoginController.cs index afeb41a252..9dbcd292e4 100644 --- a/src/Umbraco.Web.Website/Controllers/UmbLoginController.cs +++ b/src/Umbraco.Web.Website/Controllers/UmbLoginController.cs @@ -1,14 +1,20 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Web.Common.ActionsResults; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.Models; using Umbraco.Cms.Web.Common.Security; @@ -20,7 +26,29 @@ namespace Umbraco.Cms.Web.Website.Controllers public class UmbLoginController : SurfaceController { private readonly IMemberSignInManager _signInManager; + private readonly IMemberManager _memberManager; + private readonly ITwoFactorLoginService _twoFactorLoginService; + + [ActivatorUtilitiesConstructor] + public UmbLoginController( + IUmbracoContextAccessor umbracoContextAccessor, + IUmbracoDatabaseFactory databaseFactory, + ServiceContext services, + AppCaches appCaches, + IProfilingLogger profilingLogger, + IPublishedUrlProvider publishedUrlProvider, + IMemberSignInManager signInManager, + IMemberManager memberManager, + ITwoFactorLoginService twoFactorLoginService) + : base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider) + { + _signInManager = signInManager; + _memberManager = memberManager; + _twoFactorLoginService = twoFactorLoginService; + } + + [Obsolete("Use ctor with all params")] public UmbLoginController( IUmbracoContextAccessor umbracoContextAccessor, IUmbracoDatabaseFactory databaseFactory, @@ -29,9 +57,11 @@ namespace Umbraco.Cms.Web.Website.Controllers IProfilingLogger profilingLogger, IPublishedUrlProvider publishedUrlProvider, IMemberSignInManager signInManager) - : base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider) + : this(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider, signInManager, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { - _signInManager = signInManager; + } [HttpPost] @@ -74,15 +104,28 @@ namespace Umbraco.Cms.Web.Website.Controllers if (result.RequiresTwoFactor) { - throw new NotImplementedException("Two factor support is not supported for Umbraco members yet"); + MemberIdentityUser attemptedUser = await _memberManager.FindByNameAsync(model.Username); + if (attemptedUser == null) + { + return new ValidationErrorResult( + $"No local member found for username {model.Username}"); + } + + var providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(attemptedUser.Key); + ViewData.SetTwoFactorProviderNames(providerNames); + } + else if (result.IsLockedOut) + { + ModelState.AddModelError("loginModel", "Member is locked out"); + } + else if (result.IsNotAllowed) + { + ModelState.AddModelError("loginModel", "Member is not allowed"); + } + else + { + ModelState.AddModelError("loginModel", "Invalid username or password"); } - - // TODO: We can check for these and respond differently if we think it's important - // result.IsLockedOut - // result.IsNotAllowed - - // Don't add a field level error, just model level. - ModelState.AddModelError("loginModel", "Invalid username or password"); return CurrentUmbracoPage(); } @@ -97,5 +140,7 @@ namespace Umbraco.Cms.Web.Website.Controllers model.RedirectUrl = redirectUrl.ToString(); } } + + } } diff --git a/src/Umbraco.Web.Website/Controllers/UmbTwoFactorLoginController.cs b/src/Umbraco.Web.Website/Controllers/UmbTwoFactorLoginController.cs new file mode 100644 index 0000000000..ba86e63a36 --- /dev/null +++ b/src/Umbraco.Web.Website/Controllers/UmbTwoFactorLoginController.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Web.Common.ActionsResults; +using Umbraco.Cms.Web.Common.Filters; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; +using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; + +namespace Umbraco.Cms.Web.Website.Controllers +{ + [UmbracoMemberAuthorize] + public class UmbTwoFactorLoginController : SurfaceController + { + private readonly IMemberManager _memberManager; + private readonly ITwoFactorLoginService _twoFactorLoginService; + private readonly ILogger _logger; + private readonly IMemberSignInManagerExternalLogins _memberSignInManager; + + public UmbTwoFactorLoginController( + ILogger logger, + IUmbracoContextAccessor umbracoContextAccessor, + IUmbracoDatabaseFactory databaseFactory, + ServiceContext services, + AppCaches appCaches, + IProfilingLogger profilingLogger, + IPublishedUrlProvider publishedUrlProvider, + IMemberSignInManagerExternalLogins memberSignInManager, + IMemberManager memberManager, + ITwoFactorLoginService twoFactorLoginService) + : base( + umbracoContextAccessor, + databaseFactory, + services, + appCaches, + profilingLogger, + publishedUrlProvider) + { + _logger = logger; + _memberSignInManager = memberSignInManager; + _memberManager = memberManager; + _twoFactorLoginService = twoFactorLoginService; + } + + /// + /// Used to retrieve the 2FA providers for code submission + /// + /// + [AllowAnonymous] + public async Task>> Get2FAProviders() + { + var user = await _memberSignInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + _logger.LogWarning("Get2FAProviders :: No verified member found, returning 404"); + return NotFound(); + } + + var userFactors = await _memberManager.GetValidTwoFactorProvidersAsync(user); + return new ObjectResult(userFactors); + } + + [AllowAnonymous] + public async Task Verify2FACode(Verify2FACodeModel model, string returnUrl = null) + { + var user = await _memberSignInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + _logger.LogWarning("PostVerify2FACode :: No verified member found, returning 404"); + return NotFound(); + } + + if (ModelState.IsValid) + { + var result = await _memberSignInManager.TwoFactorSignInAsync(model.Provider, model.Code, model.IsPersistent, model.RememberClient); + if (result.Succeeded) + { + return RedirectToLocal(returnUrl); + } + + if (result.IsLockedOut) + { + ModelState.AddModelError(nameof(Verify2FACodeModel.Code), "Member is locked out"); + } + else if (result.IsNotAllowed) + { + ModelState.AddModelError(nameof(Verify2FACodeModel.Code), "Member is not allowed"); + } + else + { + ModelState.AddModelError(nameof(Verify2FACodeModel.Code), "Invalid code"); + } + } + + //We need to set this, to ensure we show the 2fa login page + var providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(user.Key); + ViewData.SetTwoFactorProviderNames(providerNames); + return CurrentUmbracoPage(); + } + + [HttpPost] + public async Task ValidateAndSaveSetup(string providerName, string secret, string code, string returnUrl = null) + { + var member = await _memberManager.GetCurrentMemberAsync(); + + var isValid = _twoFactorLoginService.ValidateTwoFactorSetup(providerName, secret, code); + + if (isValid == false) + { + ModelState.AddModelError(nameof(code), "Invalid Code"); + + return CurrentUmbracoPage(); + } + + var twoFactorLogin = new TwoFactorLogin() + { + Confirmed = true, + Secret = secret, + UserOrMemberKey = member.Key, + ProviderName = providerName + }; + + await _twoFactorLoginService.SaveAsync(twoFactorLogin); + + return RedirectToLocal(returnUrl); + } + + [HttpPost] + public async Task Disable(string providerName, string returnUrl = null) + { + var member = await _memberManager.GetCurrentMemberAsync(); + + var success = await _twoFactorLoginService.DisableAsync(member.Key, providerName); + + if (!success) + { + return CurrentUmbracoPage(); + } + + return RedirectToLocal(returnUrl); + } + + private IActionResult RedirectToLocal(string returnUrl) => + Url.IsLocalUrl(returnUrl) ? Redirect(returnUrl) : RedirectToCurrentUmbracoPage(); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs index e09fb70d8e..dedccca16e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs @@ -52,7 +52,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security new UmbracoMapper(new MapDefinitionCollection(() => mapDefinitions), scopeProvider), scopeProvider, new IdentityErrorDescriber(), - Mock.Of(), Mock.Of()); + Mock.Of(), + Mock.Of(), + Mock.Of()); _mockIdentityOptions = new Mock>(); var idOptions = new IdentityOptions { Lockout = { AllowedForNewUsers = false } }; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs index 4ed2f0895d..14261e34fb 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs @@ -38,7 +38,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Security mockScopeProvider.Object, new IdentityErrorDescriber(), Mock.Of(), - Mock.Of() + Mock.Of(), + Mock.Of() ); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs index e616cafd08..ccba5a4494 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Net; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -47,6 +48,11 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security { o.Cookie.Name = IdentityConstants.TwoFactorUserIdScheme; o.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }) + .AddCookie(IdentityConstants.TwoFactorRememberMeScheme, o => + { + o.Cookie.Name = IdentityConstants.TwoFactorRememberMeScheme; + o.ExpireTimeSpan = TimeSpan.FromMinutes(5); }); IServiceProvider serviceProvider = serviceProviderFactory.CreateServiceProvider(serviceCollection); var httpContextFactory = new DefaultHttpContextFactory(serviceProvider); @@ -66,7 +72,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Security _mockLogger.Object, Mock.Of(), Mock.Of>(), - Mock.Of() + Mock.Of(), + Mock.Of() ); } private static Mock MockMemberManager() From 4843b28184b0c9c9c40fe96b06633f000e685954 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 21 Jan 2022 19:30:38 +0100 Subject: [PATCH 53/53] added contains check for acceptance tests --- .../cypress/integration/Languages/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Umbraco.Tests.AcceptanceTest/cypress/integration/Languages/languages.ts b/tests/Umbraco.Tests.AcceptanceTest/cypress/integration/Languages/languages.ts index 5898335105..33d5de24cb 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/cypress/integration/Languages/languages.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/cypress/integration/Languages/languages.ts @@ -13,6 +13,7 @@ context('Languages', () => { cy.umbracoEnsureLanguageCultureNotExists(culture); cy.umbracoSection('settings'); + cy.get('.umb-tree-root-link').contains('Settings') // Enter language tree and create new language cy.umbracoTreeItem('settings', ['Languages']).click(); cy.umbracoButtonByLabelKey('languages_addLanguage').click();