diff --git a/.editorconfig b/.editorconfig index d4094b2cf3..eba04ad326 100644 --- a/.editorconfig +++ b/.editorconfig @@ -306,48 +306,6 @@ dotnet_naming_rule.other_public_protected_fields_disallowed_rule.symbols dotnet_naming_rule.other_public_protected_fields_disallowed_rule.style = disallowed_style dotnet_naming_rule.other_public_protected_fields_disallowed_rule.severity = error -########################################## -# StyleCop Field Naming Rules -# Naming rules for fields follow the StyleCop analyzers -# This does not override any rules using disallowed_style above -# https://github.com/DotNetAnalyzers/StyleCopAnalyzers -########################################## - -# All constant fields must be PascalCase -# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1303.md -dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private -dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const -dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field -dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group -dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = pascal_case_style -dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.severity = warning - -# All static readonly fields must be PascalCase -# Ajusted to ignore private fields. -# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1311.md -dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected -dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly -dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field -dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group -dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = pascal_case_style -dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.severity = warning - -# No non-private instance fields are allowed -# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1401.md -dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected -dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field -dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = stylecop_fields_must_be_private_group -dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.style = disallowed_style -dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.severity = error - -# Local variables must be camelCase -# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1312.md -dotnet_naming_symbols.stylecop_local_fields_group.applicable_accessibilities = local -dotnet_naming_symbols.stylecop_local_fields_group.applicable_kinds = local -dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.symbols = stylecop_local_fields_group -dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.style = camel_case_style -dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.severity = silent - # This rule should never fire. However, it's included for at least two purposes: # First, it helps to understand, reason about, and root-case certain types of issues, such as bugs in .editorconfig parsers. # Second, it helps to raise immediate awareness if a new field type is added (as occurred recently in C#). @@ -357,7 +315,6 @@ dotnet_naming_rule.sanity_check_uncovered_field_case_rule.symbols = sanity_chec dotnet_naming_rule.sanity_check_uncovered_field_case_rule.style = internal_error_style dotnet_naming_rule.sanity_check_uncovered_field_case_rule.severity = error - ########################################## # Other Naming Rules ########################################## diff --git a/.globalconfig b/.globalconfig new file mode 100644 index 0000000000..8c0929382d --- /dev/null +++ b/.globalconfig @@ -0,0 +1,82 @@ +is_global = true + +########################################## +# StyleCopAnalyzers Settings +########################################## + +# All constant fields must be PascalCase +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1303.md +dotnet_naming_symbols.stylecop_constant_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected, private +dotnet_naming_symbols.stylecop_constant_fields_group.required_modifiers = const +dotnet_naming_symbols.stylecop_constant_fields_group.applicable_kinds = field +dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.symbols = stylecop_constant_fields_group +dotnet_naming_rule.stylecop_constant_fields_must_be_pascal_case_rule.style = pascal_case_style + +# All static readonly fields must be PascalCase +# Ajusted to ignore private fields. +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1311.md +dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected +dotnet_naming_symbols.stylecop_static_readonly_fields_group.required_modifiers = static, readonly +dotnet_naming_symbols.stylecop_static_readonly_fields_group.applicable_kinds = field +dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.symbols = stylecop_static_readonly_fields_group +dotnet_naming_rule.stylecop_static_readonly_fields_must_be_pascal_case_rule.style = pascal_case_style + +# No non-private instance fields are allowed +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1401.md +dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_accessibilities = public, internal, protected_internal, protected, private_protected +dotnet_naming_symbols.stylecop_fields_must_be_private_group.applicable_kinds = field +dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.symbols = stylecop_fields_must_be_private_group +dotnet_naming_rule.stylecop_instance_fields_must_be_private_rule.style = disallowed_style + +# Local variables must be camelCase +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1312.md +dotnet_naming_symbols.stylecop_local_fields_group.applicable_accessibilities = local +dotnet_naming_symbols.stylecop_local_fields_group.applicable_kinds = local +dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.symbols = stylecop_local_fields_group +dotnet_naming_rule.stylecop_local_fields_must_be_camel_case_rule.style = camel_case_style + +########################################## +# StyleCopAnalyzers rule severity +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers +########################################## + +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.DocumentationRules.severity = suggestion +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.ReadabilityRules.severity = suggestion +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.NamingRules.severity = suggestion +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.SpacingRules.severity = suggestion +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = suggestion +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = suggestion +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = suggestion + +dotnet_diagnostic.SA1636.severity = none # SA1636: File header copyright text should match +dotnet_diagnostic.SA1101.severity = none # PrefixLocalCallsWithThis - stylecop appears to be ignoring dotnet_style_qualification_for_* + +dotnet_diagnostic.SA1503.severity = warning # BracesMustNotBeOmitted +dotnet_diagnostic.SA1117.severity = warning # ParametersMustBeOnSameLineOrSeparateLines +dotnet_diagnostic.SA1116.severity = warning # SplitParametersMustStartOnLineAfterDeclaration +dotnet_diagnostic.SA1122.severity = warning # UseStringEmptyForEmptyStrings +dotnet_diagnostic.SA1028.severity = warning # CodeMustNotContainTrailingWhitespace +dotnet_diagnostic.SA1500.severity = warning # BracesForMultiLineStatementsMustNotShareLine +dotnet_diagnostic.SA1401.severity = warning # FieldsMustBePrivate +dotnet_diagnostic.SA1519.severity = warning # BracesMustNotBeOmittedFromMultiLineChildStatement +dotnet_diagnostic.SA1111.severity = warning # ClosingParenthesisMustBeOnLineOfLastParameter +dotnet_diagnostic.SA1520.severity = warning # UseBracesConsistently +dotnet_diagnostic.SA1407.severity = warning # ArithmeticExpressionsMustDeclarePrecedence +dotnet_diagnostic.SA1400.severity = warning # AccessModifierMustBeDeclared +dotnet_diagnostic.SA1119.severity = warning # StatementMustNotUseUnnecessaryParenthesis +dotnet_diagnostic.SA1649.severity = warning # FileNameMustMatchTypeName +dotnet_diagnostic.SA1121.severity = warning # UseBuiltInTypeAlias +dotnet_diagnostic.SA1132.severity = warning # DoNotCombineFields +dotnet_diagnostic.SA1134.severity = warning # AttributesMustNotShareLine +dotnet_diagnostic.SA1106.severity = warning # CodeMustNotContainEmptyStatements +dotnet_diagnostic.SA1312.severity = warning # VariableNamesMustBeginWithLowerCaseLetter +dotnet_diagnostic.SA1303.severity = warning # ConstFieldNamesMustBeginWithUpperCaseLetter +dotnet_diagnostic.SA1310.severity = warning # FieldNamesMustNotContainUnderscore +dotnet_diagnostic.SA1130.severity = warning # UseLambdaSyntax +dotnet_diagnostic.SA1405.severity = warning # DebugAssertMustProvideMessageText +dotnet_diagnostic.SA1205.severity = warning # PartialElementsMustDeclareAccess +dotnet_diagnostic.SA1306.severity = warning # FieldNamesMustBeginWithLowerCaseLetter +dotnet_diagnostic.SA1209.severity = warning # UsingAliasDirectivesMustBePlacedAfterOtherUsingDirectives +dotnet_diagnostic.SA1216.severity = warning # UsingStaticDirectivesMustBePlacedAtTheCorrectLocation +dotnet_diagnostic.SA1133.severity = warning # DoNotCombineAttributes +dotnet_diagnostic.SA1135.severity = warning # UsingDirectivesMustBeQualified diff --git a/Directory.Build.props b/Directory.Build.props index 74f1ebad3d..fcf605f555 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,12 +2,6 @@ - - + - - - - $(MSBuildThisFileDirectory)codeanalysis.ruleset - diff --git a/build/templates/UmbracoPackage/.template.config/template.json b/build/templates/UmbracoPackage/.template.config/template.json index 1a4dd16fd7..082f9301bf 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.3.0-rc", + "defaultValue": "9.4.0-rc", "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 fd41de8d1c..810940c4eb 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.3.0-rc", + "defaultValue": "9.4.0-rc", "description": "The version of Umbraco to load using NuGet", "replaces": "UMBRACO_VERSION_FROM_TEMPLATE" }, diff --git a/build/templates/UmbracoProject/UmbracoProject.csproj b/build/templates/UmbracoProject/UmbracoProject.csproj index 3fa1eb2f36..6b47686415 100644 --- a/build/templates/UmbracoProject/UmbracoProject.csproj +++ b/build/templates/UmbracoProject/UmbracoProject.csproj @@ -12,9 +12,13 @@ - - - + + + + diff --git a/codeanalysis.ruleset b/codeanalysis.ruleset deleted file mode 100644 index ab5ad88f57..0000000000 --- a/codeanalysis.ruleset +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 995c8afebd..68962caef4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,10 +3,10 @@ - 9.3.0 - 9.3.0 - 9.3.0-rc - 9.3.0 + 9.4.0 + 9.4.0 + 9.4.0-rc + 9.4.0 9.0 en-US Umbraco CMS diff --git a/src/JsonSchema/AppSettings.cs b/src/JsonSchema/AppSettings.cs index 048513a5da..73c5ea18f5 100644 --- a/src/JsonSchema/AppSettings.cs +++ b/src/JsonSchema/AppSettings.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Deploy.Core.Configuration.DeployConfiguration; using Umbraco.Deploy.Core.Configuration.DeployProjectConfiguration; @@ -88,6 +89,8 @@ namespace JsonSchema public LegacyPasswordMigrationSettings LegacyPasswordMigration { get; set; } public ContentDashboardSettings ContentDashboard { get; set; } + + public HelpPageSettings HelpPage { get; set; } } /// diff --git a/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs index 3f8546a1ad..768f7c2088 100644 --- a/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentDashboardSettings.cs @@ -1,6 +1,7 @@ using System.ComponentModel; +using Umbraco.Cms.Core.Configuration.Models; -namespace Umbraco.Cms.Core.Configuration.Models +namespace Umbraco.Cms.Core.Configuration { /// /// Typed configuration options for content dashboard settings. diff --git a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs index 1caa81d80a..93a97355d9 100644 --- a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs @@ -156,6 +156,7 @@ namespace Umbraco.Cms.Core.Configuration.Models internal const bool StaticShowDeprecatedPropertyEditors = false; internal const string StaticLoginBackgroundImage = "assets/img/login.jpg"; internal const string StaticLoginLogoImage = "assets/img/application/umbraco_logo_white.svg"; + internal const bool StaticHideBackOfficeLogo = false; /// /// Gets or sets a value for the content notification settings. @@ -219,6 +220,12 @@ namespace Umbraco.Cms.Core.Configuration.Models [DefaultValue(StaticLoginLogoImage)] public string LoginLogoImage { get; set; } = StaticLoginLogoImage; + /// + /// Gets or sets a value indicating whether to hide the backoffice umbraco logo or not. + /// + [DefaultValue(StaticHideBackOfficeLogo)] + public bool HideBackOfficeLogo { get; set; } = StaticHideBackOfficeLogo; + /// /// Get or sets the model representing the global content version cleanup policy /// diff --git a/src/Umbraco.Core/Configuration/Models/HelpPageSettings.cs b/src/Umbraco.Core/Configuration/Models/HelpPageSettings.cs new file mode 100644 index 0000000000..3bd518b37e --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/HelpPageSettings.cs @@ -0,0 +1,11 @@ +namespace Umbraco.Cms.Core.Configuration.Models +{ + [UmbracoOptions(Constants.Configuration.ConfigHelpPage)] + public class HelpPageSettings + { + /// + /// Gets or sets the allowed addresses to retrieve data for the content dashboard. + /// + public string[] HelpPageUrlAllowList { get; set; } + } +} diff --git a/src/Umbraco.Core/Configuration/Models/HostingSettings.cs b/src/Umbraco.Core/Configuration/Models/HostingSettings.cs index b9e11e99ca..cbe1fa6965 100644 --- a/src/Umbraco.Core/Configuration/Models/HostingSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/HostingSettings.cs @@ -22,7 +22,7 @@ namespace Umbraco.Cms.Core.Configuration.Models /// /// Gets or sets a value for the location of temporary files. /// - [DefaultValue(StaticLocalTempStorageLocation)] + [DefaultValue(StaticLocalTempStorageLocation)] public LocalTempStorage LocalTempStorageLocation { get; set; } = Enum.Parse(StaticLocalTempStorageLocation); /// @@ -31,5 +31,10 @@ namespace Umbraco.Cms.Core.Configuration.Models /// true if [debug mode]; otherwise, false. [DefaultValue(StaticDebug)] public bool Debug { get; set; } = StaticDebug; + + /// + /// Gets or sets a value specifying the name of the site. + /// + public string SiteName { get; set; } } } diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index 051c31dc26..2bdcaef7f3 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 CharItem[] DefaultCharCollection = + internal static readonly Umbraco.Cms.Core.Configuration.Models.CharItem[] DefaultCharCollection = { new () { Char = " ", Replacement = "-" }, new () { Char = "\"", Replacement = string.Empty }, @@ -84,6 +84,16 @@ namespace Umbraco.Cms.Core.Configuration.Models /// /// Add additional character replacements, or override defaults /// - public IEnumerable UserDefinedCharCollection { get; set; } + public IEnumerable UserDefinedCharCollection { get; set; } + + [Obsolete("Use CharItem in the Umbraco.Cms.Core.Configuration.Models namespace instead. Scheduled for removal in V10.")] + public class CharItem : IChar + { + /// + public string Char { get; set; } + + /// + public string Replacement { get; set; } + } } } diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index 7d4dd45fb8..982ba8c63e 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -11,6 +11,8 @@ namespace Umbraco.Cms.Core.Configuration.Models [UmbracoOptions(Constants.Configuration.ConfigSecurity)] public class SecuritySettings { + internal const bool StaticMemberBypassTwoFactorForExternalLogins = true; + internal const bool StaticUserBypassTwoFactorForExternalLogins = true; internal const bool StaticKeepUserLoggedIn = false; internal const bool StaticHideDisabledUsersInBackOffice = false; internal const bool StaticAllowPasswordReset = true; @@ -66,5 +68,17 @@ namespace Umbraco.Cms.Core.Configuration.Models /// Gets or sets a value for the member password settings. /// public MemberPasswordConfigurationSettings MemberPassword { get; set; } + + /// + /// Gets or sets a value indicating whether to bypass the two factor requirement in Umbraco when using external login for members. Thereby rely on the External login and potential 2FA at that provider. + /// + [DefaultValue(StaticMemberBypassTwoFactorForExternalLogins)] + public bool MemberBypassTwoFactorForExternalLogins { get; set; } = StaticMemberBypassTwoFactorForExternalLogins; + + /// + /// Gets or sets a value indicating whether to bypass the two factor requirement in Umbraco when using external login for users. Thereby rely on the External login and potential 2FA at that provider. + /// + [DefaultValue(StaticUserBypassTwoFactorForExternalLogins)] + public bool UserBypassTwoFactorForExternalLogins { get; set; } = StaticUserBypassTwoFactorForExternalLogins; } } diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index ab951618e3..bdbd13b2a4 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -55,6 +55,7 @@ namespace Umbraco.Cms.Core public const string ConfigRichTextEditor = ConfigPrefix + "RichTextEditor"; public const string ConfigPackageMigration = ConfigPrefix + "PackageMigration"; public const string ConfigContentDashboard = ConfigPrefix + "ContentDashboard"; + public const string ConfigHelpPage = ConfigPrefix + "HelpPage"; } } } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index f0cbf7f95d..91e6f71415 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -4,6 +4,7 @@ using System.Reflection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Configuration.Models.Validation; using Umbraco.Extensions; @@ -86,7 +87,8 @@ namespace Umbraco.Cms.Core.DependencyInjection .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() - .AddUmbracoOptions(); + .AddUmbracoOptions() + .AddUmbracoOptions(); builder.Services.Configure(options => options.MergeReplacements(builder.Config)); diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index eacd615830..c4a95d45e5 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -263,6 +263,9 @@ namespace Umbraco.Cms.Core.DependencyInjection // Register telemetry service used to gather data about installed packages Services.AddUnique(); + + // Register a noop IHtmlSanitizer to be replaced + Services.AddUnique(); } } } 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/PropertyTagsExtensions.cs b/src/Umbraco.Core/Models/PropertyTagsExtensions.cs index 390f644831..a9da454986 100644 --- a/src/Umbraco.Core/Models/PropertyTagsExtensions.cs +++ b/src/Umbraco.Core/Models/PropertyTagsExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.Logging; @@ -68,11 +68,13 @@ namespace Umbraco.Extensions 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(serializer.Serialize(currentTags.Union(trimmedTags).ToArray()), culture); // json array + var updatedTags = currentTags.Union(trimmedTags).ToArray(); + var updatedValue = updatedTags.Length == 0 ? null : serializer.Serialize(updatedTags); + property.SetValue(updatedValue, culture); // json array break; } } @@ -81,11 +83,12 @@ namespace Umbraco.Extensions 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(serializer.Serialize(trimmedTags), culture); // json array + var updatedValue = trimmedTags.Length == 0 ? null : serializer.Serialize(trimmedTags); + property.SetValue(updatedValue, culture); // json array break; } } @@ -124,11 +127,13 @@ namespace Umbraco.Extensions 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(serializer.Serialize(currentTags.Except(trimmedTags).ToArray()), culture); // json array + var updatedTags = currentTags.Except(trimmedTags).ToArray(); + var updatedValue = updatedTags.Length == 0 ? null : serializer.Serialize(updatedTags); + property.SetValue(updatedValue, culture); // json array break; } } @@ -160,7 +165,7 @@ namespace Umbraco.Extensions case TagsStorageType.Json: try { - return serializer.Deserialize(value).Select(x => x.ToString().Trim()); + return serializer.Deserialize(value).Select(x => x.Trim()); } catch (Exception) { 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/IUmbracoApplicationLifetimeNotification.cs b/src/Umbraco.Core/Notifications/IUmbracoApplicationLifetimeNotification.cs new file mode 100644 index 0000000000..4b0ea6826a --- /dev/null +++ b/src/Umbraco.Core/Notifications/IUmbracoApplicationLifetimeNotification.cs @@ -0,0 +1,17 @@ +namespace Umbraco.Cms.Core.Notifications +{ + /// + /// Represents an Umbraco application lifetime (starting, started, stopping, stopped) notification. + /// + /// + public interface IUmbracoApplicationLifetimeNotification : INotification + { + /// + /// Gets a value indicating whether Umbraco is restarting (e.g. after an install or upgrade). + /// + /// + /// true if Umbraco is restarting; otherwise, false. + /// + bool IsRestarting { get; } + } +} 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/Notifications/UmbracoApplicationStartedNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs index a3d38720d7..196af7dfe1 100644 --- a/src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStartedNotification.cs @@ -3,7 +3,16 @@ 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 - { } + /// + public class UmbracoApplicationStartedNotification : IUmbracoApplicationLifetimeNotification + { + /// + /// Initializes a new instance of the class. + /// + /// Indicates whether Umbraco is restarting. + public UmbracoApplicationStartedNotification(bool isRestarting) => IsRestarting = isRestarting; + + /// + public bool IsRestarting { get; } + } } diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs index dd60f9431c..82b87aa3bf 100644 --- a/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStartingNotification.cs @@ -1,16 +1,34 @@ +using System; + namespace Umbraco.Cms.Core.Notifications { /// /// Notification that occurs at the very end of the Umbraco boot process (after all s are initialized). /// - /// - public class UmbracoApplicationStartingNotification : INotification + /// + public class UmbracoApplicationStartingNotification : IUmbracoApplicationLifetimeNotification { /// /// Initializes a new instance of the class. /// /// The runtime level - public UmbracoApplicationStartingNotification(RuntimeLevel runtimeLevel) => RuntimeLevel = runtimeLevel; + [Obsolete("Use ctor with all params")] + public UmbracoApplicationStartingNotification(RuntimeLevel runtimeLevel) + : this(runtimeLevel, false) + { + // TODO: Remove this constructor in V10 + } + + /// + /// Initializes a new instance of the class. + /// + /// The runtime level + /// Indicates whether Umbraco is restarting. + public UmbracoApplicationStartingNotification(RuntimeLevel runtimeLevel, bool isRestarting) + { + RuntimeLevel = runtimeLevel; + IsRestarting = isRestarting; + } /// /// Gets the runtime level. @@ -19,5 +37,8 @@ namespace Umbraco.Cms.Core.Notifications /// The runtime level. /// public RuntimeLevel RuntimeLevel { get; } + + /// + public bool IsRestarting { get; } } } diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs index be4c6ccfd4..c6dac40a26 100644 --- a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppedNotification.cs @@ -3,7 +3,16 @@ namespace Umbraco.Cms.Core.Notifications /// /// Notification that occurs when Umbraco has completely shutdown. /// - /// - public class UmbracoApplicationStoppedNotification : INotification - { } + /// + public class UmbracoApplicationStoppedNotification : IUmbracoApplicationLifetimeNotification + { + /// + /// Initializes a new instance of the class. + /// + /// Indicates whether Umbraco is restarting. + public UmbracoApplicationStoppedNotification(bool isRestarting) => IsRestarting = isRestarting; + + /// + public bool IsRestarting { get; } + } } diff --git a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs index 6d5234bbcc..062ca954d9 100644 --- a/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs +++ b/src/Umbraco.Core/Notifications/UmbracoApplicationStoppingNotification.cs @@ -1,9 +1,30 @@ +using System; + namespace Umbraco.Cms.Core.Notifications { /// /// Notification that occurs when Umbraco is shutting down (after all s are terminated). /// - /// - public class UmbracoApplicationStoppingNotification : INotification - { } + /// + public class UmbracoApplicationStoppingNotification : IUmbracoApplicationLifetimeNotification + { + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Use ctor with all params")] + public UmbracoApplicationStoppingNotification() + : this(false) + { + // TODO: Remove this constructor in V10 + } + + /// + /// Initializes a new instance of the class. + /// + /// Indicates whether Umbraco is restarting. + public UmbracoApplicationStoppingNotification(bool isRestarting) => IsRestarting = isRestarting; + + /// + public bool IsRestarting { 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/PropertyEditors/DataValueEditor.cs b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs index 07607be2b0..795d75c319 100644 --- a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -6,7 +6,6 @@ using System.Linq; using System.Runtime.Serialization; using System.Xml.Linq; using Microsoft.Extensions.Logging; -using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -149,151 +148,164 @@ namespace Umbraco.Cms.Core.PropertyEditors public virtual bool IsReadOnly => false; /// - /// Used to try to convert the string value to the correct CLR type based on the DatabaseDataType specified for this value editor + /// Used to try to convert the string value to the correct CLR type based on the specified for this value editor. /// - /// - /// + /// The value. + /// + /// The result of the conversion attempt. + /// + /// ValueType was out of range. internal Attempt TryConvertValueToCrlType(object value) { - // if (value is JValue) - // value = value.ToString(); - - //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)) + // Ensure empty string and JSON values are converted to null + if (value is string stringValue && string.IsNullOrWhiteSpace(stringValue)) + { value = null; + } + else if (value is not string && ValueType.InvariantEquals(ValueTypes.Json)) + { + // Only serialize value when it's not already a string + var jsonValue = _jsonSerializer.Serialize(value); + if (jsonValue.DetectIsEmptyJson()) + { + value = null; + } + else + { + value = jsonValue; + } + } + // Convert the string to a known type Type valueType; - //convert the string to a known type switch (ValueTypes.ToStorageType(ValueType)) { case ValueStorageType.Ntext: case ValueStorageType.Nvarchar: valueType = typeof(string); break; - case ValueStorageType.Integer: - //ensure these are nullable so we can return a null if required - //NOTE: This is allowing type of 'long' because I think json.net will deserialize a numerical value as long - // instead of int. Even though our db will not support this (will get truncated), we'll at least parse to this. + case ValueStorageType.Integer: + // Ensure these are nullable so we can return a null if required + // NOTE: This is allowing type of 'long' because I think JSON.NEt will deserialize a numerical value as long instead of int + // Even though our DB will not support this (will get truncated), we'll at least parse to this valueType = typeof(long?); - //if parsing is successful, we need to return as an Int, we're only dealing with long's here because of json.net, we actually - //don't support long values and if we return a long value it will get set as a 'long' on the Property.Value (object) and then - //when we compare the values for dirty tracking we'll be comparing an int -> long and they will not match. + // If parsing is successful, we need to return as an int, we're only dealing with long's here because of JSON.NET, + // we actually don't support long values and if we return a long value, it will get set as a 'long' on the Property.Value (object) and then + // when we compare the values for dirty tracking we'll be comparing an int -> long and they will not match. var result = value.TryConvertTo(valueType); + return result.Success && result.Result != null ? Attempt.Succeed((int)(long)result.Result) : result; case ValueStorageType.Decimal: - //ensure these are nullable so we can return a null if required + // Ensure these are nullable so we can return a null if required valueType = typeof(decimal?); break; case ValueStorageType.Date: - //ensure these are nullable so we can return a null if required + // Ensure these are nullable so we can return a null if required valueType = typeof(DateTime?); break; + default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException("ValueType was out of range."); } + return value.TryConvertTo(valueType); } - /// - /// A method to deserialize the string value that has been saved in the content editor - /// to an object to be stored in the database. - /// - /// - /// - /// The current value that has been persisted to the database for this editor. This value may be useful for - /// how the value then get's deserialized again to be re-persisted. In most cases it will probably not be used. - /// - /// - /// - /// - /// - /// By default this will attempt to automatically convert the string value to the value type supplied by ValueType. - /// - /// If overridden then the object returned must match the type supplied in the ValueType, otherwise persisting the - /// value to the DB will fail when it tries to validate the value type. - /// + /// + /// A method to deserialize the string value that has been saved in the content editor to an object to be stored in the database. + /// + /// The value returned by the editor. + /// The current value that has been persisted to the database for this editor. This value may be useful for how the value then get's deserialized again to be re-persisted. In most cases it will probably not be used. + /// The value that gets persisted to the database. + /// + /// By default this will attempt to automatically convert the string value to the value type supplied by ValueType. + /// If overridden then the object returned must match the type supplied in the ValueType, otherwise persisting the + /// value to the DB will fail when it tries to validate the value type. + /// public virtual object FromEditor(ContentPropertyData editorValue, object currentValue) { - //if it's json but it's empty json, then return null - if (ValueType.InvariantEquals(ValueTypes.Json) && editorValue.Value != null && editorValue.Value.ToString().DetectIsEmptyJson()) - { - return null; - } - var result = TryConvertValueToCrlType(editorValue.Value); if (result.Success == false) { StaticApplicationLogging.Logger.LogWarning("The value {EditorValue} cannot be converted to the type {StorageTypeValue}", editorValue.Value, ValueTypes.ToStorageType(ValueType)); return null; } + return result.Result; } /// - /// A method used to format the database value to a value that can be used by the editor + /// A method used to format the database value to a value that can be used by the editor. /// - /// - /// - /// - /// + /// The property. + /// The culture. + /// The segment. /// + /// ValueType was out of range. /// - /// The object returned will automatically be serialized into json notation. For most property editors - /// the value returned is probably just a string but in some cases a json structure will be returned. + /// The object returned will automatically be serialized into JSON notation. For most property editors + /// the value returned is probably just a string, but in some cases a JSON structure will be returned. /// public virtual object ToEditor(IProperty property, string culture = null, string segment = null) { - var val = property.GetValue(culture, segment); - if (val == null) return string.Empty; + var value = property.GetValue(culture, segment); + if (value == null) + { + return string.Empty; + } switch (ValueTypes.ToStorageType(ValueType)) { case ValueStorageType.Ntext: case ValueStorageType.Nvarchar: - //if it is a string type, we will attempt to see if it is json stored data, if it is we'll try to convert - //to a real json object so we can pass the true json object directly to angular! - var asString = val.ToString(); - if (asString.DetectIsJson()) + // If it is a string type, we will attempt to see if it is JSON stored data, if it is we'll try to convert + // to a real JSON object so we can pass the true JSON object directly to Angular! + var stringValue = value as string ?? value.ToString(); + if (stringValue.DetectIsJson()) { try { - var json = _jsonSerializer.Deserialize(asString); - return json; + return _jsonSerializer.Deserialize(stringValue); } catch { - //swallow this exception, we thought it was json but it really isn't so continue returning a string + // Swallow this exception, we thought it was JSON but it really isn't so continue returning a string } } - return asString; + + return stringValue; + case ValueStorageType.Integer: case ValueStorageType.Decimal: - //Decimals need to be formatted with invariant culture (dots, not commas) - //Anything else falls back to ToString() - var decim = val.TryConvertTo(); - return decim.Success - ? decim.Result.ToString(NumberFormatInfo.InvariantInfo) - : val.ToString(); + // Decimals need to be formatted with invariant culture (dots, not commas) + // Anything else falls back to ToString() + var decimalValue = value.TryConvertTo(); + + return decimalValue.Success + ? decimalValue.Result.ToString(NumberFormatInfo.InvariantInfo) + : value.ToString(); + case ValueStorageType.Date: - var date = val.TryConvertTo(); - if (date.Success == false || date.Result == null) + var dateValue = value.TryConvertTo(); + if (dateValue.Success == false || dateValue.Result == null) { return string.Empty; } - //Dates will be formatted as yyyy-MM-dd HH:mm:ss - return date.Result.Value.ToIsoString(); + + // Dates will be formatted as yyyy-MM-dd HH:mm:ss + return dateValue.Result.Value.ToIsoString(); + default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException("ValueType was out of range."); } } - // TODO: the methods below should be replaced by proper property value convert ToXPath usage! /// diff --git a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs index ae2c3d7f3a..5c27760b2a 100644 --- a/src/Umbraco.Core/Routing/DefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/DefaultUrlProvider.cs @@ -134,8 +134,13 @@ namespace Umbraco.Cms.Core.Routing return GetUrlFromRoute(route, umbracoContext, content.Id, current, mode, culture); } - internal UrlInfo GetUrlFromRoute(string route, IUmbracoContext umbracoContext, int id, Uri current, - UrlMode mode, string culture) + internal UrlInfo GetUrlFromRoute( + string route, + IUmbracoContext umbracoContext, + int id, + Uri current, + UrlMode mode, + string culture) { if (string.IsNullOrWhiteSpace(route)) { @@ -149,12 +154,12 @@ namespace Umbraco.Cms.Core.Routing // route is / or / var pos = route.IndexOf('/'); var path = pos == 0 ? route : route.Substring(pos); - var domainUri = pos == 0 + DomainAndUri domainUri = pos == 0 ? null : 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 || string.IsNullOrEmpty(culture)) + if (domainUri is not null || culture is null || culture.Equals(defaultCulture, StringComparison.InvariantCultureIgnoreCase)) { var url = AssembleUrl(domainUri, path, current, mode).ToString(); return UrlInfo.Url(url, culture); diff --git a/src/Umbraco.Core/Security/IHtmlSanitizer.cs b/src/Umbraco.Core/Security/IHtmlSanitizer.cs new file mode 100644 index 0000000000..9bcfe405dd --- /dev/null +++ b/src/Umbraco.Core/Security/IHtmlSanitizer.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Core.Security +{ + public interface IHtmlSanitizer + { + /// + /// Sanitizes HTML + /// + /// HTML to be sanitized + /// Sanitized HTML + string Sanitize(string html); + } +} diff --git a/src/Umbraco.Core/Security/NoopHtmlSanitizer.cs b/src/Umbraco.Core/Security/NoopHtmlSanitizer.cs new file mode 100644 index 0000000000..2ada23631a --- /dev/null +++ b/src/Umbraco.Core/Security/NoopHtmlSanitizer.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Security +{ + public class NoopHtmlSanitizer : IHtmlSanitizer + { + public string Sanitize(string html) + { + return html; + } + } +} diff --git a/src/Umbraco.Core/Services/ITwoFactorLoginService.cs b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs new file mode 100644 index 0000000000..33a96ad751 --- /dev/null +++ b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services +{ + /// + /// Service handling 2FA logins. + /// + public interface ITwoFactorLoginService : IService + { + /// + /// Deletes all user logins - normally used when a member is deleted. + /// + Task DeleteUserLoginsAsync(Guid userOrMemberKey); + + /// + /// Checks whether 2FA is enabled for the user or member with the specified key. + /// + Task IsTwoFactorEnabledAsync(Guid userOrMemberKey); + + /// + /// Gets the secret for user or member and a specific provider. + /// + Task GetSecretForUserAndProviderAsync(Guid userOrMemberKey, string providerName); + + /// + /// Gets the setup info for a specific user or member and a specific provider. + /// + /// + /// The returned type can be anything depending on the setup providers. You will need to cast it to the type handled by the provider. + /// + Task GetSetupInfoAsync(Guid userOrMemberKey, string providerName); + + /// + /// Gets all registered providers names. + /// + IEnumerable GetAllProviderNames(); + + /// + /// Disables the 2FA provider with the specified provider name for the specified user or member. + /// + Task DisableAsync(Guid userOrMemberKey, string providerName); + + /// + /// Validates the setup of the provider using the secret and code. + /// + bool ValidateTwoFactorSetup(string providerName, string secret, string code); + + /// + /// Saves the 2FA login information. + /// + Task SaveAsync(TwoFactorLogin twoFactorLogin); + + /// + /// Gets all the enabled 2FA providers for the user or member with the specified key. + /// + Task> GetEnabledTwoFactorProviderNamesAsync(Guid userOrMemberKey); + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.FileSystems.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.FileSystems.cs index ccb515182e..fbb32671a1 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.FileSystems.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.FileSystems.cs @@ -1,3 +1,4 @@ +using System.IO; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -49,7 +50,7 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection ILogger logger = factory.GetRequiredService>(); GlobalSettings globalSettings = factory.GetRequiredService>().Value; - var rootPath = hostingEnvironment.MapPathWebRoot(globalSettings.UmbracoMediaPhysicalRootPath); + var rootPath = Path.IsPathRooted(globalSettings.UmbracoMediaPhysicalRootPath) ? globalSettings.UmbracoMediaPhysicalRootPath : hostingEnvironment.MapPathWebRoot(globalSettings.UmbracoMediaPhysicalRootPath); var rootUrl = hostingEnvironment.ToAbsolute(globalSettings.UmbracoMediaPath); return new PhysicalFileSystem(ioHelper, hostingEnvironment, logger, rootPath, rootUrl); }); 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 fa448bdc86..90d08b93fe 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/HostedServices/RecurringHostedServiceBase.cs b/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs index 70dcb3a04e..b6f2b469f6 100644 --- a/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs +++ b/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs @@ -21,7 +21,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices /// protected static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3); - private readonly TimeSpan _period; + private TimeSpan _period; private readonly TimeSpan _delay; private Timer _timer; @@ -73,6 +73,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices /// public Task StopAsync(CancellationToken cancellationToken) { + _period = Timeout.InfiniteTimeSpan; _timer?.Change(Timeout.Infinite, 0); return Task.CompletedTask; } diff --git a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs index 282847963f..d54d67338e 100644 --- a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs +++ b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs @@ -2,13 +2,17 @@ // See LICENSE for more details. using System; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration @@ -22,6 +26,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration private readonly IServerRegistrationService _serverRegistrationService; private readonly IHostingEnvironment _hostingEnvironment; private readonly ILogger _logger; + private readonly IServerRoleAccessor _serverRoleAccessor; private readonly GlobalSettings _globalSettings; /// @@ -37,7 +42,8 @@ namespace Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration IServerRegistrationService serverRegistrationService, IHostingEnvironment hostingEnvironment, ILogger logger, - IOptions globalSettings) + IOptions globalSettings, + IServerRoleAccessor serverRoleAccessor) : base(globalSettings.Value.DatabaseServerRegistrar.WaitTimeBetweenCalls, TimeSpan.FromSeconds(15)) { _runtimeState = runtimeState; @@ -45,6 +51,24 @@ namespace Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration _hostingEnvironment = hostingEnvironment; _logger = logger; _globalSettings = globalSettings.Value; + _serverRoleAccessor = serverRoleAccessor; + } + + [Obsolete("Use constructor that takes an IServerRoleAccessor")] + public TouchServerTask( + IRuntimeState runtimeState, + IServerRegistrationService serverRegistrationService, + IHostingEnvironment hostingEnvironment, + ILogger logger, + IOptions globalSettings) + : this( + runtimeState, + serverRegistrationService, + hostingEnvironment, + logger, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService()) + { } public override Task PerformExecuteAsync(object state) @@ -54,6 +78,14 @@ namespace Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration return Task.CompletedTask; } + // If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense, + // since all it's used for is to allow the ElectedServerRoleAccessor + // to figure out what role a given server has, so we just stop this task. + if (_serverRoleAccessor is not ElectedServerRoleAccessor) + { + return StopAsync(CancellationToken.None); + } + var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString(); if (serverAddress.IsNullOrWhiteSpace()) { diff --git a/src/Umbraco.Infrastructure/IPublishedContentQuery.cs b/src/Umbraco.Infrastructure/IPublishedContentQuery.cs index 77ca873638..0f98fb2924 100644 --- a/src/Umbraco.Infrastructure/IPublishedContentQuery.cs +++ b/src/Umbraco.Infrastructure/IPublishedContentQuery.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Xml.XPath; using Examine.Search; @@ -8,31 +8,46 @@ using Umbraco.Cms.Core.Xml; namespace Umbraco.Cms.Core { /// - /// Query methods used for accessing strongly typed content in templates + /// Query methods used for accessing strongly typed content in templates. /// public interface IPublishedContentQuery { IPublishedContent Content(int id); + IPublishedContent Content(Guid id); + IPublishedContent Content(Udi id); + IPublishedContent Content(object id); + IPublishedContent ContentSingleAtXPath(string xpath, params XPathVariable[] vars); + IEnumerable Content(IEnumerable ids); + IEnumerable Content(IEnumerable ids); IEnumerable Content(IEnumerable ids); + IEnumerable ContentAtXPath(string xpath, params XPathVariable[] vars); + IEnumerable ContentAtXPath(XPathExpression xpath, params XPathVariable[] vars); + IEnumerable ContentAtRoot(); IPublishedContent Media(int id); + IPublishedContent Media(Guid id); + IPublishedContent Media(Udi id); IPublishedContent Media(object id); + IEnumerable Media(IEnumerable ids); + IEnumerable Media(IEnumerable ids); + IEnumerable Media(IEnumerable ids); + IEnumerable MediaAtRoot(); /// @@ -44,7 +59,7 @@ namespace Umbraco.Cms.Core /// The total amount of records. /// The culture (defaults to a culture insensitive search). /// The name of the index to search (defaults to ). - /// The fields to load in the results of the search (defaults to all fields loaded). + /// This parameter is no longer used, because the results are loaded from the published snapshot using the single item ID field. /// /// The search results. /// 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..39d7d886b3 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -242,6 +242,9 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade // to 8.17.0 To("{153865E9-7332-4C2A-9F9D-F20AEE078EC7}"); + // Hack to support migration from 8.18 + To("{03482BB0-CF13-475C-845E-ECB8319DBE3C}"); + // This should be safe to execute again. We need it with a new name to ensure updates from all the following has executed this step. // - 8.15.0 RC - Current state: {4695D0C9-0729-4976-985B-048D503665D8} // - 8.15.0 Final - Current state: {5C424554-A32D-4852-8ED1-A13508187901} @@ -275,6 +278,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_0_0/ExternalLoginTableIndexes.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTableIndexes.cs index f350ed633c..db7f17eee3 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTableIndexes.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTableIndexes.cs @@ -1,6 +1,10 @@ +using System; using System.Collections.Generic; using System.Linq; +using NPoco; using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; using Umbraco.Cms.Infrastructure.Persistence.Dtos; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 @@ -20,14 +24,14 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 { // Before adding these indexes we need to remove duplicate data. // Get all logins by latest - var logins = Database.Fetch() + var logins = Database.Fetch() .OrderByDescending(x => x.CreateDate) .ToList(); var toDelete = new List(); // used to track duplicates so they can be removed var keys = new HashSet<(string, string)>(); - foreach(ExternalLoginDto login in logins) + foreach(ExternalLoginTokenTable.LegacyExternalLoginDto login in logins) { if (!keys.Add((login.ProviderKey, login.LoginProvider))) { @@ -37,16 +41,16 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 } if (toDelete.Count > 0) { - Database.DeleteMany().Where(x => toDelete.Contains(x.Id)).Execute(); - } + Database.DeleteMany().Where(x => toDelete.Contains(x.Id)).Execute(); + } - var indexName1 = "IX_" + ExternalLoginDto.TableName + "_LoginProvider"; + var indexName1 = "IX_" + ExternalLoginTokenTable.LegacyExternalLoginDto.TableName + "_LoginProvider"; if (!IndexExists(indexName1)) { Create .Index(indexName1) - .OnTable(ExternalLoginDto.TableName) + .OnTable(ExternalLoginTokenTable.LegacyExternalLoginDto.TableName) .OnColumn("loginProvider") .Ascending() .WithOptions() @@ -56,13 +60,13 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 .Do(); } - var indexName2 = "IX_" + ExternalLoginDto.TableName + "_ProviderKey"; + var indexName2 = "IX_" + ExternalLoginTokenTable.LegacyExternalLoginDto.TableName + "_ProviderKey"; if (!IndexExists(indexName2)) { Create .Index(indexName2) - .OnTable(ExternalLoginDto.TableName) + .OnTable(ExternalLoginTokenTable.LegacyExternalLoginDto.TableName) .OnColumn("loginProvider").Ascending() .OnColumn("providerKey").Ascending() .WithOptions() @@ -70,5 +74,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 .Do(); } } + + } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTableIndexesFixup.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTableIndexesFixup.cs index 5efb914eb7..2c77b301ce 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTableIndexesFixup.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTableIndexesFixup.cs @@ -14,29 +14,29 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 protected override void Migrate() { - var indexName1 = "IX_" + ExternalLoginDto.TableName + "_LoginProvider"; - var indexName2 = "IX_" + ExternalLoginDto.TableName + "_ProviderKey"; + var indexName1 = "IX_" + ExternalLoginTokenTable.LegacyExternalLoginDto.TableName + "_LoginProvider"; + var indexName2 = "IX_" + ExternalLoginTokenTable.LegacyExternalLoginDto.TableName + "_ProviderKey"; if (IndexExists(indexName1)) { // drop it since the previous migration index was wrong, and we // need to modify a column that belons to it - Delete.Index(indexName1).OnTable(ExternalLoginDto.TableName).Do(); + Delete.Index(indexName1).OnTable(ExternalLoginTokenTable.LegacyExternalLoginDto.TableName).Do(); } if (IndexExists(indexName2)) { // drop since it's using a column we're about to modify - Delete.Index(indexName2).OnTable(ExternalLoginDto.TableName).Do(); + Delete.Index(indexName2).OnTable(ExternalLoginTokenTable.LegacyExternalLoginDto.TableName).Do(); } // then fixup the length of the loginProvider column - AlterColumn(ExternalLoginDto.TableName, "loginProvider"); + AlterColumn(ExternalLoginTokenTable.LegacyExternalLoginDto.TableName, "loginProvider"); // create it with the correct definition Create .Index(indexName1) - .OnTable(ExternalLoginDto.TableName) + .OnTable(ExternalLoginTokenTable.LegacyExternalLoginDto.TableName) .OnColumn("loginProvider").Ascending() .OnColumn("userId").Ascending() .WithOptions() @@ -48,9 +48,9 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 // re-create the original Create .Index(indexName2) - .OnTable(ExternalLoginDto.TableName) + .OnTable(ExternalLoginTokenTable.LegacyExternalLoginDto.TableName) .OnColumn("loginProvider").Ascending() - .OnColumn("providerKey").Ascending() + .OnColumn("providerKey").Ascending() .WithOptions() .NonClustered() .Do(); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTokenTable.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTokenTable.cs index 8dd43f1834..851d986c7c 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTokenTable.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_0_0/ExternalLoginTokenTable.cs @@ -1,10 +1,14 @@ +using System; using System.Collections.Generic; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 { - public class ExternalLoginTokenTable : MigrationBase { public ExternalLoginTokenTable(IMigrationContext context) @@ -13,7 +17,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 } /// - /// Adds new External Login token table + /// Adds new External Login token table /// protected override void Migrate() { @@ -25,5 +29,53 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0 Create.Table().Do(); } + + [TableName(TableName)] + [ExplicitColumns] + [PrimaryKey("Id")] + internal class LegacyExternalLoginDto + { + public const string TableName = Constants.DatabaseSchema.Tables.ExternalLogin; + + [Column("id")] [PrimaryKeyColumn] public int Id { get; set; } + + [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")] + [Column("userId")] + public int? UserId { get; set; } + + + /// + /// Used to store the name of the provider (i.e. Facebook, Google) + /// + [Column("loginProvider")] + [Length(400)] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Index(IndexTypes.UniqueNonClustered, ForColumns = "loginProvider,userOrMemberKey", + Name = "IX_" + TableName + "_LoginProvider")] + public string LoginProvider { get; set; } + + /// + /// Stores the key the provider uses to lookup the login + /// + [Column("providerKey")] + [Length(4000)] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Index(IndexTypes.NonClustered, ForColumns = "loginProvider,providerKey", + Name = "IX_" + TableName + "_ProviderKey")] + public string ProviderKey { get; set; } + + [Column("createDate")] + [Constraint(Default = SystemMethods.CurrentDateTime)] + public DateTime CreateDate { get; set; } + + /// + /// Used to store any arbitrary data for the user and external provider - like user tokens returned from the provider + /// + [Column("userData")] + [NullSetting(NullSetting = NullSettings.Null)] + [SpecialDbType(SpecialDbTypes.NTEXT)] + public string UserData { get; set; } + } } } 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/Packaging/PackageDataInstallation.cs b/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs index defea0ea51..790cefe7e9 100644 --- a/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs +++ b/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs @@ -575,21 +575,22 @@ namespace Umbraco.Cms.Infrastructure.Packaging { var importedFolders = new Dictionary(); var trackEntityContainersInstalled = new List(); - foreach (var documentType in unsortedDocumentTypes) + + foreach (XElement documentType in unsortedDocumentTypes) { - var foldersAttribute = documentType.Attribute("Folders"); - var infoElement = documentType.Element("Info"); + XAttribute foldersAttribute = documentType.Attribute("Folders"); + XElement infoElement = documentType.Element("Info"); if (foldersAttribute != null && infoElement != null - //don't import any folder if this is a child doc type - the parent doc type will need to - //exist which contains it's folders + // don't import any folder if this is a child doc type - the parent doc type will need to + // exist which contains it's folders && ((string)infoElement.Element("Master")).IsNullOrWhiteSpace()) { var alias = documentType.Element("Info").Element("Alias").Value; var folders = foldersAttribute.Value.Split(Constants.CharArrays.ForwardSlash); - var folderKeysAttribute = documentType.Attribute("FolderKeys"); + XAttribute folderKeysAttribute = documentType.Attribute("FolderKeys"); - var folderKeys = Array.Empty(); + Guid[] folderKeys = Array.Empty(); if (folderKeysAttribute != null) { folderKeys = folderKeysAttribute.Value.Split(Constants.CharArrays.ForwardSlash).Select(x=>Guid.Parse(x)).ToArray(); @@ -597,22 +598,22 @@ namespace Umbraco.Cms.Infrastructure.Packaging var rootFolder = WebUtility.UrlDecode(folders[0]); - EntityContainer current; + EntityContainer current = null; Guid? rootFolderKey = null; if (folderKeys.Length == folders.Length && folderKeys.Length > 0) { rootFolderKey = folderKeys[0]; current = _contentTypeService.GetContainer(rootFolderKey.Value); } - else - { - //level 1 = root level folders, there can only be one with the same name - current = _contentTypeService.GetContainers(rootFolder, 1).FirstOrDefault(); - } + + // The folder might already exist, but with a different key, so check if it exists, even if there is a key. + // Level 1 = root level folders, there can only be one with the same name + current ??= _contentTypeService.GetContainers(rootFolder, 1).FirstOrDefault(); if (current == null) { - var tryCreateFolder = _contentTypeService.CreateContainer(-1, rootFolderKey ?? Guid.NewGuid(), rootFolder); + Attempt> tryCreateFolder = _contentTypeService.CreateContainer(-1, rootFolderKey ?? Guid.NewGuid(), rootFolder); + if (tryCreateFolder == false) { _logger.LogError(tryCreateFolder.Exception, "Could not create folder: {FolderName}", rootFolder); @@ -644,7 +645,7 @@ namespace Umbraco.Cms.Infrastructure.Packaging private EntityContainer CreateContentTypeChildFolder(string folderName, Guid folderKey, IUmbracoEntity current) { var children = _entityService.GetChildren(current.Id).ToArray(); - var found = children.Any(x => x.Name.InvariantEquals(folderName) ||x.Key.Equals(folderKey)); + var found = children.Any(x => x.Name.InvariantEquals(folderName) || x.Key.Equals(folderKey)); if (found) { var containerId = children.Single(x => x.Name.InvariantEquals(folderName)).Id; 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/PropertyEditors/BlockEditorPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs index b4b46f2f7c..7a2d626fcd 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyEditor.cs @@ -238,7 +238,7 @@ namespace Umbraco.Cms.Core.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.Infrastructure/PropertyEditors/BlockEditorPropertyHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyHandler.cs index cfa1c4b3cb..7248a7f5b0 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyHandler.cs @@ -52,7 +52,7 @@ namespace Umbraco.Cms.Core.PropertyEditors 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.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs index 36f11f5ce8..0c9cd40995 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs @@ -6,6 +6,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Runtime.Serialization; using System.Text.RegularExpressions; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Serialization; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs index e4039b6cee..ac7d5a4ef4 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs @@ -2,6 +2,8 @@ // See LICENSE for more details. using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; @@ -41,11 +43,13 @@ namespace Umbraco.Cms.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.Infrastructure/PropertyEditors/GridPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs index e3a6987110..f149757919 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs @@ -1,12 +1,10 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. using System; using System.Collections.Generic; using System.Linq; -using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; @@ -139,7 +137,7 @@ namespace Umbraco.Cms.Core.PropertyEditors } // Convert back to raw JSON for persisting - return JsonConvert.SerializeObject(grid); + return JsonConvert.SerializeObject(grid, Formatting.None); } /// @@ -153,7 +151,8 @@ namespace Umbraco.Cms.Core.PropertyEditors public override object ToEditor(IProperty property, string culture = null, string segment = null) { var val = property.GetValue(culture, segment)?.ToString(); - if (val.IsNullOrWhiteSpace()) return string.Empty; + if (val.IsNullOrWhiteSpace()) + return string.Empty; var grid = DeserializeGridValue(val, out var rtes, out _); @@ -199,7 +198,7 @@ namespace Umbraco.Cms.Core.PropertyEditors _richTextPropertyValueEditor.GetReferences(x.Value))) yield return umbracoEntityReference; - foreach (var umbracoEntityReference in mediaValues.Where(x=>x.Value.HasValues) + foreach (var umbracoEntityReference in mediaValues.Where(x => x.Value.HasValues) .SelectMany(x => _mediaPickerPropertyValueEditor.GetReferences(x.Value["udi"]))) yield return umbracoEntityReference; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs index 95023cecf6..ed194038d9 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs @@ -208,10 +208,11 @@ namespace Umbraco.Cms.Core.PropertyEditors { continue; } + var sourcePath = _mediaFileManager.FileSystem.GetRelativePath(src); var copyPath = _mediaFileManager.CopyFile(notification.Copy, property.PropertyType, sourcePath); jo["src"] = _mediaFileManager.FileSystem.GetUrl(copyPath); - notification.Copy.SetValue(property.Alias, jo.ToString(), propertyValue.Culture, propertyValue.Segment); + notification.Copy.SetValue(property.Alias, jo.ToString(Formatting.None), propertyValue.Culture, propertyValue.Segment); isUpdated = true; } } @@ -272,17 +273,9 @@ namespace Umbraco.Cms.Core.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.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs index 10a99ccd99..d24a46f815 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs @@ -86,31 +86,42 @@ namespace Umbraco.Cms.Core.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.LogWarning(ex, "Could not parse current db value to a JObject."); } + if (string.IsNullOrWhiteSpace(currentPath) == false) currentPath = _mediaFileManager.FileSystem.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 @@ -138,7 +149,7 @@ namespace Umbraco.Cms.Core.PropertyEditors return null; // clear } - return editorJson?.ToString(); // unchanged + return editorJson?.ToString(Formatting.None); // unchanged } // process the file @@ -159,7 +170,7 @@ namespace Umbraco.Cms.Core.PropertyEditors // update json and return if (editorJson == null) return null; editorJson["src"] = filepath == null ? string.Empty : _mediaFileManager.FileSystem.GetUrl(filepath); - return editorJson.ToString(); + return editorJson.ToString(Formatting.None); } private string ProcessFile(ContentPropertyFile file, Guid cuid, Guid puid) @@ -186,7 +197,6 @@ namespace Umbraco.Cms.Core.PropertyEditors return filepath; } - public override string ConvertDbToString(IPropertyType propertyType, object value) { if (value == null || string.IsNullOrEmpty(value.ToString())) @@ -205,6 +215,10 @@ namespace Umbraco.Cms.Core.PropertyEditors { src = val, crops = crops + }, new JsonSerializerSettings() + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore }); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index 45aa507a54..25174d5599 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; @@ -12,6 +12,7 @@ using Newtonsoft.Json; using System; using System.Linq; using System.Runtime.Serialization; +using Newtonsoft.Json.Linq; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors @@ -53,23 +54,55 @@ namespace Umbraco.Cms.Core.PropertyEditors internal class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference { private readonly IJsonSerializer _jsonSerializer; + private readonly IDataTypeService _dataTypeService; public MediaPicker3PropertyValueEditor( ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - DataEditorAttribute attribute) + DataEditorAttribute attribute, + IDataTypeService dataTypeService) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _jsonSerializer = jsonSerializer; + _dataTypeService = dataTypeService; } public override object ToEditor(IProperty property, string culture = null, string segment = null) { var value = property.GetValue(culture, segment); - return Deserialize(_jsonSerializer, value); + var dtos = Deserialize(_jsonSerializer, 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); } /// @@ -124,7 +157,6 @@ namespace Umbraco.Cms.Core.PropertyEditors } } - /// /// Model/DTO that represents the JSON that the MediaPicker3 stores. /// @@ -142,6 +174,48 @@ namespace Umbraco.Cms.Core.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. + /// + /// 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); } } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs index c1d8aa33f8..db55792a31 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs @@ -57,7 +57,7 @@ namespace Umbraco.Cms.Core.PropertyEditors try { - var links = JsonConvert.DeserializeObject>(value); + var links = JsonConvert.DeserializeObject>(value); var documentLinks = links.FindAll(link => link.Udi != null && link.Udi.EntityType == Constants.UdiEntityType.Document); var mediaLinks = links.FindAll(link => link.Udi != null && link.Udi.EntityType == Constants.UdiEntityType.Media); @@ -141,6 +141,7 @@ namespace Umbraco.Cms.Core.PropertyEditors private static readonly JsonSerializerSettings LinkDisplayJsonSerializerSettings = new JsonSerializerSettings { + Formatting = Formatting.None, NullValueHandling = NullValueHandling.Ignore }; @@ -150,22 +151,28 @@ namespace Umbraco.Cms.Core.PropertyEditors 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) - select new MultiUrlPickerValueEditor.LinkDto + from link in links + select new LinkDto { Name = link.Name, QueryString = link.QueryString, 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.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs index c3a5bae383..f0d5907e8e 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs @@ -1,14 +1,12 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.Exceptions; -using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -80,7 +78,7 @@ namespace Umbraco.Cms.Core.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.Infrastructure/PropertyEditors/MultipleValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultipleValueEditor.cs index c0e544b50c..8177c9ffeb 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultipleValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultipleValueEditor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. using System; @@ -59,14 +59,18 @@ namespace Umbraco.Cms.Core.PropertyEditors public override object FromEditor(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.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs index 80ed34e6e1..a3d30d0578 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. using System; @@ -105,7 +105,9 @@ namespace Umbraco.Cms.Core.PropertyEditors var rows = _nestedContentValues.GetPropertyValues(propertyValue); if (rows.Count == 0) - return string.Empty; + { + return null; + } foreach (var row in rows.ToList()) { @@ -136,13 +138,11 @@ namespace Umbraco.Cms.Core.PropertyEditors } } - return JsonConvert.SerializeObject(rows).ToXmlString(); + return JsonConvert.SerializeObject(rows, Formatting.None).ToXmlString(); } #endregion - - #region Convert database // editor // note: there is NO variant support here @@ -231,7 +231,7 @@ namespace Umbraco.Cms.Core.PropertyEditors var rows = _nestedContentValues.GetPropertyValues(editorValue.Value); if (rows.Count == 0) - return string.Empty; + return null; foreach (var row in rows.ToList()) { @@ -256,7 +256,7 @@ namespace Umbraco.Cms.Core.PropertyEditors } // return json - return JsonConvert.SerializeObject(rows); + return JsonConvert.SerializeObject(rows, Formatting.None); } #endregion diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyHandler.cs index 1bc5dd2f4b..aa825bb0f8 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyHandler.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Extensions; @@ -32,7 +33,7 @@ namespace Umbraco.Cms.Core.PropertyEditors UpdateNestedContentKeysRecursively(complexEditorValue, onlyMissingKeys, createGuid); - return complexEditorValue.ToString(); + return complexEditorValue.ToString(Formatting.None); } private void UpdateNestedContentKeysRecursively(JToken json, bool onlyMissingKeys, Func createGuid) @@ -65,7 +66,7 @@ namespace Umbraco.Cms.Core.PropertyEditors 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.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 50b6d7a881..1cfbc3449e 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. using System; @@ -81,6 +81,7 @@ namespace Umbraco.Cms.Core.PropertyEditors private readonly HtmlLocalLinkParser _localLinkParser; private readonly RichTextEditorPastedImages _pastedImages; private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly IHtmlSanitizer _htmlSanitizer; public RichTextPropertyValueEditor( DataEditorAttribute attribute, @@ -92,7 +93,8 @@ namespace Umbraco.Cms.Core.PropertyEditors RichTextEditorPastedImages pastedImages, IImageUrlGenerator imageUrlGenerator, IJsonSerializer jsonSerializer, - IIOHelper ioHelper) + IIOHelper ioHelper, + IHtmlSanitizer htmlSanitizer) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _backOfficeSecurityAccessor = backOfficeSecurityAccessor; @@ -100,6 +102,7 @@ namespace Umbraco.Cms.Core.PropertyEditors _localLinkParser = localLinkParser; _pastedImages = pastedImages; _imageUrlGenerator = imageUrlGenerator; + _htmlSanitizer = htmlSanitizer; } /// @@ -145,7 +148,9 @@ namespace Umbraco.Cms.Core.PropertyEditors public override object FromEditor(ContentPropertyData editorValue, object currentValue) { if (editorValue.Value == null) + { return null; + } var userId = _backOfficeSecurityAccessor?.BackOfficeSecurity?.CurrentUser?.Id ?? Constants.Security.SuperUserId; @@ -156,8 +161,9 @@ namespace Umbraco.Cms.Core.PropertyEditors var parseAndSavedTempImages = _pastedImages.FindAndPersistPastedTempImages(editorValue.Value.ToString(), mediaParentId, userId, _imageUrlGenerator); var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); var parsed = MacroTagParser.FormatRichTextContentForPersistence(editorValueWithMediaUrlsRemoved); + var sanitized = _htmlSanitizer.Sanitize(parsed); - return parsed; + return sanitized.NullOrWhiteSpaceAsNull(); } /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs index 4683851936..30911b0866 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. using System; @@ -73,7 +73,7 @@ namespace Umbraco.Cms.Core.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) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValue.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValue.cs index c0efaac4ae..af9e820d66 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValue.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValue.cs @@ -7,6 +7,7 @@ using System.ComponentModel; using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Strings; @@ -20,14 +21,14 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters /// [JsonConverter(typeof(NoTypeConverterJsonConverter))] [TypeConverter(typeof(ImageCropperValueTypeConverter))] - [DataContract(Name="imageCropDataSet")] + [DataContract(Name = "imageCropDataSet")] public class ImageCropperValue : IHtmlEncodedString, 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. @@ -43,9 +44,7 @@ namespace Umbraco.Cms.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; @@ -122,13 +121,19 @@ namespace Umbraco.Cms.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. @@ -167,6 +172,49 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters }; } + /// + /// Removes redundant crop data/default focal point. + /// + /// The image cropper 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 /// @@ -200,8 +248,8 @@ namespace Umbraco.Cms.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 } @@ -246,7 +294,7 @@ namespace Umbraco.Cms.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 } } @@ -300,9 +348,9 @@ namespace Umbraco.Cms.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 } @@ -357,9 +405,9 @@ namespace Umbraco.Cms.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.Infrastructure/PublishedContentQuery.cs b/src/Umbraco.Infrastructure/PublishedContentQuery.cs index d8119f919c..d0afe53540 100644 --- a/src/Umbraco.Infrastructure/PublishedContentQuery.cs +++ b/src/Umbraco.Infrastructure/PublishedContentQuery.cs @@ -17,23 +17,25 @@ using Constants = Umbraco.Cms.Core.Constants; namespace Umbraco.Cms.Infrastructure { /// - /// A class used to query for published content, media items + /// A class used to query for published content, media items /// + /// public class PublishedContentQuery : IPublishedContentQuery { private readonly IExamineManager _examineManager; private readonly IPublishedSnapshot _publishedSnapshot; private readonly IVariationContextAccessor _variationContextAccessor; + private static readonly HashSet s_itemIdFieldNameHashSet = + new HashSet() { ExamineFieldNames.ItemIdFieldName }; + /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public PublishedContentQuery(IPublishedSnapshot publishedSnapshot, - IVariationContextAccessor variationContextAccessor, IExamineManager examineManager) + public PublishedContentQuery(IPublishedSnapshot publishedSnapshot, IVariationContextAccessor variationContextAccessor, IExamineManager examineManager) { _publishedSnapshot = publishedSnapshot ?? throw new ArgumentNullException(nameof(publishedSnapshot)); - _variationContextAccessor = variationContextAccessor ?? - throw new ArgumentNullException(nameof(variationContextAccessor)); + _variationContextAccessor = variationContextAccessor ?? throw new ArgumentNullException(nameof(variationContextAccessor)); _examineManager = examineManager ?? throw new ArgumentNullException(nameof(examineManager)); } @@ -72,6 +74,7 @@ namespace Umbraco.Cms.Infrastructure return false; } } + private static bool ConvertIdObjectToUdi(object id, out Udi guidId) { switch (id) @@ -93,160 +96,148 @@ namespace Umbraco.Cms.Infrastructure #region Content - public IPublishedContent Content(int id) => ItemById(id, _publishedSnapshot.Content); + public IPublishedContent Content(int id) + => ItemById(id, _publishedSnapshot.Content); - public IPublishedContent Content(Guid id) => ItemById(id, _publishedSnapshot.Content); + public IPublishedContent Content(Guid id) + => ItemById(id, _publishedSnapshot.Content); public IPublishedContent Content(Udi id) - { - if (!(id is GuidUdi udi)) return null; - return ItemById(udi.Guid, _publishedSnapshot.Content); - } + => id is GuidUdi udi ? ItemById(udi.Guid, _publishedSnapshot.Content) : null; public IPublishedContent Content(object id) { if (ConvertIdObjectToInt(id, out var intId)) + { return Content(intId); + } + if (ConvertIdObjectToGuid(id, out var guidId)) + { return Content(guidId); + } + if (ConvertIdObjectToUdi(id, out var udiId)) + { return Content(udiId); + } + return null; } - public IPublishedContent ContentSingleAtXPath(string xpath, params XPathVariable[] vars) => - ItemByXPath(xpath, vars, _publishedSnapshot.Content); + public IPublishedContent ContentSingleAtXPath(string xpath, params XPathVariable[] vars) + => ItemByXPath(xpath, vars, _publishedSnapshot.Content); - public IEnumerable Content(IEnumerable ids) => - ItemsByIds(_publishedSnapshot.Content, ids); + public IEnumerable Content(IEnumerable ids) + => ItemsByIds(_publishedSnapshot.Content, ids); - public IEnumerable Content(IEnumerable ids) => - ItemsByIds(_publishedSnapshot.Content, ids); + public IEnumerable Content(IEnumerable ids) + => ItemsByIds(_publishedSnapshot.Content, ids); public IEnumerable Content(IEnumerable ids) - { - return ids.Select(Content).WhereNotNull(); - } - public IEnumerable ContentAtXPath(string xpath, params XPathVariable[] vars) => - ItemsByXPath(xpath, vars, _publishedSnapshot.Content); + => ids.Select(Content).WhereNotNull(); - public IEnumerable ContentAtXPath(XPathExpression xpath, params XPathVariable[] vars) => - ItemsByXPath(xpath, vars, _publishedSnapshot.Content); + public IEnumerable ContentAtXPath(string xpath, params XPathVariable[] vars) + => ItemsByXPath(xpath, vars, _publishedSnapshot.Content); - public IEnumerable ContentAtRoot() => ItemsAtRoot(_publishedSnapshot.Content); + public IEnumerable ContentAtXPath(XPathExpression xpath, params XPathVariable[] vars) + => ItemsByXPath(xpath, vars, _publishedSnapshot.Content); + + public IEnumerable ContentAtRoot() + => ItemsAtRoot(_publishedSnapshot.Content); #endregion #region Media - public IPublishedContent Media(int id) => ItemById(id, _publishedSnapshot.Media); + public IPublishedContent Media(int id) + => ItemById(id, _publishedSnapshot.Media); - public IPublishedContent Media(Guid id) => ItemById(id, _publishedSnapshot.Media); + public IPublishedContent Media(Guid id) + => ItemById(id, _publishedSnapshot.Media); public IPublishedContent Media(Udi id) - { - if (!(id is GuidUdi udi)) return null; - return ItemById(udi.Guid, _publishedSnapshot.Media); - } + => id is GuidUdi udi ? ItemById(udi.Guid, _publishedSnapshot.Media) : null; public IPublishedContent Media(object id) { if (ConvertIdObjectToInt(id, out var intId)) + { return Media(intId); + } + if (ConvertIdObjectToGuid(id, out var guidId)) + { return Media(guidId); + } + if (ConvertIdObjectToUdi(id, out var udiId)) + { return Media(udiId); + } + return null; } - public IEnumerable Media(IEnumerable ids) => ItemsByIds(_publishedSnapshot.Media, ids); + public IEnumerable Media(IEnumerable ids) + => ItemsByIds(_publishedSnapshot.Media, ids); + public IEnumerable Media(IEnumerable ids) - { - return ids.Select(Media).WhereNotNull(); - } + => ids.Select(Media).WhereNotNull(); - public IEnumerable Media(IEnumerable ids) => ItemsByIds(_publishedSnapshot.Media, ids); + public IEnumerable Media(IEnumerable ids) + => ItemsByIds(_publishedSnapshot.Media, ids); - public IEnumerable MediaAtRoot() => ItemsAtRoot(_publishedSnapshot.Media); + public IEnumerable MediaAtRoot() + => ItemsAtRoot(_publishedSnapshot.Media); #endregion #region Used by Content/Media private static IPublishedContent ItemById(int id, IPublishedCache cache) - { - var doc = cache.GetById(id); - return doc; - } + => cache.GetById(id); private static IPublishedContent ItemById(Guid id, IPublishedCache cache) - { - var doc = cache.GetById(id); - return doc; - } + => cache.GetById(id); private static IPublishedContent ItemByXPath(string xpath, XPathVariable[] vars, IPublishedCache cache) - { - var doc = cache.GetSingleByXPath(xpath, vars); - return doc; - } - - //NOTE: Not used? - //private IPublishedContent ItemByXPath(XPathExpression xpath, XPathVariable[] vars, IPublishedCache cache) - //{ - // var doc = cache.GetSingleByXPath(xpath, vars); - // return doc; - //} + => cache.GetSingleByXPath(xpath, vars); private static IEnumerable ItemsByIds(IPublishedCache cache, IEnumerable ids) - { - return ids.Select(eachId => ItemById(eachId, cache)).WhereNotNull(); - } + => ids.Select(eachId => ItemById(eachId, cache)).WhereNotNull(); private IEnumerable ItemsByIds(IPublishedCache cache, IEnumerable ids) - { - return ids.Select(eachId => ItemById(eachId, cache)).WhereNotNull(); - } + => ids.Select(eachId => ItemById(eachId, cache)).WhereNotNull(); - private static IEnumerable ItemsByXPath(string xpath, XPathVariable[] vars, - IPublishedCache cache) - { - var doc = cache.GetByXPath(xpath, vars); - return doc; - } + private static IEnumerable ItemsByXPath(string xpath, XPathVariable[] vars, IPublishedCache cache) + => cache.GetByXPath(xpath, vars); - private static IEnumerable ItemsByXPath(XPathExpression xpath, XPathVariable[] vars, - IPublishedCache cache) - { - var doc = cache.GetByXPath(xpath, vars); - return doc; - } + private static IEnumerable ItemsByXPath(XPathExpression xpath, XPathVariable[] vars, IPublishedCache cache) + => cache.GetByXPath(xpath, vars); - private static IEnumerable ItemsAtRoot(IPublishedCache cache) => cache.GetAtRoot(); + private static IEnumerable ItemsAtRoot(IPublishedCache cache) + => cache.GetAtRoot(); #endregion #region Search /// - public IEnumerable Search(string term, string culture = "*", - string indexName = Constants.UmbracoIndexes.ExternalIndexName) => - Search(term, 0, 0, out _, culture, indexName); + public IEnumerable Search(string term, string culture = "*", string indexName = Constants.UmbracoIndexes.ExternalIndexName) + => Search(term, 0, 0, out _, culture, indexName); /// public IEnumerable Search(string term, int skip, int take, out long totalRecords, string culture = "*", string indexName = Constants.UmbracoIndexes.ExternalIndexName, ISet loadedFields = null) { if (skip < 0) { - throw new ArgumentOutOfRangeException(nameof(skip), skip, - "The value must be greater than or equal to zero."); + throw new ArgumentOutOfRangeException(nameof(skip), skip, "The value must be greater than or equal to zero."); } if (take < 0) { - throw new ArgumentOutOfRangeException(nameof(take), take, - "The value must be greater than or equal to zero."); + throw new ArgumentOutOfRangeException(nameof(take), take, "The value must be greater than or equal to zero."); } if (string.IsNullOrEmpty(indexName)) @@ -254,68 +245,67 @@ namespace Umbraco.Cms.Infrastructure indexName = Constants.UmbracoIndexes.ExternalIndexName; } - if (!_examineManager.TryGetIndex(indexName, out var index) || !(index is IUmbracoIndex umbIndex)) + if (!_examineManager.TryGetIndex(indexName, out var index) || index is not IUmbracoIndex umbIndex) { - throw new InvalidOperationException( - $"No index found by name {indexName} or is not of type {typeof(IUmbracoIndex)}"); + throw new InvalidOperationException($"No index found by name {indexName} or is not of type {typeof(IUmbracoIndex)}"); } var query = umbIndex.Searcher.CreateQuery(IndexTypes.Content); - IQueryExecutor queryExecutor; + IOrdering ordering; if (culture == "*") { // Search everything - queryExecutor = query.ManagedQuery(term); + ordering = query.ManagedQuery(term); } else if (string.IsNullOrWhiteSpace(culture)) { // Only search invariant - queryExecutor = query + ordering = query .Field(UmbracoExamineFieldNames.VariesByCultureFieldName, "n") // Must not vary by culture .And().ManagedQuery(term); } else { // Only search the specified culture - var fields = - umbIndex.GetCultureAndInvariantFields(culture) - .ToArray(); // Get all index fields suffixed with the culture name supplied - queryExecutor = query.ManagedQuery(term, fields); - } - if (loadedFields != null && queryExecutor is IBooleanOperation booleanOperation) - { - queryExecutor = booleanOperation.SelectFields(loadedFields); + var fields = umbIndex.GetCultureAndInvariantFields(culture).ToArray(); // Get all index fields suffixed with the culture name supplied + ordering = query.ManagedQuery(term, fields); } + // Only select item ID field, because results are loaded from the published snapshot based on this single value + var queryExecutor = ordering.SelectFields(s_itemIdFieldNameHashSet); + + var results = skip == 0 && take == 0 ? queryExecutor.Execute() : queryExecutor.Execute(QueryOptions.SkipTake(skip, take)); totalRecords = results.TotalItemCount; - return new CultureContextualSearchResults( - results.ToPublishedSearchResults(_publishedSnapshot.Content), _variationContextAccessor, - culture); + return new CultureContextualSearchResults(results.ToPublishedSearchResults(_publishedSnapshot.Content), _variationContextAccessor, culture); } /// - public IEnumerable Search(IQueryExecutor query) => Search(query, 0, 0, out _); + public IEnumerable Search(IQueryExecutor query) + => Search(query, 0, 0, out _); /// - public IEnumerable Search(IQueryExecutor query, int skip, int take, - out long totalRecords) + public IEnumerable Search(IQueryExecutor query, int skip, int take, out long totalRecords) { if (skip < 0) { - throw new ArgumentOutOfRangeException(nameof(skip), skip, - "The value must be greater than or equal to zero."); + throw new ArgumentOutOfRangeException(nameof(skip), skip, "The value must be greater than or equal to zero."); } if (take < 0) { - throw new ArgumentOutOfRangeException(nameof(take), take, - "The value must be greater than or equal to zero."); + throw new ArgumentOutOfRangeException(nameof(take), take, "The value must be greater than or equal to zero."); + } + + if (query is IOrdering ordering) + { + // Only select item ID field, because results are loaded from the published snapshot based on this single value + query = ordering.SelectFields(s_itemIdFieldNameHashSet); } var results = skip == 0 && take == 0 @@ -328,8 +318,7 @@ namespace Umbraco.Cms.Infrastructure } /// - /// This is used to contextualize the values in the search results when enumerating over them so that the correct - /// culture values are used + /// This is used to contextualize the values in the search results when enumerating over them, so that the correct culture values are used. /// private class CultureContextualSearchResults : IEnumerable { @@ -337,8 +326,7 @@ namespace Umbraco.Cms.Infrastructure private readonly IVariationContextAccessor _variationContextAccessor; private readonly IEnumerable _wrapped; - public CultureContextualSearchResults(IEnumerable wrapped, - IVariationContextAccessor variationContextAccessor, string culture) + public CultureContextualSearchResults(IEnumerable wrapped, IVariationContextAccessor variationContextAccessor, string culture) { _wrapped = wrapped; _variationContextAccessor = variationContextAccessor; @@ -347,20 +335,21 @@ namespace Umbraco.Cms.Infrastructure public IEnumerator GetEnumerator() { - //We need to change the current culture to what is requested and then change it back + // We need to change the current culture to what is requested and then change it back var originalContext = _variationContextAccessor.VariationContext; - if (!_culture.IsNullOrWhiteSpace() && !_culture.InvariantEquals(originalContext.Culture)) + if (!_culture.IsNullOrWhiteSpace() && !_culture.InvariantEquals(originalContext?.Culture)) + { _variationContextAccessor.VariationContext = new VariationContext(_culture); + } - //now the IPublishedContent returned will be contextualized to the culture specified and will be reset when the enumerator is disposed - return new CultureContextualSearchResultsEnumerator(_wrapped.GetEnumerator(), _variationContextAccessor, - originalContext); + // Now the IPublishedContent returned will be contextualized to the culture specified and will be reset when the enumerator is disposed + return new CultureContextualSearchResultsEnumerator(_wrapped.GetEnumerator(), _variationContextAccessor, originalContext); } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// - /// Resets the variation context when this is disposed + /// Resets the variation context when this is disposed. /// private class CultureContextualSearchResultsEnumerator : IEnumerator { @@ -368,30 +357,28 @@ namespace Umbraco.Cms.Infrastructure private readonly IVariationContextAccessor _variationContextAccessor; private readonly IEnumerator _wrapped; - public CultureContextualSearchResultsEnumerator(IEnumerator wrapped, - IVariationContextAccessor variationContextAccessor, VariationContext originalContext) + public CultureContextualSearchResultsEnumerator(IEnumerator wrapped, IVariationContextAccessor variationContextAccessor, VariationContext originalContext) { _wrapped = wrapped; _variationContextAccessor = variationContextAccessor; _originalContext = originalContext; } + public PublishedSearchResult Current => _wrapped.Current; + + object IEnumerator.Current => Current; + public void Dispose() { _wrapped.Dispose(); - //reset + + // Reset to original variation context _variationContextAccessor.VariationContext = _originalContext; } public bool MoveNext() => _wrapped.MoveNext(); - public void Reset() - { - _wrapped.Reset(); - } - - public PublishedSearchResult Current => _wrapped.Current; - object IEnumerator.Current => Current; + public void Reset() => _wrapped.Reset(); } } diff --git a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs index 5dbe78c2f5..851d67e713 100644 --- a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs +++ b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs @@ -134,60 +134,48 @@ namespace Umbraco.Cms.Infrastructure.Runtime public IRuntimeState State { get; } /// - 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) => await StartAsync(cancellationToken, false); /// - public async Task StartAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) => await StopAsync(cancellationToken, false); + + /// + public async Task RestartAsync() + { + await StopAsync(_cancellationToken, true); + await _eventAggregator.PublishAsync(new UmbracoApplicationStoppedNotification(true), _cancellationToken); + await StartAsync(_cancellationToken, true); + await _eventAggregator.PublishAsync(new UmbracoApplicationStartedNotification(true), _cancellationToken); + } + + private async Task StartAsync(CancellationToken cancellationToken, bool isRestarting) { // Store token, so we can re-use this during restart _cancellationToken = cancellationToken; - StaticApplicationLogging.Initialize(_loggerFactory); - StaticServiceProvider.Instance = _serviceProvider; - - AppDomain.CurrentDomain.UnhandledException += (_, args) => + if (isRestarting == false) { - var exception = (Exception)args.ExceptionObject; - var isTerminating = args.IsTerminating; // always true? + StaticApplicationLogging.Initialize(_loggerFactory); + StaticServiceProvider.Instance = _serviceProvider; - var msg = "Unhandled exception in AppDomain"; - - if (isTerminating) - { - msg += " (terminating)"; - } - - msg += "."; - - _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())); + AppDomain.CurrentDomain.UnhandledException += (_, args) + => _logger.LogError(args.ExceptionObject as Exception, $"Unhandled exception in AppDomain{(args.IsTerminating ? " (terminating)" : null)}."); } - // acquire the main domain - if this fails then anything that should be registered with MainDom will not operate + // Acquire the main domain - if this fails then anything that should be registered with MainDom will not operate AcquireMainDom(); // TODO (V10): Remove this obsoleted notification publish. await _eventAggregator.PublishAsync(new UmbracoApplicationMainDomAcquiredNotification(), cancellationToken); - // notify for unattended install + // Notify for unattended install await _eventAggregator.PublishAsync(new RuntimeUnattendedInstallNotification(), cancellationToken); DetermineRuntimeLevel(); if (!State.UmbracoCanBoot()) { - return; // The exception will be rethrown by BootFailedMiddelware + // We cannot continue here, the exception will be rethrown by BootFailedMiddelware + return; } IApplicationShutdownRegistry hostingEnvironmentLifetime = _applicationShutdownRegistry; @@ -196,7 +184,7 @@ namespace Umbraco.Cms.Infrastructure.Runtime throw new InvalidOperationException($"An instance of {typeof(IApplicationShutdownRegistry)} could not be resolved from the container, ensure that one if registered in your runtime before calling {nameof(IRuntime)}.{nameof(StartAsync)}"); } - // if level is Run and reason is UpgradeMigrations, that means we need to perform an unattended upgrade + // 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, cancellationToken); switch (unattendedUpgradeNotification.UnattendedUpgradeResult) @@ -207,54 +195,59 @@ 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 + // We cannot continue here, the exception will be rethrown by BootFailedMiddelware return; case RuntimeUnattendedUpgradeNotification.UpgradeResult.CoreUpgradeComplete: case RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete: - // upgrade is done, set reason to Run + // Upgrade is done, set reason to Run DetermineRuntimeLevel(); break; case RuntimeUnattendedUpgradeNotification.UpgradeResult.NotRequired: break; } - // TODO (V10): Remove this obsoleted notification publish. + // TODO (V10): Remove this obsoleted notification publish await _eventAggregator.PublishAsync(new UmbracoApplicationComponentsInstallingNotification(State.Level), cancellationToken); - // create & initialize the components + // Initialize the components _components.Initialize(); - await _eventAggregator.PublishAsync(new UmbracoApplicationStartingNotification(State.Level), cancellationToken); + await _eventAggregator.PublishAsync(new UmbracoApplicationStartingNotification(State.Level, isRestarting), cancellationToken); + + if (isRestarting == false) + { + // Add application started and stopped notifications last (to ensure they're always published after starting) + _hostApplicationLifetime.ApplicationStarted.Register(() => _eventAggregator.Publish(new UmbracoApplicationStartedNotification(false))); + _hostApplicationLifetime.ApplicationStopped.Register(() => _eventAggregator.Publish(new UmbracoApplicationStoppedNotification(false))); + } } - public async Task StopAsync(CancellationToken cancellationToken) + private async Task StopAsync(CancellationToken cancellationToken, bool isRestarting) { _components.Terminate(); - await _eventAggregator.PublishAsync(new UmbracoApplicationStoppingNotification(), cancellationToken); - StaticApplicationLogging.Initialize(null); + await _eventAggregator.PublishAsync(new UmbracoApplicationStoppingNotification(isRestarting), cancellationToken); } private void AcquireMainDom() { - using (DisposableTimer timer = _profilingLogger.DebugDuration("Acquiring MainDom.", "Acquired.")) + using DisposableTimer timer = _profilingLogger.DebugDuration("Acquiring MainDom.", "Acquired."); + + try { - try - { - _mainDom.Acquire(_applicationShutdownRegistry); - } - catch - { - timer?.Fail(); - throw; - } + _mainDom.Acquire(_applicationShutdownRegistry); + } + catch + { + timer?.Fail(); + throw; } } private void DetermineRuntimeLevel() { - if (State.BootFailedException != null) + if (State.BootFailedException is not null) { - // there's already been an exception so cannot boot and no need to check + // There's already been an exception, so cannot boot and no need to check return; } @@ -277,7 +270,8 @@ namespace Umbraco.Cms.Infrastructure.Runtime State.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException); timer?.Fail(); _logger.LogError(ex, "Boot Failed"); - // We do not throw the exception. It will be rethrown by BootFailedMiddleware + + // We do not throw the exception, it will be rethrown by BootFailedMiddleware } } } diff --git a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs index 73b6692e3a..c81041849a 100644 --- a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs +++ b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs @@ -44,27 +44,6 @@ namespace Umbraco.Cms.Infrastructure.Runtime { } - /// - /// Initializes a new instance of the class. - /// - public RuntimeState( - IOptions globalSettings, - IOptions unattendedSettings, - IUmbracoVersion umbracoVersion, - IUmbracoDatabaseFactory databaseFactory, - ILogger logger, - PendingPackageMigrations packageMigrationState) - : this( - globalSettings, - unattendedSettings, - umbracoVersion, - databaseFactory, - logger, - packageMigrationState, - StaticServiceProvider.Instance.GetRequiredService()) - { - } - public RuntimeState( IOptions globalSettings, IOptions unattendedSettings, @@ -83,6 +62,28 @@ namespace Umbraco.Cms.Infrastructure.Runtime _conflictingRouteService = conflictingRouteService; } + /// + /// Initializes a new instance of the class. + /// + [Obsolete("use ctor with all params")] + public RuntimeState( + IOptions globalSettings, + IOptions unattendedSettings, + IUmbracoVersion umbracoVersion, + IUmbracoDatabaseFactory databaseFactory, + ILogger logger, + PendingPackageMigrations packageMigrationState) + : this( + globalSettings, + unattendedSettings, + umbracoVersion, + databaseFactory, + logger, + packageMigrationState, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + /// public Version Version => _umbracoVersion.Version; 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..420d66b0b4 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()) { } @@ -107,6 +112,9 @@ namespace Umbraco.Cms.Core.Security // create the member _memberService.Save(memberEntity); + //We need to add roles now that the member has an Id. It do not work implicit in UpdateMemberProperties + _memberService.AssignRoles(new[] { memberEntity.Id }, user.Roles.Select(x => x.RoleId).ToArray()); + if (!memberEntity.HasIdentity) { throw new DataException("Could not create the member, check logs for details"); @@ -678,5 +686,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/Serialization/ConfigurationEditorJsonSerializer.cs b/src/Umbraco.Infrastructure/Serialization/ConfigurationEditorJsonSerializer.cs index e8e32fe7da..ab978c903e 100644 --- a/src/Umbraco.Infrastructure/Serialization/ConfigurationEditorJsonSerializer.cs +++ b/src/Umbraco.Infrastructure/Serialization/ConfigurationEditorJsonSerializer.cs @@ -12,6 +12,8 @@ namespace Umbraco.Cms.Infrastructure.Serialization { JsonSerializerSettings.Converters.Add(new FuzzyBooleanConverter()); JsonSerializerSettings.ContractResolver = new ConfigurationCustomContractResolver(); + JsonSerializerSettings.Formatting = Formatting.None; + JsonSerializerSettings.NullValueHandling = NullValueHandling.Ignore; } private class ConfigurationCustomContractResolver : DefaultContractResolver diff --git a/src/Umbraco.Infrastructure/Serialization/JsonNetSerializer.cs b/src/Umbraco.Infrastructure/Serialization/JsonNetSerializer.cs index a728630680..dd228ac008 100644 --- a/src/Umbraco.Infrastructure/Serialization/JsonNetSerializer.cs +++ b/src/Umbraco.Infrastructure/Serialization/JsonNetSerializer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -14,25 +14,21 @@ namespace Umbraco.Cms.Infrastructure.Serialization Converters = new List() { new StringEnumConverter() - } + }, + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore }; - public string Serialize(object input) - { - return JsonConvert.SerializeObject(input, JsonSerializerSettings); - } - public T Deserialize(string input) - { - return JsonConvert.DeserializeObject(input, JsonSerializerSettings); - } + public string Serialize(object input) => JsonConvert.SerializeObject(input, JsonSerializerSettings); + + public T Deserialize(string input) => JsonConvert.DeserializeObject(input, JsonSerializerSettings); public T DeserializeSubset(string input, string key) { if (key == null) throw new ArgumentNullException(nameof(key)); - var root = JsonConvert.DeserializeObject(input); - - var jToken = root.SelectToken(key); + var root = Deserialize(input); + var jToken = root?.SelectToken(key); return jToken switch { diff --git a/src/Umbraco.Infrastructure/Serialization/JsonToStringConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonToStringConverter.cs index e4c17ab918..3cf23154c8 100644 --- a/src/Umbraco.Infrastructure/Serialization/JsonToStringConverter.cs +++ b/src/Umbraco.Infrastructure/Serialization/JsonToStringConverter.cs @@ -1,4 +1,4 @@ -using System; +using System; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -20,9 +20,10 @@ namespace Umbraco.Cms.Infrastructure.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.Infrastructure/Services/Implement/TwoFactorLoginService.cs b/src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs new file mode 100644 index 0000000000..cdcc6b19e9 --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/TwoFactorLoginService.cs @@ -0,0 +1,151 @@ +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 IOptions _backOfficeIdentityOptions; + private readonly IDictionary _twoFactorSetupGenerators; + + /// + /// Initializes a new instance of the class. + /// + public TwoFactorLoginService( + ITwoFactorLoginRepository twoFactorLoginRepository, + IScopeProvider scopeProvider, + IEnumerable twoFactorSetupGenerators, + IOptions identityOptions, + IOptions backOfficeIdentityOptions + ) + { + _twoFactorLoginRepository = twoFactorLoginRepository; + _scopeProvider = scopeProvider; + _identityOptions = identityOptions; + _backOfficeIdentityOptions = backOfficeIdentityOptions; + _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(IsKnownProviderName); + } + + /// + /// The provider needs to be registered as either a member provider or backoffice provider to show up. + /// + private bool IsKnownProviderName(string providerName) + { + if (_identityOptions.Value.Tokens.ProviderMap.ContainsKey(providerName)) + { + return true; + } + + if (_backOfficeIdentityOptions.Value.Tokens.ProviderMap.ContainsKey(providerName)) + { + return true; + } + + return false; + } + + /// + 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.PublishedCache.NuCache/DomainCacheExtensions.cs b/src/Umbraco.PublishedCache.NuCache/DomainCacheExtensions.cs index 61f10917fd..47cc427217 100644 --- a/src/Umbraco.PublishedCache.NuCache/DomainCacheExtensions.cs +++ b/src/Umbraco.PublishedCache.NuCache/DomainCacheExtensions.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Umbraco.Cms.Core.PublishedCache; @@ -9,7 +10,8 @@ namespace Umbraco.Cms.Infrastructure.PublishedCache { var assigned = domainCache.GetAssigned(documentId, includeWildcards); - return culture is null ? assigned.Any() : assigned.Any(x => x.Culture == culture); + // It's super important that we always compare cultures with ignore case, since we can't be sure of the casing! + return culture is null ? assigned.Any() : assigned.Any(x => x.Culture.Equals(culture, StringComparison.InvariantCultureIgnoreCase)); } } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs index 30ad3f75ae..cdc9d2e913 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs @@ -678,7 +678,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers r = code }); - // Construct full URL using configured application URL (which will fall back to request) + // Construct full URL using configured application URL (which will fall back to current request) Uri applicationUri = _httpContextAccessor.GetRequiredHttpContext().Request.GetApplicationUri(_webRoutingSettings); var callbackUri = new Uri(applicationUri, action); return callbackUri.ToString(); diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs index aa5fc83e1e..e866409c17 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; @@ -31,6 +32,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.Extensions; using Constants = Umbraco.Cms.Core.Constants; @@ -68,7 +70,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions; private readonly IManifestParser _manifestParser; private readonly ServerVariablesParser _serverVariables; + private readonly IOptions _securitySettings; + + [ActivatorUtilitiesConstructor] public BackOfficeController( IBackOfficeUserManager userManager, IRuntimeState runtimeState, @@ -87,7 +92,8 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers IHttpContextAccessor httpContextAccessor, IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions, IManifestParser manifestParser, - ServerVariablesParser serverVariables) + ServerVariablesParser serverVariables, + IOptions securitySettings) { _userManager = userManager; _runtimeState = runtimeState; @@ -107,6 +113,51 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers _backOfficeTwoFactorOptions = backOfficeTwoFactorOptions; _manifestParser = manifestParser; _serverVariables = serverVariables; + _securitySettings = securitySettings; + } + + [Obsolete("Use ctor with all params. This overload will be removed in Umbraco 10.")] + public BackOfficeController( + IBackOfficeUserManager userManager, + IRuntimeState runtimeState, + IRuntimeMinifier runtimeMinifier, + IOptions globalSettings, + IHostingEnvironment hostingEnvironment, + ILocalizedTextService textService, + IGridConfig gridConfig, + BackOfficeServerVariables backOfficeServerVariables, + AppCaches appCaches, + IBackOfficeSignInManager signInManager, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILogger logger, + IJsonSerializer jsonSerializer, + IBackOfficeExternalLoginProviders externalLogins, + IHttpContextAccessor httpContextAccessor, + IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions, + IManifestParser manifestParser, + ServerVariablesParser serverVariables) + : this(userManager, + runtimeState, + runtimeMinifier, + globalSettings, + hostingEnvironment, + textService, + gridConfig, + backOfficeServerVariables, + appCaches, + signInManager, + backofficeSecurityAccessor, + logger, + jsonSerializer, + externalLogins, + httpContextAccessor, + backOfficeTwoFactorOptions, + manifestParser, + serverVariables, + StaticServiceProvider.Instance.GetRequiredService>() + ) + { + } [HttpGet] @@ -458,7 +509,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers if (response == null) throw new ArgumentNullException(nameof(response)); // Sign in the user with this external login provider (which auto links, etc...) - SignInResult result = await _signInManager.ExternalLoginSignInAsync(loginInfo, isPersistent: false); + SignInResult result = await _signInManager.ExternalLoginSignInAsync(loginInfo, isPersistent: false, bypassTwoFactor: _securitySettings.Value.UserBypassTwoFactorForExternalLogins); var errors = new List(); diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs index 43723207d3..0d9d8b903d 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs @@ -100,7 +100,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers var keepOnlyKeys = new Dictionary { {"umbracoUrls", new[] {"authenticationApiBaseUrl", "serverVarsJs", "externalLoginsUrl", "currentUserApiBaseUrl", "previewHubUrl", "iconApiBaseUrl"}}, - {"umbracoSettings", new[] {"allowPasswordReset", "imageFileTypes", "maxFileSize", "loginBackgroundImage", "loginLogoImage", "canSendRequiredEmail", "usernameIsEmail", "minimumPasswordLength", "minimumPasswordNonAlphaNum"}}, + {"umbracoSettings", new[] {"allowPasswordReset", "imageFileTypes", "maxFileSize", "loginBackgroundImage", "loginLogoImage", "canSendRequiredEmail", "usernameIsEmail", "minimumPasswordLength", "minimumPasswordNonAlphaNum", "hideBackofficeLogo"}}, {"application", new[] {"applicationPath", "cacheBuster"}}, {"isDebuggingEnabled", new string[] { }}, {"features", new [] {"disabledFeatures"}} @@ -408,6 +408,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers {"allowPasswordReset", _securitySettings.AllowPasswordReset}, {"loginBackgroundImage", _contentSettings.LoginBackgroundImage}, {"loginLogoImage", _contentSettings.LoginLogoImage }, + {"hideBackofficeLogo", _contentSettings.HideBackOfficeLogo }, {"showUserInvite", _emailSender.CanSendRequiredEmail()}, {"canSendRequiredEmail", _emailSender.CanSendRequiredEmail()}, {"showAllowSegmentationForDocumentTypes", false}, diff --git a/src/Umbraco.Web.BackOffice/Controllers/HelpController.cs b/src/Umbraco.Web.BackOffice/Controllers/HelpController.cs index 3bc45703fa..f431b1a827 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/HelpController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/HelpController.cs @@ -1,10 +1,17 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; using System.Net.Http; using System.Runtime.Serialization; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Newtonsoft.Json; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.DependencyInjection; using Constants = Umbraco.Cms.Core.Constants; namespace Umbraco.Cms.Web.BackOffice.Controllers @@ -13,15 +20,44 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers public class HelpController : UmbracoAuthorizedJsonController { private readonly ILogger _logger; + private HelpPageSettings _helpPageSettings; + [Obsolete("Use constructor that takes IOptions")] public HelpController(ILogger logger) + : this(logger, StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + [ActivatorUtilitiesConstructor] + public HelpController( + ILogger logger, + IOptionsMonitor helpPageSettings) { _logger = logger; + + ResetHelpPageSettings(helpPageSettings.CurrentValue); + helpPageSettings.OnChange(ResetHelpPageSettings); + } + + private void ResetHelpPageSettings(HelpPageSettings settings) + { + _helpPageSettings = settings; } private static HttpClient _httpClient; + public async Task> GetContextHelpForPage(string section, string tree, string baseUrl = "https://our.umbraco.com") { + if (IsAllowedUrl(baseUrl) is false) + { + _logger.LogError($"The following URL is not listed in the allowlist for HelpPage in HelpPageSettings: {baseUrl}"); + HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + + // Ideally we'd want to return a BadRequestResult here, + // however, since we're not returning ActionResult this is not possible and changing it would be a breaking change. + return new List(); + } + var url = string.Format(baseUrl + "/Umbraco/Documentation/Lessons/GetContextHelpDocs?sectionAlias={0}&treeAlias={1}", section, tree); try @@ -44,6 +80,17 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers return new List(); } + + private bool IsAllowedUrl(string url) + { + if (_helpPageSettings.HelpPageUrlAllowList is null || + _helpPageSettings.HelpPageUrlAllowList.Contains(url)) + { + return true; + } + + return false; + } } [DataContract(Name = "HelpPage")] diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index e9cc213598..1dc5bda7a9 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Net; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Security; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.AspNetCore; using Umbraco.Cms.Web.Common.Security; @@ -77,5 +78,14 @@ namespace Umbraco.Extensions return umbracoBuilder; } + public static BackOfficeIdentityBuilder AddTwoFactorProvider(this BackOfficeIdentityBuilder identityBuilder, string providerName) where T : class, ITwoFactorProvider + { + identityBuilder.Services.AddSingleton(); + identityBuilder.Services.AddSingleton(); + identityBuilder.AddTokenProvider>(providerName); + + return identityBuilder; + } + } } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs index 2c801e963b..63027a3c9e 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.DependencyInjection; @@ -54,7 +55,8 @@ namespace Umbraco.Extensions .AddCoreNotifications() .AddLogViewer() .AddExamine() - .AddExamineIndexes(); + .AddExamineIndexes() + .AddControllersWithAmbiguousConstructors(); public static IUmbracoBuilder AddUnattendedInstallInstallCreateUser(this IUmbracoBuilder builder) { @@ -119,5 +121,18 @@ namespace Umbraco.Extensions return builder; } + + /// + /// Adds explicit registrations for controllers with ambiguous constructors to prevent downstream issues for + /// users who wish to use + /// + public static IUmbracoBuilder AddControllersWithAmbiguousConstructors( + this IUmbracoBuilder builder) + { + builder.Services.TryAddTransient(sp => + ActivatorUtilities.CreateInstance(sp)); + + return builder; + } } } diff --git a/src/Umbraco.Web.BackOffice/Services/ConflictingRouteService.cs b/src/Umbraco.Web.BackOffice/Services/ConflictingRouteService.cs index 2951ace9e1..03451a60fd 100644 --- a/src/Umbraco.Web.BackOffice/Services/ConflictingRouteService.cs +++ b/src/Umbraco.Web.BackOffice/Services/ConflictingRouteService.cs @@ -1,7 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Routing; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Controllers; namespace Umbraco.Cms.Web.BackOffice.Services @@ -9,11 +13,16 @@ namespace Umbraco.Cms.Web.BackOffice.Services public class ConflictingRouteService : IConflictingRouteService { private readonly TypeLoader _typeLoader; + private readonly IEnumerable _endpointDataSources; /// /// Initializes a new instance of the class. /// - public ConflictingRouteService(TypeLoader typeLoader) => _typeLoader = typeLoader; + public ConflictingRouteService(TypeLoader typeLoader, IEnumerable endpointDataSources) + { + _typeLoader = typeLoader; + _endpointDataSources = endpointDataSources; + } /// public bool HasConflictingRoutes(out string controllerName) @@ -21,10 +30,20 @@ namespace Umbraco.Cms.Web.BackOffice.Services var controllers = _typeLoader.GetTypes().ToList(); foreach (Type controller in controllers) { - if (controllers.Count(x => x.Name == controller.Name) > 1) + var potentialConflicting = controllers.Where(x => x.Name == controller.Name).ToArray(); + if (potentialConflicting.Length > 1) { - controllerName = controller.Name; - return true; + //If we have any with same controller name and located in the same area, then it is a confict. + var conflicting = potentialConflicting + .Select(x => x.GetCustomAttribute()) + .GroupBy(x => x?.AreaName) + .Any(x => x?.Count() > 1); + + if (conflicting) + { + controllerName = controller.Name; + return true; + } } } diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs index 3dd303d8f7..a41f396faf 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs @@ -282,7 +282,12 @@ namespace Umbraco.Cms.Web.BackOffice.Trees if (_emailSender.CanSendRequiredEmail()) { - AddActionNode(item, menu, true, opensDialog: true); + menu.Items.Add(new MenuItem("notify", LocalizedTextService) + { + Icon = "megaphone", + SeparatorBefore = true, + OpensDialog = true + }); } if((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false) diff --git a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs index 9e5919c1e2..13f73e1b41 100644 --- a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs +++ b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreHostingEnvironment.cs @@ -37,7 +37,16 @@ namespace Umbraco.Cms.Web.Common.AspNetCore _webHostEnvironment = webHostEnvironment ?? throw new ArgumentNullException(nameof(webHostEnvironment)); _urlProviderMode = _webRoutingSettings.CurrentValue.UrlProviderMode; - SiteName = webHostEnvironment.ApplicationName; + SetSiteName(hostingSettings.CurrentValue.SiteName); + + // We have to ensure that the OptionsMonitor is an actual options monitor since we have a hack + // where we initially use an OptionsMonitorAdapter, which doesn't implement OnChange. + // See summery of OptionsMonitorAdapter for more information. + if (hostingSettings is OptionsMonitor) + { + hostingSettings.OnChange(settings => SetSiteName(settings.SiteName)); + } + ApplicationPhysicalPath = webHostEnvironment.ContentRootPath; if (_webRoutingSettings.CurrentValue.UmbracoApplicationUrl is not null) @@ -53,7 +62,7 @@ namespace Umbraco.Cms.Web.Common.AspNetCore public Uri ApplicationMainUrl { get; private set; } /// - public string SiteName { get; } + public string SiteName { get; private set; } /// public string ApplicationId @@ -198,7 +207,10 @@ namespace Umbraco.Cms.Web.Common.AspNetCore } } } + + private void SetSiteName(string siteName) => + SiteName = string.IsNullOrWhiteSpace(siteName) + ? _webHostEnvironment.ApplicationName + : siteName; } - - } 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/Profiler/WebProfiler.cs b/src/Umbraco.Web.Common/Profiler/WebProfiler.cs index 7c5a89fa71..688414b3de 100644 --- a/src/Umbraco.Web.Common/Profiler/WebProfiler.cs +++ b/src/Umbraco.Web.Common/Profiler/WebProfiler.cs @@ -1,11 +1,16 @@ using System; using System.Linq; +using System.Net; using System.Threading; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.DependencyInjection; using StackExchange.Profiling; +using StackExchange.Profiling.Internal; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Logging; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; using Umbraco.Extensions; namespace Umbraco.Cms.Web.Common.Profiler @@ -13,6 +18,8 @@ namespace Umbraco.Cms.Web.Common.Profiler public class WebProfiler : IProfiler { + private const string WebProfileCookieKey = "umbracoWebProfiler"; + public static readonly AsyncLocal MiniProfilerContext = new AsyncLocal(x => { _ = x; @@ -39,7 +46,6 @@ namespace Umbraco.Cms.Web.Common.Profiler public void Stop(bool discardResults = false) => MiniProfilerContext.Value?.Stop(discardResults); - public void UmbracoApplicationBeginRequest(HttpContext context, RuntimeLevel runtimeLevel) { if (runtimeLevel != RuntimeLevel.Run) @@ -50,9 +56,13 @@ namespace Umbraco.Cms.Web.Common.Profiler if (ShouldProfile(context.Request)) { Start(); + ICookieManager cookieManager = GetCookieManager(context); + cookieManager.ExpireCookie(WebProfileCookieKey); //Ensure we expire the cookie, so we do not reuse the old potential value saved } } + private static ICookieManager GetCookieManager(HttpContext context) => context.RequestServices.GetRequiredService(); + public void UmbracoApplicationEndRequest(HttpContext context, RuntimeLevel runtimeLevel) { if (runtimeLevel != RuntimeLevel.Run) @@ -70,19 +80,42 @@ namespace Umbraco.Cms.Web.Common.Profiler var first = Interlocked.Exchange(ref _first, 1) == 0; if (first) { - - var startupDuration = _startupProfiler.Root.DurationMilliseconds.GetValueOrDefault(); - MiniProfilerContext.Value.DurationMilliseconds += startupDuration; - MiniProfilerContext.Value.GetTimingHierarchy().First().DurationMilliseconds += startupDuration; - MiniProfilerContext.Value.Root.AddChild(_startupProfiler.Root); + AddSubProfiler(_startupProfiler); _startupProfiler = null; } + + ICookieManager cookieManager = GetCookieManager(context); + var cookieValue = cookieManager.GetCookieValue(WebProfileCookieKey); + + if (cookieValue is not null) + { + AddSubProfiler(MiniProfiler.FromJson(cookieValue)); + } + + //If it is a redirect to a relative path (local redirect) + if (context.Response.StatusCode == (int)HttpStatusCode.Redirect + && context.Response.Headers.TryGetValue(Microsoft.Net.Http.Headers.HeaderNames.Location, out var location) + && !location.Contains("://")) + { + MiniProfilerContext.Value.Root.Name = "Before Redirect"; + cookieManager.SetCookieValue(WebProfileCookieKey, MiniProfilerContext.Value.ToJson()); + } + } } } + private void AddSubProfiler(MiniProfiler subProfiler) + { + var startupDuration = subProfiler.Root.DurationMilliseconds.GetValueOrDefault(); + MiniProfilerContext.Value.DurationMilliseconds += startupDuration; + MiniProfilerContext.Value.GetTimingHierarchy().First().DurationMilliseconds += startupDuration; + MiniProfilerContext.Value.Root.AddChild(subProfiler.Root); + + } + private static bool ShouldProfile(HttpRequest request) { if (request.IsClientSideRequest()) return false; 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 cb7a2b0835..8124b9c50e 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..d4272515e5 --- /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 + { + public 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.Client/src/assets/img/application/umbraco_logo_large_blue.svg b/src/Umbraco.Web.UI.Client/src/assets/img/application/umbraco_logo_large_blue.svg new file mode 100644 index 0000000000..ce15dd3092 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/assets/img/application/umbraco_logo_large_blue.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/src/Umbraco.Web.UI.Client/src/assets/img/application/umbraco_logo_white.svg b/src/Umbraco.Web.UI.Client/src/assets/img/application/umbraco_logo_white.svg index b27ae89e91..c0bdbdd40c 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/img/application/umbraco_logo_white.svg +++ b/src/Umbraco.Web.UI.Client/src/assets/img/application/umbraco_logo_white.svg @@ -1,3 +1,41 @@ - - - + + + + + + + + + + + + diff --git a/src/Umbraco.Web.UI.Client/src/assets/img/application/umbraco_logomark_white.svg b/src/Umbraco.Web.UI.Client/src/assets/img/application/umbraco_logomark_white.svg new file mode 100644 index 0000000000..08c2264337 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/assets/img/application/umbraco_logomark_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbappheader.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbappheader.directive.js index b52b0a5763..01e199c572 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbappheader.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umbappheader.directive.js @@ -1,9 +1,9 @@ (function () { "use strict"; - function AppHeaderDirective(eventsService, appState, userService, focusService, backdropService, overlayService) { + function AppHeaderDirective(eventsService, appState, userService, focusService, overlayService, $timeout) { - function link(scope, el, attr, ctrl) { + function link(scope, element) { var evts = []; @@ -16,6 +16,7 @@ { value: "assets/img/application/logo@2x.png" }, { value: "assets/img/application/logo@3x.png" } ]; + scope.hideBackofficeLogo = Umbraco.Sys.ServerVariables.umbracoSettings.hideBackofficeLogo; // when a user logs out or timesout evts.push(eventsService.on("app.notAuthenticated", function () { @@ -84,6 +85,46 @@ overlayService.open(dialog); }; + scope.logoModal = { + show: false, + text: "", + timer: null + }; + scope.showLogoModal = function() { + $timeout.cancel(scope.logoModal.timer); + scope.logoModal.show = true; + scope.logoModal.text = "version "+Umbraco.Sys.ServerVariables.application.version; + $timeout(function () { + const anchorLink = element[0].querySelector('.umb-app-header__logo-modal'); + if(anchorLink) { + anchorLink.focus(); + } + }); + }; + scope.keepLogoModal = function() { + $timeout.cancel(scope.logoModal.timer); + }; + scope.hideLogoModal = function() { + if(scope.logoModal.show === true) { + $timeout.cancel(scope.logoModal.timer); + scope.logoModal.timer = $timeout(function () { + scope.logoModal.show = false; + }, 100); + } + }; + scope.stopClickEvent = function($event) { + $event.stopPropagation(); + }; + + scope.toggleLogoModal = function() { + if(scope.logoModal.show) { + $timeout.cancel(scope.logoModal.timer); + scope.logoModal.show = false; + } else { + scope.showLogoModal(); + } + }; + } var directive = { diff --git a/src/Umbraco.Web.UI.Client/src/common/filters/simpleMarkdown.filter.js b/src/Umbraco.Web.UI.Client/src/common/filters/simpleMarkdown.filter.js new file mode 100644 index 0000000000..58d5b0ed91 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/filters/simpleMarkdown.filter.js @@ -0,0 +1,20 @@ +/** +* @ngdoc filter +* @name umbraco.filters.simpleMarkdown +* @description +* Used when rendering a string as Markdown as HTML (i.e. with ng-bind-html). Allows use of **bold**, *italics*, ![images](url) and [links](url) +**/ +angular.module("umbraco.filters").filter('simpleMarkdown', function () { + return function (text) { + if (!text) { + return ''; + } + + return text + .replace(/\*\*(.*)\*\*/gim, '$1') + .replace(/\*(.*)\*/gim, '$1') + .replace(/!\[(.*?)\]\((.*?)\)/gim, "$1") + .replace(/\[(.*?)\]\((.*?)\)/gim, "$1") + .replace(/\n/g, '
').trim(); + }; +}); 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 a07ade2b55..3b2618a82b 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 @@ -173,12 +173,12 @@ function entityResource($q, $http, umbRequestHelper) { $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/common/resources/ourpackagerrepository.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/ourpackagerrepository.resource.js index f803d7edce..be13e6d0ec 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/ourpackagerrepository.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/ourpackagerrepository.resource.js @@ -36,7 +36,21 @@ function ourPackageRepositoryResource($q, $http, umbDataFormatter, umbRequestHel $http.get(baseurl + "?pageIndex=0&pageSize=" + maxResults + "&category=" + category + "&order=Popular&version=" + Umbraco.Sys.ServerVariables.application.version), 'Failed to query packages'); }, - + + getPromoted: function (maxResults, category) { + + if (maxResults === undefined) { + maxResults = 20; + } + if (category === undefined) { + category = ""; + } + + return umbRequestHelper.resourcePromise( + $http.get(baseurl + "?pageIndex=0&pageSize=" + maxResults + "&category=" + category + "&order=Popular&version=" + Umbraco.Sys.ServerVariables.application.version + "&onlyPromoted=true"), + 'Failed to query packages'); + }, + search: function (pageIndex, pageSize, orderBy, category, query, canceler) { var httpConfig = {}; diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js index 22baed8472..24432ca261 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js @@ -30,9 +30,8 @@ for (var p = 0; p < tab.properties.length; p++) { var prop = tab.properties[p]; - if (dataModel[prop.alias]) { - prop.value = dataModel[prop.alias]; - } + + prop.value = dataModel[prop.alias]; } } @@ -53,9 +52,8 @@ for (var p = 0; p < tab.properties.length; p++) { var prop = tab.properties[p]; - if (prop.value) { - dataModel[prop.alias] = prop.value; - } + + dataModel[prop.alias] = prop.value; } } @@ -101,11 +99,30 @@ /** * Generate label for Block, uses either the labelInterpolator or falls back to the contentTypeName. - * @param {Object} blockObject BlockObject to recive data values from. + * @param {Object} blockObject BlockObject to receive data values from. */ function getBlockLabel(blockObject) { if (blockObject.labelInterpolator !== undefined) { - var labelVars = Object.assign({"$contentTypeName": blockObject.content.contentTypeName, "$settings": blockObject.settingsData || {}, "$layout": blockObject.layout || {}, "$index": (blockObject.index || 0)+1 }, blockObject.data); + // blockobject.content may be null if the block is no longer allowed, + // so try and fall back to the label in the config, + // if that too is null, there's not much we can do, so just default to empty string. + var contentTypeName; + if(blockObject.content != null){ + contentTypeName = blockObject.content.contentTypeName; + } + else if(blockObject.config != null && blockObject.config.label != null){ + contentTypeName = blockObject.config.label; + } + else { + contentTypeName = ""; + } + + var labelVars = Object.assign({ + "$contentTypeName": contentTypeName, + "$settings": blockObject.settingsData || {}, + "$layout": blockObject.layout || {}, + "$index": (blockObject.index || 0)+1 + }, blockObject.data); var label = blockObject.labelInterpolator(labelVars); if (label) { return label; @@ -511,10 +528,10 @@ * @methodOf umbraco.services.blockEditorModelObject * @description Retrieve a Block Object for the given layout entry. * The Block Object offers the necessary data to display and edit a block. - * The Block Object setups live syncronization of content and settings models back to the data of your Property Editor model. - * The returned object, named ´BlockObject´, contains several usefull models to make editing of this block happen. + * The Block Object setups live synchronization of content and settings models back to the data of your Property Editor model. + * The returned object, named ´BlockObject´, contains several useful models to make editing of this block happen. * The ´BlockObject´ contains the following properties: - * - key {string}: runtime generated key, usefull for tracking of this object + * - key {string}: runtime generated key, useful for tracking of this object * - content {Object}: Content model, the content data in a ElementType model. * - settings {Object}: Settings model, the settings data in a ElementType model. * - config {Object}: A local deep copy of the block configuration model. @@ -522,12 +539,11 @@ * - updateLabel {Method}: Method to trigger an update of the label for this block. * - data {Object}: A reference to the content data object from your property editor model. * - settingsData {Object}: A reference to the settings data object from your property editor model. - * - layout {Object}: A refernce to the layout entry from your property editor model. + * - layout {Object}: A reference to the layout entry from your property editor model. * @param {Object} layoutEntry the layout entry object to build the block model from. - * @return {Object | null} The BlockObject for the given layout entry. Or null if data or configuration wasnt found for this block. + * @return {Object | null} The BlockObject for the given layout entry. Or null if data or configuration wasn't found for this block. */ getBlockObject: function (layoutEntry) { - var contentUdi = layoutEntry.contentUdi; var dataModel = getDataByUdi(contentUdi, this.value.contentData); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/navigation.service.js b/src/Umbraco.Web.UI.Client/src/common/services/navigation.service.js index a932888aa4..81d1818448 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/navigation.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/navigation.service.js @@ -259,8 +259,8 @@ function navigationService($routeParams, $location, $q, $injector, eventsService var updated = false; retainedQueryStrings.forEach(r => { - // if mculture is set to null in nextRouteParams, the value will be undefined and we will not retain any query string that has a value of "null" - if (currRouteParams[r] && nextRouteParams[r] !== undefined && !nextRouteParams[r]) { + // testing explicitly for undefined in nextRouteParams here, as it must be possible to "unset" e.g. mculture by specifying a null value + if (currRouteParams[r] && nextRouteParams[r] === undefined) { toRetain[r] = currRouteParams[r]; updated = true; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-app-header.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-app-header.less index 68a29df89e..6e1fa29eab 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-app-header.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-app-header.less @@ -2,12 +2,72 @@ background: @blueExtraDark; display: flex; align-items: center; - justify-content: space-between; max-width: 100%; height: @appHeaderHeight; padding: 0 20px; } +.umb-app-header__logo { + margin-right: 30px; + flex-shrink: 0; + button { + img { + height: 30px; + } + } + +} +@media (max-width: 1279px) { + .umb-app-header__logo { + display: none; + } +} + +.umb-app-header__logo-modal { + position: absolute; + z-index: @zindexUmbOverlay; + top: 50px; + left: 17px; + font-size: 13px; + + border-radius: 6px; + + width: 160px; + padding: 20px 20px; + background-color:@white; + color: @blueExtraDark; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, .14), 0 1px 6px 1px rgba(0, 0, 0, .14); + text-decoration: none; + + text-align: center; + + &::before { + content:''; + position: absolute; + transform: rotate(45deg); + background-color:@white; + top: -4px; + left: 14px; + width: 8px; + height: 8px; + } + + img { + display: block; + height: auto; + width: 120px; + margin-left: auto; + margin-right: auto; + margin-bottom: 3px; + } +} + +.umb-app-header__right { + display: flex; + align-items: center; + margin-left: auto; +} + .umb-app-header__actions { display: flex; list-style: none; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-package-local-install.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-package-local-install.less index ead54ac49f..43f3c5e353 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-package-local-install.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-package-local-install.less @@ -9,44 +9,51 @@ color: @gray-5; } -.umb-upload-local__dropzone { - position: relative; - width: 500px; - height: 300px; - border: 2px dashed @ui-action-border; - border-radius: 3px; - background: @white; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - margin-bottom: 30px; - transition: 100ms box-shadow ease, 100ms border ease; +.umb-upload-local { - &.drag-over { - border-color: @ui-action-border-hover; - border-style: solid; - box-shadow: 0 3px 8px rgba(0,0,0, .1); + &__dropzone { + position: relative; + width: 500px; + height: 300px; + border: 2px dashed @ui-action-border; + border-radius: 3px; + background: @white; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-bottom: 30px; transition: 100ms box-shadow ease, 100ms border ease; + + &.drag-over { + border-color: @ui-action-border-hover; + border-style: solid; + box-shadow: 0 3px 8px rgba(0,0,0, .1); + transition: 100ms box-shadow ease, 100ms border ease; + } + + .umb-icon { + display: block; + color: @ui-action-type; + font-size: 6.75rem; + line-height: 1; + margin: 0 auto; + } + + .umb-info-local-item { + margin: 20px; + } } - .umb-icon { - display: block; + &__select-file { + font-weight: bold; color: @ui-action-type; - font-size: 6.75rem; - line-height: 1; - margin: 0 auto; - } -} + cursor: pointer; -.umb-upload-local__select-file { - font-weight: bold; - color: @ui-action-type; - cursor: pointer; - - &:hover { - text-decoration: underline; - color: @ui-action-type-hover; + &:hover { + text-decoration: underline; + color: @ui-action-type-hover; + } } } @@ -117,7 +124,3 @@ .umb-info-local-item { margin-bottom: 20px; } - -.umb-upload-local__dropzone .umb-info-local-item { - margin:20px; -} 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 00827dde64..66fe2e0ae4 100644 --- a/src/Umbraco.Web.UI.Client/src/less/pages/login.less +++ b/src/Umbraco.Web.UI.Client/src/less/pages/login.less @@ -28,15 +28,15 @@ .login-overlay__logo { position: absolute; - top: 22px; - left: 25px; - min-width: 30px; + top: 12.5px; + left: 20px; + right: 25px; + min-width: 112px; height: 30px; z-index: 1; } - -.login-overlay__logo > img { - max-height:100%; +.login-overlay__logo img { + height: 100%; } .login-overlay .umb-modalcolumn { @@ -70,7 +70,8 @@ margin-right: 25px; margin-top: auto; margin-bottom: auto; - border-radius: @baseBorderRadius; + border-radius: @doubleBorderRadius; + box-shadow: 0 1px 6px 1px rgba(0, 0, 0, 0.12); } .login-overlay .form input[type="text"], diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-app-header.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-app-header.html index e0fb4aeb77..ce3bf06853 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-app-header.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-app-header.html @@ -1,34 +1,103 @@ -
-
- - + -
+ + +
  • -
  • -
  • -
-
-
diff --git a/src/Umbraco.Web.UI.Client/src/views/content/content.protect.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/content.protect.controller.js index 01ba2567a7..92efb24e63 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/content.protect.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/content.protect.controller.js @@ -1,261 +1,261 @@ (function () { - "use strict"; + "use strict"; - function ContentProtectController($scope, $q, publicAccessResource, memberResource, memberGroupResource, navigationService, localizationService, editorService) { + function ContentProtectController($scope, $q, contentResource, memberResource, memberGroupResource, navigationService, localizationService, editorService) { - var vm = this; - var id = $scope.currentNode.id; + var vm = this; + var id = $scope.currentNode.id; - vm.loading = false; - vm.buttonState = "init"; - - vm.isValid = isValid; - vm.next = next; - vm.save = save; - vm.close = close; - vm.toggle = toggle; - vm.pickLoginPage = pickLoginPage; - vm.pickErrorPage = pickErrorPage; - vm.pickGroup = pickGroup; - vm.removeGroup = removeGroup; - vm.pickMember = pickMember; - vm.removeMember = removeMember; - vm.removeProtection = removeProtection; - vm.removeProtectionConfirm = removeProtectionConfirm; - - vm.type = null; - vm.step = null; - - function onInit() { - vm.loading = true; - - // get the current public access protection - publicAccessResource.getPublicAccess(id).then(function (publicAccess) { vm.loading = false; + vm.buttonState = "init"; - // init the current settings for public access (if any) - vm.loginPage = publicAccess.loginPage; - vm.errorPage = publicAccess.errorPage; - vm.groups = publicAccess.groups || []; - vm.members = publicAccess.members || []; - vm.canRemove = true; + vm.isValid = isValid; + vm.next = next; + vm.save = save; + vm.close = close; + vm.toggle = toggle; + vm.pickLoginPage = pickLoginPage; + vm.pickErrorPage = pickErrorPage; + vm.pickGroup = pickGroup; + vm.removeGroup = removeGroup; + vm.pickMember = pickMember; + vm.removeMember = removeMember; + vm.removeProtection = removeProtection; + vm.removeProtectionConfirm = removeProtectionConfirm; - if (vm.members.length) { - vm.type = "member"; - next(); - } - else if (vm.groups.length) { - vm.type = "group"; - next(); - } - else { - vm.canRemove = false; - } - }); - } + vm.type = null; + vm.step = null; - function next() { - if (vm.type === "group") { - vm.loading = true; - // get all existing member groups for lookup upon selection - // NOTE: if/when member groups support infinite editing, we can't rely on using a cached lookup list of valid groups anymore - memberGroupResource.getGroups().then(function (groups) { - vm.step = vm.type; - vm.allGroups = groups; - vm.hasGroups = groups.length > 0; - vm.loading = false; - }); - } - else { - vm.step = vm.type; - } - } - - function isValid() { - if (!vm.type) { - return false; - } - if (!vm.protectForm.$valid) { - return false; - } - if (!vm.loginPage || !vm.errorPage) { - return false; - } - if (vm.type === "group") { - return vm.groups && vm.groups.length > 0; - } - if (vm.type === "member") { - return vm.members && vm.members.length > 0; - } - return true; - } - - function save() { - vm.buttonState = "busy"; - var groups = _.map(vm.groups, function (group) { return group.name; }); - var usernames = _.map(vm.members, function (member) { return member.username; }); - publicAccessResource.updatePublicAccess(id, groups, usernames, vm.loginPage.id, vm.errorPage.id).then( - function () { - localizationService.localize("publicAccess_paIsProtected", [$scope.currentNode.name]).then(function (value) { - vm.success = { - message: value - }; - }); - navigationService.syncTree({ tree: "content", path: $scope.currentNode.path, forceReload: true }); - $scope.dialog.confirmDiscardChanges = true; - }, function (error) { - vm.error = error; - vm.buttonState = "error"; - } - ); - } - - function close() { - // ensure that we haven't set a locked state on the dialog before closing it - navigationService.allowHideDialog(true); - navigationService.hideDialog(); - } - - function toggle(group) { - group.selected = !group.selected; - $scope.dialog.confirmDiscardChanges = true; - } - - function pickGroup() { - navigationService.allowHideDialog(false); - editorService.memberGroupPicker({ - multiPicker: true, - submit: function (model) { - var selectedGroupIds = model.selectedMemberGroups - ? model.selectedMemberGroups - : [model.selectedMemberGroup]; - _.each(selectedGroupIds, - function (groupId) { - // find the group in the lookup list and add it if it isn't already - var group = _.find(vm.allGroups, function (g) { return g.id === parseInt(groupId); }); - if (group && !_.find(vm.groups, function (g) { return g.id === group.id })) { - vm.groups.push(group); - } - }); - editorService.close(); - navigationService.allowHideDialog(true); - $scope.dialog.confirmDiscardChanges = true; - }, - close: function () { - editorService.close(); - navigationService.allowHideDialog(true); - } - }); - } - - function removeGroup(group) { - vm.groups = _.reject(vm.groups, function (g) { return g.id === group.id }); - $scope.dialog.confirmDiscardChanges = true; - } - - function pickMember() { - navigationService.allowHideDialog(false); - // TODO: once editorService has a memberPicker method, use that instead - editorService.treePicker({ - multiPicker: true, - entityType: "Member", - section: "member", - treeAlias: "member", - filter: function (i) { - return i.metaData.isContainer; - }, - filterCssClass: "not-allowed", - submit: function (model) { - if (model.selection && model.selection.length) { - var promises = []; - // get the selected member usernames - _.each(model.selection, - function (member) { - // TODO: - // as-is we need to fetch all the picked members one at a time to get their usernames. - // when editorService has a memberPicker method, see if this can't be avoided - otherwise - // add a memberResource.getByKeys() method to do all this in one request - promises.push( - memberResource.getByKey(member.key).then(function (newMember) { - if (!_.find(vm.members, function (currentMember) { return currentMember.username === newMember.username })) { - vm.members.push(newMember); - } - }) - ); - }); - editorService.close(); - navigationService.allowHideDialog(true); - // wait for all the member lookups to complete + function onInit() { vm.loading = true; - $q.all(promises).then(function () { - vm.loading = false; + + // get the current public access protection + contentResource.getPublicAccess(id).then(function (publicAccess) { + vm.loading = false; + + // init the current settings for public access (if any) + vm.loginPage = publicAccess.loginPage; + vm.errorPage = publicAccess.errorPage; + vm.groups = publicAccess.groups || []; + vm.members = publicAccess.members || []; + vm.canRemove = true; + + if (vm.members.length) { + vm.type = "member"; + next(); + } + else if (vm.groups.length) { + vm.type = "group"; + next(); + } + else { + vm.canRemove = false; + } }); + } + + function next() { + if (vm.type === "group") { + vm.loading = true; + // get all existing member groups for lookup upon selection + // NOTE: if/when member groups support infinite editing, we can't rely on using a cached lookup list of valid groups anymore + memberGroupResource.getGroups().then(function (groups) { + vm.step = vm.type; + vm.allGroups = groups; + vm.hasGroups = groups.length > 0; + vm.loading = false; + }); + } + else { + vm.step = vm.type; + } + } + + function isValid() { + if (!vm.type) { + return false; + } + if (!vm.protectForm.$valid) { + return false; + } + if (!vm.loginPage || !vm.errorPage) { + return false; + } + if (vm.type === "group") { + return vm.groups && vm.groups.length > 0; + } + if (vm.type === "member") { + return vm.members && vm.members.length > 0; + } + return true; + } + + function save() { + vm.buttonState = "busy"; + var groups = _.map(vm.groups, function (group) { return encodeURIComponent(group.name); }); + var usernames = _.map(vm.members, function (member) { return member.username; }); + contentResource.updatePublicAccess(id, groups, usernames, vm.loginPage.id, vm.errorPage.id).then( + function () { + localizationService.localize("publicAccess_paIsProtected", [$scope.currentNode.name]).then(function (value) { + vm.success = { + message: value + }; + }); + navigationService.syncTree({ tree: "content", path: $scope.currentNode.path, forceReload: true }); + $scope.dialog.confirmDiscardChanges = true; + }, function (error) { + vm.error = error; + vm.buttonState = "error"; + } + ); + } + + function close() { + // ensure that we haven't set a locked state on the dialog before closing it + navigationService.allowHideDialog(true); + navigationService.hideDialog(); + } + + function toggle(group) { + group.selected = !group.selected; $scope.dialog.confirmDiscardChanges = true; - } - }, - close: function () { - editorService.close(); - navigationService.allowHideDialog(true); } - }); - } - function removeMember(member) { - vm.members = _.without(vm.members, member); - } - - function pickLoginPage() { - pickPage(vm.loginPage); - } - - function pickErrorPage() { - pickPage(vm.errorPage); - } - - function pickPage(page) { - navigationService.allowHideDialog(false); - editorService.contentPicker({ - submit: function (model) { - if (page === vm.loginPage) { - vm.loginPage = model.selection[0]; - } - else { - vm.errorPage = model.selection[0]; - } - editorService.close(); - navigationService.allowHideDialog(true); - $scope.dialog.confirmDiscardChanges = true; - }, - close: function () { - editorService.close(); - navigationService.allowHideDialog(true); + function pickGroup() { + navigationService.allowHideDialog(false); + editorService.memberGroupPicker({ + multiPicker: true, + submit: function(model) { + var selectedGroupIds = model.selectedMemberGroups + ? model.selectedMemberGroups + : [model.selectedMemberGroup]; + _.each(selectedGroupIds, + function (groupId) { + // find the group in the lookup list and add it if it isn't already + var group = _.find(vm.allGroups, function(g) { return g.id === parseInt(groupId); }); + if (group && !_.find(vm.groups, function (g) { return g.id === group.id })) { + vm.groups.push(group); + } + }); + editorService.close(); + navigationService.allowHideDialog(true); + $scope.dialog.confirmDiscardChanges = true; + }, + close: function() { + editorService.close(); + navigationService.allowHideDialog(true); + } + }); } - }); - } - function removeProtection() { - vm.removing = true; - } - - function removeProtectionConfirm() { - vm.buttonState = "busy"; - publicAccessResource.removePublicAccess(id).then( - function () { - localizationService.localize("publicAccess_paIsRemoved", [$scope.currentNode.name]).then(function (value) { - vm.success = { - message: value - }; - }); - navigationService.syncTree({ tree: "content", path: $scope.currentNode.path, forceReload: true }); - }, function (error) { - vm.error = error; - vm.buttonState = "error"; + function removeGroup(group) { + vm.groups = _.reject(vm.groups, function(g) { return g.id === group.id }); + $scope.dialog.confirmDiscardChanges = true; } - ); + + function pickMember() { + navigationService.allowHideDialog(false); + // TODO: once editorService has a memberPicker method, use that instead + editorService.treePicker({ + multiPicker: true, + entityType: "Member", + section: "member", + treeAlias: "member", + filter: function (i) { + return i.metaData.isContainer; + }, + filterCssClass: "not-allowed", + submit: function (model) { + if (model.selection && model.selection.length) { + var promises = []; + // get the selected member usernames + _.each(model.selection, + function (member) { + // TODO: + // as-is we need to fetch all the picked members one at a time to get their usernames. + // when editorService has a memberPicker method, see if this can't be avoided - otherwise + // add a memberResource.getByKeys() method to do all this in one request + promises.push( + memberResource.getByKey(member.key).then(function(newMember) { + if (!_.find(vm.members, function (currentMember) { return currentMember.username === newMember.username })) { + vm.members.push(newMember); + } + }) + ); + }); + editorService.close(); + navigationService.allowHideDialog(true); + // wait for all the member lookups to complete + vm.loading = true; + $q.all(promises).then(function() { + vm.loading = false; + }); + $scope.dialog.confirmDiscardChanges = true; + } + }, + close: function () { + editorService.close(); + navigationService.allowHideDialog(true); + } + }); + } + + function removeMember(member) { + vm.members = _.without(vm.members, member); + } + + function pickLoginPage() { + pickPage(vm.loginPage); + } + + function pickErrorPage() { + pickPage(vm.errorPage); + } + + function pickPage(page) { + navigationService.allowHideDialog(false); + editorService.contentPicker({ + submit: function (model) { + if (page === vm.loginPage) { + vm.loginPage = model.selection[0]; + } + else { + vm.errorPage = model.selection[0]; + } + editorService.close(); + navigationService.allowHideDialog(true); + $scope.dialog.confirmDiscardChanges = true; + }, + close: function () { + editorService.close(); + navigationService.allowHideDialog(true); + } + }); + } + + function removeProtection() { + vm.removing = true; + } + + function removeProtectionConfirm() { + vm.buttonState = "busy"; + contentResource.removePublicAccess(id).then( + function () { + localizationService.localize("publicAccess_paIsRemoved", [$scope.currentNode.name]).then(function(value) { + vm.success = { + message: value + }; + }); + navigationService.syncTree({ tree: "content", path: $scope.currentNode.path, forceReload: true }); + }, function (error) { + vm.error = error; + vm.buttonState = "error"; + } + ); + } + + onInit(); } - onInit(); - } - - angular.module("umbraco").controller("Umbraco.Editors.Content.ProtectController", ContentProtectController); + angular.module("umbraco").controller("Umbraco.Editors.Content.ProtectController", ContentProtectController); })(); diff --git a/src/Umbraco.Web.UI.Client/src/views/errors/BootFailed.html b/src/Umbraco.Web.UI.Client/src/views/errors/BootFailed.html index c08627739a..7b91125e09 100644 --- a/src/Umbraco.Web.UI.Client/src/views/errors/BootFailed.html +++ b/src/Umbraco.Web.UI.Client/src/views/errors/BootFailed.html @@ -1,79 +1,87 @@ - - - - Boot Failed - - - -
- -
-

Boot Failed

-

Umbraco failed to boot, if you are the owner of the website please see the log file for more details.

-
-
- + + + + Boot Failed + + + +
+ +
+

Boot Failed

+

+ Umbraco failed to boot, if you are the owner of the website + please see the log file for more details. +

+
+
+ diff --git a/src/Umbraco.Web.UI.Client/src/views/packages/views/install-local.controller.js b/src/Umbraco.Web.UI.Client/src/views/packages/views/install-local.controller.js new file mode 100644 index 0000000000..5996ae105c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/packages/views/install-local.controller.js @@ -0,0 +1,212 @@ +(function () { + "use strict"; + + function PackagesInstallLocalController($scope, Upload, umbRequestHelper, packageResource, localStorageService, $timeout, $window, localizationService, $q) { + + var vm = this; + vm.state = "upload"; + + vm.localPackage = {}; + vm.installPackage = installPackage; + vm.installState = { + status: "", + progress: 0 + }; + vm.installCompleted = false; + vm.zipFile = { + uploadStatus: "idle", + uploadProgress: 0, + serverErrorMessage: null + }; + + $scope.handleFiles = function (files, event, invalidFiles) { + if (files) { + for (var i = 0; i < files.length; i++) { + upload(files[i]); + } + } + }; + + var labels = {}; + var labelKeys = [ + "packager_installStateImporting", + "packager_installStateInstalling", + "packager_installStateRestarting", + "packager_installStateComplete", + "packager_installStateCompleted" + ]; + + localizationService.localizeMany(labelKeys).then(function (values) { + labels.installStateImporting = values[0]; + labels.installStateInstalling = values[1]; + labels.installStateRestarting = values[2]; + labels.installStateComplete = values[3]; + labels.installStateCompleted = values[4]; + }); + + function upload(file) { + + Upload.upload({ + url: umbRequestHelper.getApiUrl("packageInstallApiBaseUrl", "UploadLocalPackage"), + fields: {}, + file: file + }).progress(function (evt) { + + // hack: in some browsers the progress event is called after success + // this prevents the UI from going back to a uploading state + if (vm.zipFile.uploadStatus !== "done" && vm.zipFile.uploadStatus !== "error") { + + // set view state to uploading + vm.state = 'uploading'; + + // calculate progress in percentage + var progressPercentage = parseInt(100.0 * evt.loaded / evt.total, 10); + + // set percentage property on file + vm.zipFile.uploadProgress = progressPercentage; + + // set uploading status on file + vm.zipFile.uploadStatus = "uploading"; + + } + + }).success(function (data, status, headers, config) { + + if (data.notifications && data.notifications.length > 0) { + + // set error status on file + vm.zipFile.uploadStatus = "error"; + + // Throw message back to user with the cause of the error + vm.zipFile.serverErrorMessage = data.notifications[0].message; + + } else { + + // set done status on file + vm.zipFile.uploadStatus = "done"; + loadPackage(); + vm.zipFile.uploadProgress = 100; + vm.localPackage = data; + } + + }).error(function (evt, status, headers, config) { + + // set status done + vm.zipFile.uploadStatus = "error"; + + // If file not found, server will return a 404 and display this message + if (status === 404) { + vm.zipFile.serverErrorMessage = "File not found"; + } + else if (status == 400) { + //it's a validation error + vm.zipFile.serverErrorMessage = evt.message; + } + else { + //it's an unhandled error + //if the service returns a detailed error + if (evt.InnerException) { + vm.zipFile.serverErrorMessage = evt.InnerException.ExceptionMessage; + + //Check if its the common "too large file" exception + if (evt.InnerException.StackTrace && evt.InnerException.StackTrace.indexOf("ValidateRequestEntityLength") > 0) { + vm.zipFile.serverErrorMessage = "File too large to upload"; + } + + } else if (evt.Message) { + vm.zipFile.serverErrorMessage = evt.Message; + } + } + }); + } + + function loadPackage() { + if (vm.zipFile.uploadStatus === "done") { + vm.state = "packageDetails"; + } + } + + function installPackage() { + + vm.installState.status = labels.installStateImporting; + vm.installState.progress = "0"; + + packageResource + .import(vm.localPackage) + .then(function (pack) { + vm.installState.progress = "25"; + vm.installState.status = labels.installStateInstalling; + return packageResource.installFiles(pack); + }, + installError) + .then(function (pack) { + vm.installState.status = labels.installStateRestarting; + vm.installState.progress = "50"; + var deferred = $q.defer(); + + //check if the app domain is restarted ever 2 seconds + var count = 0; + + function checkRestart() { + $timeout(function () { + packageResource.checkRestart(pack).then(function (d) { + count++; + //if there is an id it means it's not restarted yet but we'll limit it to only check 10 times + if (d.isRestarting && count < 10) { + checkRestart(); + } + else { + //it's restarted! + deferred.resolve(d); + } + }, + installError); + }, + 2000); + } + + checkRestart(); + + return deferred.promise; + }, + installError) + .then(function (pack) { + vm.installState.status = labels.installStateInstalling; + vm.installState.progress = "75"; + return packageResource.installData(pack); + }, + installError) + .then(function (pack) { + vm.installState.status = labels.installStateComplete; + vm.installState.progress = "100"; + return packageResource.cleanUp(pack); + }, + installError) + .then(function (result) { + + //Put the package data in local storage so we can use after reloading + localStorageService.set("packageInstallData", result); + + vm.installState.status = labels.installStateCompleted; + vm.installCompleted = true; + + + }, installError); + } + + function installError() { + //This will return a rejection meaning that the promise change above will stop + return $q.reject(); + } + + vm.reloadPage = function () { + //reload on next digest (after cookie) + $timeout(function () { + $window.location.reload(true); + }); + } + } + + angular.module("umbraco").controller("Umbraco.Editors.Packages.InstallLocalController", PackagesInstallLocalController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/packages/views/install-local.html b/src/Umbraco.Web.UI.Client/src/views/packages/views/install-local.html new file mode 100644 index 0000000000..4d44af7d6e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/packages/views/install-local.html @@ -0,0 +1,194 @@ +
+ +
+ + +
+ + + +
+ +
+ + + + + Drop to upload + + + +
+ - or click here to choose files +
+ +
+ +
+ +

Upload package

+

+ Install a local package by selecting it from your machine. Only install packages from sources you know and trust. +

+ +
+
+
+ +
+ + + + + + + +
+ +
+
+
+ +
+
+ +

Uploading package...

+ + + + +
+ {{ vm.zipFile.serverErrorMessage }} +
+ +
+
+
+ +
+ +
+ +
+ + + + + + + +
+ + +
+
+ +
+
+ + +
+ +
+

{{ vm.localPackage.name }}

+ + + +
+ Contributors
+ {{ vm.localPackage.contributors.join(', ')}} +
+ +
+ Version
+ {{ vm.localPackage.version }} +

+ + + Upgrading from version + {{ vm.localPackage.originalVersion }} + + +

+
+ + + +
+ Read me
+ +
+ +
+ + + I accept terms of use + + + + +
+ +
+ + +
+ +
+ This package cannot be installed, it requires a minimum Umbraco version of {{vm.localPackage.umbracoVersion}} +
+
+

{{vm.installState.status}}

+
+ +
+ + +
+ +
+
+ +
+
+
+
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/packages/views/repo.controller.js b/src/Umbraco.Web.UI.Client/src/views/packages/views/repo.controller.js index 05b09e8abd..a3405bc2bf 100644 --- a/src/Umbraco.Web.UI.Client/src/views/packages/views/repo.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/packages/views/repo.controller.js @@ -1,7 +1,7 @@ (function () { "use strict"; - function PackagesRepoController($scope, $timeout, ourPackageRepositoryResource, $q, packageResource, localStorageService, localizationService) { + function PackagesRepoController($scope, $timeout, ourPackageRepositoryResource, $q, localizationService) { var vm = this; @@ -24,6 +24,8 @@ vm.closeLightbox = closeLightbox; vm.search = search; vm.installCompleted = false; + vm.highlightedPackageCollections = []; + vm.labels = {}; var defaultSort = "Latest"; var currSort = defaultSort; @@ -46,28 +48,38 @@ function init() { vm.loading = true; + localizationService.localizeMany(["packager_packagesPopular", "packager_packagesPromoted"]) + .then(function (labels) { + vm.labels.popularPackages = labels[0]; + vm.labels.promotedPackages = labels[1]; - $q.all([ - ourPackageRepositoryResource.getCategories() - .then(function (cats) { - vm.categories = cats.filter(function (cat) { - return cat.name !== "Umbraco Pro"; + var popularPackages, promotedPackages; + $q.all([ + ourPackageRepositoryResource.getCategories() + .then(function (cats) { + vm.categories = cats.filter(function (cat) { + return cat.name !== "Umbraco Pro"; + }); + }), + ourPackageRepositoryResource.getPopular(10) + .then(function (pack) { + popularPackages = { title: vm.labels.popularPackages, packages: pack.packages }; + }), + ourPackageRepositoryResource.getPromoted(20) + .then(function (pack) { + promotedPackages = { title: vm.labels.promotedPackages, packages: pack.packages }; + }), + ourPackageRepositoryResource.search(vm.pagination.pageNumber - 1, vm.pagination.pageSize, currSort) + .then(function (pack) { + vm.packages = pack.packages; + vm.pagination.totalPages = Math.ceil(pack.total / vm.pagination.pageSize); + }) + ]) + .then(function () { + vm.highlightedPackageCollections = [popularPackages, promotedPackages]; + vm.loading = false; }); - }), - ourPackageRepositoryResource.getPopular(8) - .then(function (pack) { - vm.popular = pack.packages; - }), - ourPackageRepositoryResource.search(vm.pagination.pageNumber - 1, vm.pagination.pageSize, currSort) - .then(function (pack) { - vm.packages = pack.packages; - vm.pagination.totalPages = Math.ceil(pack.total / vm.pagination.pageSize); - }) - ]) - .then(function () { - vm.loading = false; }); - } function selectCategory(selectedCategory, categories) { @@ -96,10 +108,15 @@ currSort = defaultSort; + var popularPackages, promotedPackages; $q.all([ - ourPackageRepositoryResource.getPopular(8, searchCategory) + ourPackageRepositoryResource.getPopular(10, searchCategory) .then(function (pack) { - vm.popular = pack.packages; + popularPackages = { title: vm.labels.popularPackages, packages: pack.packages }; + }), + ourPackageRepositoryResource.getPromoted(20, searchCategory) + .then(function (pack) { + promotedPackages = { title: vm.labels.promotedPackages, packages: pack.packages }; }), ourPackageRepositoryResource.search(vm.pagination.pageNumber - 1, vm.pagination.pageSize, currSort, searchCategory, vm.searchQuery) .then(function (pack) { @@ -109,6 +126,7 @@ }) ]) .then(function () { + vm.highlightedPackageCollections = [popularPackages, promotedPackages]; vm.loading = false; }); } diff --git a/src/Umbraco.Web.UI.Client/src/views/packages/views/repo.html b/src/Umbraco.Web.UI.Client/src/views/packages/views/repo.html index 262cf3eda6..7dc3e499fe 100644 --- a/src/Umbraco.Web.UI.Client/src/views/packages/views/repo.html +++ b/src/Umbraco.Web.UI.Client/src/views/packages/views/repo.html @@ -36,46 +36,48 @@
-
-

Popular

-
+
+
+

{{highlightedPackageCollection.title}}

+
-
- -
+ +
-
+
+
diff --git a/src/Umbraco.Web.UI/Composers/ControllersAsServicesComposer.cs b/src/Umbraco.Web.UI/Composers/ControllersAsServicesComposer.cs new file mode 100644 index 0000000000..042343df67 --- /dev/null +++ b/src/Umbraco.Web.UI/Composers/ControllersAsServicesComposer.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.DependencyInjection; + +namespace Umbraco.Cms.Web.UI.Composers +{ + /// + /// Adds controllers to the service collection. + /// + /// + /// + /// Umbraco 9 out of the box, makes use of which doesn't resolve controller + /// instances from the IOC container, instead it resolves the required dependencies of the controller and constructs an instance + /// of the controller. + /// + /// + /// Some users may wish to switch to (perhaps to make use of interception/decoration). + /// + /// + /// This composer exists to help us detect ambiguous constructors in the CMS such that we do not cause unnecessary effort downstream. + /// + /// + /// This Composer is not shipped by the Umbraco.Templates package. + /// + /// + public class ControllersAsServicesComposer : IComposer + { + /// + public void Compose(IUmbracoBuilder builder) => builder.Services + .AddMvc() + .AddControllersAsServicesWithoutChangingActivator(); + } + + internal static class MvcBuilderExtensions + { + /// + /// but without the replacement of + /// . + /// + /// + /// We don't need to opt in to to ensure container validation + /// passes. + /// + public static IMvcBuilder AddControllersAsServicesWithoutChangingActivator(this IMvcBuilder builder) + { + var feature = new ControllerFeature(); + builder.PartManager.PopulateFeature(feature); + + foreach (Type controller in feature.Controllers.Select(c => c.AsType())) + { + builder.Services.TryAddTransient(controller, controller); + } + + return builder; + } + } +} diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 69f812b6e6..4222f4312d 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -18,6 +18,13 @@ + + + + + + + @@ -29,6 +36,7 @@ + @@ -36,6 +44,7 @@ + @@ -45,6 +54,7 @@ + @@ -62,6 +72,7 @@ + @@ -92,7 +103,7 @@ - + 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.UI/umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml index 4fc52fc0a7..6364d58b5d 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml @@ -1292,6 +1292,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Please try searching for another package or browse through the categories Popular + Promoted New releases has karma points 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 2d575ba77f..e46e917a85 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml @@ -1310,6 +1310,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Please try searching for another package or browse through the categories Popular + Promoted New releases has karma points diff --git a/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs b/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs index 2d5ec250e9..cb9188f5d0 100644 --- a/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs +++ b/src/Umbraco.Web.Website/Controllers/UmbExternalLoginController.cs @@ -4,12 +4,15 @@ 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 Microsoft.Extensions.Options; using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; 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 +30,13 @@ namespace Umbraco.Cms.Web.Website.Controllers public class UmbExternalLoginController : SurfaceController { private readonly IMemberManager _memberManager; + private readonly ITwoFactorLoginService _twoFactorLoginService; + private readonly IOptions _securitySettings; + private readonly ILogger _logger; private readonly IMemberSignInManagerExternalLogins _memberSignInManager; public UmbExternalLoginController( + ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, IUmbracoDatabaseFactory databaseFactory, ServiceContext services, @@ -37,7 +44,9 @@ namespace Umbraco.Cms.Web.Website.Controllers IProfilingLogger profilingLogger, IPublishedUrlProvider publishedUrlProvider, IMemberSignInManagerExternalLogins memberSignInManager, - IMemberManager memberManager) + IMemberManager memberManager, + ITwoFactorLoginService twoFactorLoginService, + IOptions securitySettings) : base( umbracoContextAccessor, databaseFactory, @@ -46,8 +55,11 @@ namespace Umbraco.Cms.Web.Website.Controllers profilingLogger, publishedUrlProvider) { + _logger = logger; _memberSignInManager = memberSignInManager; _memberManager = memberManager; + _twoFactorLoginService = twoFactorLoginService; + _securitySettings = securitySettings; } /// @@ -88,7 +100,7 @@ namespace Umbraco.Cms.Web.Website.Controllers } else { - SignInResult result = await _memberSignInManager.ExternalLoginSignInAsync(loginInfo, false); + SignInResult result = await _memberSignInManager.ExternalLoginSignInAsync(loginInfo, false, _securitySettings.Value.MemberBypassTwoFactorForExternalLogins); if (result == SignInResult.Success) { @@ -108,14 +120,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/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs b/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs new file mode 100644 index 0000000000..bc14f43235 --- /dev/null +++ b/src/Umbraco.Web/HealthCheck/Checks/Security/UmbracoApplicationUrlCheck.cs @@ -0,0 +1,109 @@ +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 url = _settings.WebRouting.UmbracoApplicationUrl; + + string resultMessage; + StatusResultType resultType; + var actions = new List(); + + 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) + { + 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/stylecop.json b/stylecop.json deleted file mode 100644 index b2f7771470..0000000000 --- a/stylecop.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", - "settings": { - "orderingRules": { - "usingDirectivesPlacement": "outsideNamespace", - "elementOrder": [ - "kind" - ] - }, - "documentationRules": { - "xmlHeader": false, - "documentInternalElements": false, - "copyrightText": "Copyright (c) Umbraco.\nSee LICENSE for more details." - } - } -} 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(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockEditorComponentTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockEditorComponentTests.cs index b76719888f..efd753296a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockEditorComponentTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockEditorComponentTests.cs @@ -21,7 +21,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors private readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings { Formatting = Formatting.None, - NullValueHandling = NullValueHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore }; private const string ContentGuid1 = "036ce82586a64dfba2d523a99ed80f58"; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NestedContentPropertyComponentTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NestedContentPropertyComponentTests.cs index 0ada6a20dd..f32f252633 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NestedContentPropertyComponentTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/NestedContentPropertyComponentTests.cs @@ -3,6 +3,7 @@ using System; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NUnit.Framework; using Umbraco.Cms.Core.PropertyEditors; @@ -11,6 +12,11 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.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() { @@ -27,17 +33,17 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors Guid 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 NestedContentPropertyHandler(); - 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] @@ -48,29 +54,27 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors Guid 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()) @@ -79,9 +83,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors .Replace("fbde4288-8382-4e13-8933-ed9c160de050", guids[3].ToString()); var component = new NestedContentPropertyHandler(); - 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] @@ -93,7 +97,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.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"", @@ -104,21 +109,21 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors ""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()) @@ -127,9 +132,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors .Replace("fbde4288-8382-4e13-8933-ed9c160de050", guids[3].ToString()); var component = new NestedContentPropertyHandler(); - 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] @@ -141,7 +146,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.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"", @@ -152,7 +157,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" } - ]").ToString()); + ]").ToString(Formatting.None)); // Complex editor such as the grid var complexEditorJsonEscaped = @"{ @@ -231,9 +236,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors .Replace("fbde4288-8382-4e13-8933-ed9c160de050", guids[3].ToString()); var component = new NestedContentPropertyHandler(); - 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] @@ -252,10 +257,10 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors 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] @@ -267,7 +272,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.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"" @@ -276,7 +281,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" } - ]").ToString()); + ]").ToString(Formatting.None)); var json = @"[{ ""name"": ""Item 1 was copied and has no key"", @@ -295,9 +300,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors 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] @@ -309,7 +314,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.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"", @@ -319,7 +324,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors ""ncContentTypeAlias"": ""text"", ""text"": ""zoot"" } - ]").ToString()); + ]").ToString(Formatting.None)); // Complex editor such as the grid var complexEditorJsonEscaped = @"{ @@ -394,8 +399,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors 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/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs index 29b011a5a6..c690f35b7a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices.ServerRegistration @@ -51,7 +52,15 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices.Serv VerifyServerTouched(); } - private TouchServerTask CreateTouchServerTask(RuntimeLevel runtimeLevel = RuntimeLevel.Run, string applicationUrl = ApplicationUrl) + [Test] + public async Task Does_Not_Execute_When_Role_Accessor_Is_Not_Elected() + { + TouchServerTask sut = CreateTouchServerTask(useElection: false); + await sut.PerformExecuteAsync(null); + VerifyServerNotTouched(); + } + + private TouchServerTask CreateTouchServerTask(RuntimeLevel runtimeLevel = RuntimeLevel.Run, string applicationUrl = ApplicationUrl, bool useElection = true) { var mockRequestAccessor = new Mock(); mockRequestAccessor.SetupGet(x => x.ApplicationMainUrl).Returns(!string.IsNullOrEmpty(applicationUrl) ? new Uri(ApplicationUrl) : null); @@ -71,12 +80,17 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices.Serv } }; + IServerRoleAccessor roleAccessor = useElection + ? new ElectedServerRoleAccessor(_mockServerRegistrationService.Object) + : new SingleServerRoleAccessor(); + return new TouchServerTask( mockRunTimeState.Object, _mockServerRegistrationService.Object, mockRequestAccessor.Object, mockLogger.Object, - Options.Create(settings)); + Options.Create(settings), + roleAccessor); } private void VerifyServerNotTouched() => VerifyServerTouchedTimes(Times.Never()); 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/ImageCropperTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/ImageCropperTest.cs index f00225e7b4..f5d5d7c766 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/ImageCropperTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/ImageCropperTest.cs @@ -22,13 +22,13 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common { 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 ImageCropperValue cropperValue = CropperJson3.DeserializeImageCropperValue(); Attempt serialized = cropperValue.TryConvertTo(); Assert.IsTrue(serialized.Success); @@ -38,7 +38,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common [Test] public void CanConvertImageCropperDataSetJObject() { - // cropperJson3 - has not crops + // cropperJson3 - has no crops ImageCropperValue cropperValue = CropperJson3.DeserializeImageCropperValue(); Attempt serialized = cropperValue.TryConvertTo(); Assert.IsTrue(serialized.Success); 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() diff --git a/umbraco.sln b/umbraco.sln index 0018c91041..497258c699 100644 --- a/umbraco.sln +++ b/umbraco.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29209.152 @@ -38,7 +37,7 @@ EndProject Project("{E24C65DC-7377-472B-9ABA-BC803B73C61A}") = "Umbraco.Web.UI.Client", "http://localhost:3961", "{3819A550-DCEC-4153-91B4-8BA9F7F0B9B4}" ProjectSection(WebsiteProperties) = preProject UseIISExpress = "true" - TargetFrameworkMoniker = ".NETFramework,Version%3Dv4.5" + TargetFrameworkMoniker = ".NETFramework,Version%3Dv4.5.2" Debug.AspNetCompiler.VirtualPath = "/localhost_3961" Debug.AspNetCompiler.PhysicalPath = "src\Umbraco.Web.UI.Client\" Debug.AspNetCompiler.TargetPath = "PrecompiledWeb\localhost_3961\" @@ -61,7 +60,7 @@ EndProject Project("{E24C65DC-7377-472B-9ABA-BC803B73C61A}") = "Umbraco.Tests.AcceptanceTest", "http://localhost:58896", "{9E4C8A12-FBE0-4673-8CE2-DF99D5D57817}" ProjectSection(WebsiteProperties) = preProject UseIISExpress = "true" - TargetFrameworkMoniker = ".NETFramework,Version%3Dv4.5" + TargetFrameworkMoniker = ".NETFramework,Version%3Dv4.5.2" Debug.AspNetCompiler.VirtualPath = "/localhost_62926" Debug.AspNetCompiler.PhysicalPath = "tests\Umbraco.Tests.AcceptanceTest\" Debug.AspNetCompiler.TargetPath = "PrecompiledWeb\localhost_62926\"