diff --git a/Directory.Build.props b/Directory.Build.props index da9b4e3fa7..4e42bb2f8f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,9 +1,9 @@ - - - - + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 6eaa51e431..b7d39cc6f5 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - + 10.0.0 diff --git a/src/Umbraco.Web.BackOffice/ActionResults/JavaScriptResult.cs b/src/Umbraco.Web.BackOffice/ActionResults/JavaScriptResult.cs index c092c57897..5344a22707 100644 --- a/src/Umbraco.Web.BackOffice/ActionResults/JavaScriptResult.cs +++ b/src/Umbraco.Web.BackOffice/ActionResults/JavaScriptResult.cs @@ -1,13 +1,12 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; -namespace Umbraco.Cms.Web.BackOffice.ActionResults +namespace Umbraco.Cms.Web.BackOffice.ActionResults; + +public class JavaScriptResult : ContentResult { - public class JavaScriptResult : ContentResult + public JavaScriptResult(string? script) { - public JavaScriptResult(string? script) - { - this.Content = script; - this.ContentType = "application/javascript"; - } + Content = script; + ContentType = "application/javascript"; } } diff --git a/src/Umbraco.Web.BackOffice/ActionResults/UmbracoErrorResult.cs b/src/Umbraco.Web.BackOffice/ActionResults/UmbracoErrorResult.cs index cb4d999e69..a2ae808b19 100644 --- a/src/Umbraco.Web.BackOffice/ActionResults/UmbracoErrorResult.cs +++ b/src/Umbraco.Web.BackOffice/ActionResults/UmbracoErrorResult.cs @@ -1,27 +1,20 @@ -using System.Net; +using System.Net; using Microsoft.AspNetCore.Mvc; -namespace Umbraco.Cms.Web.BackOffice.ActionResults +namespace Umbraco.Cms.Web.BackOffice.ActionResults; + +public class UmbracoErrorResult : ObjectResult { - public class UmbracoErrorResult : ObjectResult + public UmbracoErrorResult(HttpStatusCode statusCode, string message) : this(statusCode, new MessageWrapper(message)) { - public UmbracoErrorResult(HttpStatusCode statusCode, string message) : this (statusCode, new MessageWrapper(message)) - { - } + } - public UmbracoErrorResult(HttpStatusCode statusCode, object value) : base(value) - { - StatusCode = (int)statusCode; - } + public UmbracoErrorResult(HttpStatusCode statusCode, object value) : base(value) => StatusCode = (int)statusCode; - private class MessageWrapper - { - public MessageWrapper(string message) - { - Message = message; - } + private class MessageWrapper + { + public MessageWrapper(string message) => Message = message; - public string Message { get;} - } + public string Message { get; } } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/AdminUsersHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/AdminUsersHandler.cs index 819e0b127b..b5cc970025 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/AdminUsersHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/AdminUsersHandler.cs @@ -1,11 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -14,80 +10,84 @@ using Umbraco.Cms.Core.Editors; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// If the users being edited is an admin then we must ensure that the current user is also an admin. +/// +public class AdminUsersHandler : MustSatisfyRequirementAuthorizationHandler { + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly UserEditorAuthorizationHelper _userEditorAuthorizationHelper; + private readonly IUserService _userService; + /// - /// If the users being edited is an admin then we must ensure that the current user is also an admin. + /// Initializes a new instance of the class. /// - public class AdminUsersHandler : MustSatisfyRequirementAuthorizationHandler + /// Accessor for the HTTP context of the current request. + /// Service for user related operations. + /// Accessor for back-office security. + /// Helper for user authorization checks. + public AdminUsersHandler( + IHttpContextAccessor httpContextAccessor, + IUserService userService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + UserEditorAuthorizationHelper userEditorAuthorizationHelper) { - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IUserService _userService; - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly UserEditorAuthorizationHelper _userEditorAuthorizationHelper; + _httpContextAccessor = httpContextAccessor; + _userService = userService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _userEditorAuthorizationHelper = userEditorAuthorizationHelper; + } - /// - /// Initializes a new instance of the class. - /// - /// Accessor for the HTTP context of the current request. - /// Service for user related operations. - /// Accessor for back-office security. - /// Helper for user authorization checks. - public AdminUsersHandler( - IHttpContextAccessor httpContextAccessor, - IUserService userService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - UserEditorAuthorizationHelper userEditorAuthorizationHelper) + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, AdminUsersRequirement requirement) + { + StringValues? queryString = _httpContextAccessor.HttpContext?.Request.Query[requirement.QueryStringName]; + if (!queryString.HasValue || !queryString.Value.Any()) { - _httpContextAccessor = httpContextAccessor; - _userService = userService; - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _userEditorAuthorizationHelper = userEditorAuthorizationHelper; + // Must succeed this requirement since we cannot process it. + return Task.FromResult(true); } - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, AdminUsersRequirement requirement) + int[]? userIds; + if (int.TryParse(queryString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var userId)) { - StringValues? queryString = _httpContextAccessor.HttpContext?.Request.Query[requirement.QueryStringName]; - if (!queryString.HasValue || !queryString.Value.Any()) + userIds = new[] { userId }; + } + else + { + var ids = queryString.ToString()?.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + if (ids?.Count == 0) { // Must succeed this requirement since we cannot process it. return Task.FromResult(true); } - int[]? userIds; - if (int.TryParse(queryString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var userId)) - { - userIds = new[] { userId }; - } - else - { - var ids = queryString.ToString()?.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).ToList(); - if (ids?.Count == 0) - { - // Must succeed this requirement since we cannot process it. - return Task.FromResult(true); - } - - userIds = ids? - .Select(x => int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var output) ? Attempt.Succeed(output) : Attempt.Fail()) - .Where(x => x.Success) - .Select(x => x.Result) - .ToArray(); - } - - if (userIds?.Length == 0) - { - // Must succeed this requirement since we cannot process it. - return Task.FromResult(true); - } - - IEnumerable users = _userService.GetUsersById(userIds); - var isAuth = users.All(user => _userEditorAuthorizationHelper.IsAuthorized(_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, user, null, null, null) != false); - - return Task.FromResult(isAuth); + userIds = ids? + .Select(x => + int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var output) + ? Attempt.Succeed(output) + : Attempt.Fail()) + .Where(x => x.Success) + .Select(x => x.Result) + .ToArray(); } + + if (userIds?.Length == 0) + { + // Must succeed this requirement since we cannot process it. + return Task.FromResult(true); + } + + IEnumerable users = _userService.GetUsersById(userIds); + var isAuth = users.All(user => + _userEditorAuthorizationHelper.IsAuthorized(_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, + user, null, null, null) != false); + + return Task.FromResult(isAuth); } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/AdminUsersRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/AdminUsersRequirement.cs index 95e115bc16..2c2aea0ff2 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/AdminUsersRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/AdminUsersRequirement.cs @@ -3,22 +3,21 @@ using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Authorization requirement for the +/// +public class AdminUsersRequirement : IAuthorizationRequirement { /// - /// Authorization requirement for the + /// Initializes a new instance of the class. /// - public class AdminUsersRequirement : IAuthorizationRequirement - { - /// - /// Initializes a new instance of the class. - /// - /// Query string name from which to authorize values. - public AdminUsersRequirement(string queryStringName = "id") => QueryStringName = queryStringName; + /// Query string name from which to authorize values. + public AdminUsersRequirement(string queryStringName = "id") => QueryStringName = queryStringName; - /// - /// Gets the query string name from which to authorize values. - /// - public string QueryStringName { get; } - } + /// + /// Gets the query string name from which to authorize values. + /// + public string QueryStringName { get; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/BackOfficeHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/BackOfficeHandler.cs index 99fd24348a..451aec2d89 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/BackOfficeHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/BackOfficeHandler.cs @@ -1,48 +1,45 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Ensures authorization is successful for a back office user. +/// +public class BackOfficeHandler : MustSatisfyRequirementAuthorizationHandler { - /// - /// Ensures authorization is successful for a back office user. - /// - public class BackOfficeHandler : MustSatisfyRequirementAuthorizationHandler + private readonly IBackOfficeSecurityAccessor _backOfficeSecurity; + private readonly IRuntimeState _runtimeState; + + public BackOfficeHandler(IBackOfficeSecurityAccessor backOfficeSecurity, IRuntimeState runtimeState) { - private readonly IBackOfficeSecurityAccessor _backOfficeSecurity; - private readonly IRuntimeState _runtimeState; + _backOfficeSecurity = backOfficeSecurity; + _runtimeState = runtimeState; + } - public BackOfficeHandler(IBackOfficeSecurityAccessor backOfficeSecurity, IRuntimeState runtimeState) + protected override Task IsAuthorized(AuthorizationHandlerContext context, BackOfficeRequirement requirement) + { + // if not configured (install or upgrade) then we can continue + // otherwise we need to ensure that a user is logged in + + switch (_runtimeState.Level) { - _backOfficeSecurity = backOfficeSecurity; - _runtimeState = runtimeState; + case var _ when _runtimeState.EnableInstaller(): + return Task.FromResult(true); + default: + if (!_backOfficeSecurity.BackOfficeSecurity?.IsAuthenticated() ?? false) + { + return Task.FromResult(false); + } + + var userApprovalSucceeded = !requirement.RequireApproval || + (_backOfficeSecurity.BackOfficeSecurity?.CurrentUser?.IsApproved ?? false); + return Task.FromResult(userApprovalSucceeded); } - - protected override Task IsAuthorized(AuthorizationHandlerContext context, BackOfficeRequirement requirement) - { - // if not configured (install or upgrade) then we can continue - // otherwise we need to ensure that a user is logged in - - switch (_runtimeState.Level) - { - case var _ when _runtimeState.EnableInstaller(): - return Task.FromResult(true); - default: - if (!_backOfficeSecurity.BackOfficeSecurity?.IsAuthenticated() ?? false) - { - return Task.FromResult(false); - } - - var userApprovalSucceeded = !requirement.RequireApproval || (_backOfficeSecurity.BackOfficeSecurity?.CurrentUser?.IsApproved ?? false); - return Task.FromResult(userApprovalSucceeded); - } - } - } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/BackOfficeRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/BackOfficeRequirement.cs index 1bce297b9b..1512d48ad9 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/BackOfficeRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/BackOfficeRequirement.cs @@ -3,22 +3,21 @@ using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Authorization requirement for the +/// +public class BackOfficeRequirement : IAuthorizationRequirement { /// - /// Authorization requirement for the + /// Initializes a new instance of the class. /// - public class BackOfficeRequirement : IAuthorizationRequirement - { - /// - /// Initializes a new instance of the class. - /// - /// Flag for whether back-office user approval is required. - public BackOfficeRequirement(bool requireApproval = true) => RequireApproval = requireApproval; + /// Flag for whether back-office user approval is required. + public BackOfficeRequirement(bool requireApproval = true) => RequireApproval = requireApproval; - /// - /// Gets a value indicating whether back-office user approval is required. - /// - public bool RequireApproval { get; } - } + /// + /// Gets a value indicating whether back-office user approval is required. + /// + public bool RequireApproval { get; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsPublishBranchHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsPublishBranchHandler.cs index 8c9814d41b..faf67d8ec5 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsPublishBranchHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsPublishBranchHandler.cs @@ -1,81 +1,78 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// The user must have access to all descendant nodes of the content item in order to continue. +/// +public class ContentPermissionsPublishBranchHandler : MustSatisfyRequirementAuthorizationHandler< + ContentPermissionsPublishBranchRequirement, IContent> { + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly ContentPermissions _contentPermissions; + private readonly IEntityService _entityService; + /// - /// The user must have access to all descendant nodes of the content item in order to continue. + /// Initializes a new instance of the class. /// - public class ContentPermissionsPublishBranchHandler : MustSatisfyRequirementAuthorizationHandler + /// Service for entity operations. + /// per for user content authorization checks. + /// Accessor for back-office security. + public ContentPermissionsPublishBranchHandler( + IEntityService entityService, + ContentPermissions contentPermissions, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { - private readonly IEntityService _entityService; - private readonly ContentPermissions _contentPermissions; - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + _entityService = entityService; + _contentPermissions = contentPermissions; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } - /// - /// Initializes a new instance of the class. - /// - /// Service for entity operations. - /// per for user content authorization checks. - /// Accessor for back-office security. - public ContentPermissionsPublishBranchHandler( - IEntityService entityService, - ContentPermissions contentPermissions, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, + ContentPermissionsPublishBranchRequirement requirement, IContent resource) + { + IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + + var denied = new List(); + var page = 0; + const int pageSize = 500; + var total = long.MaxValue; + + while (page * pageSize < total) { - _entityService = entityService; - _contentPermissions = contentPermissions; - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - } + // Order descendents by shallowest to deepest, this allows us to check permissions from top to bottom so we can exit + // early if a permission higher up fails. + IEnumerable descendants = _entityService.GetPagedDescendants( + resource.Id, + UmbracoObjectTypes.Document, + page++, + pageSize, + out total, + ordering: Ordering.By("path")); - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, ContentPermissionsPublishBranchRequirement requirement, IContent resource) - { - IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - - var denied = new List(); - var page = 0; - const int pageSize = 500; - var total = long.MaxValue; - - while (page * pageSize < total) + foreach (IEntitySlim c in descendants) { - // Order descendents by shallowest to deepest, this allows us to check permissions from top to bottom so we can exit - // early if a permission higher up fails. - IEnumerable descendants = _entityService.GetPagedDescendants( - resource.Id, - UmbracoObjectTypes.Document, - page++, - pageSize, - out total, - ordering: Ordering.By("path", Direction.Ascending)); - - foreach (IEntitySlim c in descendants) + // If this item's path has already been denied or if the user doesn't have access to it, add to the deny list. + if (denied.Any(x => c.Path.StartsWith($"{x.Path},")) || + _contentPermissions.CheckPermissions( + c, + currentUser, + requirement.Permission) == ContentPermissions.ContentAccess.Denied) { - // If this item's path has already been denied or if the user doesn't have access to it, add to the deny list. - if (denied.Any(x => c.Path.StartsWith($"{x.Path},")) || - (_contentPermissions.CheckPermissions( - c, - currentUser, - requirement.Permission) == ContentPermissions.ContentAccess.Denied)) - { - denied.Add(c); - } + denied.Add(c); } } - - return Task.FromResult(denied.Count == 0); } + + return Task.FromResult(denied.Count == 0); } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsPublishBranchRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsPublishBranchRequirement.cs index caf987488f..4bb1636b63 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsPublishBranchRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsPublishBranchRequirement.cs @@ -3,22 +3,21 @@ using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Authorization requirement for +/// +public class ContentPermissionsPublishBranchRequirement : IAuthorizationRequirement { /// - /// Authorization requirement for + /// Initializes a new instance of the class. /// - public class ContentPermissionsPublishBranchRequirement : IAuthorizationRequirement - { - /// - /// Initializes a new instance of the class. - /// - /// Permission to check. - public ContentPermissionsPublishBranchRequirement(char permission) => Permission = permission; + /// Permission to check. + public ContentPermissionsPublishBranchRequirement(char permission) => Permission = permission; - /// - /// Gets a value for the permission to check. - /// - public char Permission { get; } - } + /// + /// Gets a value for the permission to check. + /// + public char Permission { get; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringHandler.cs index adb4521bfe..15d0b39f65 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringHandler.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -9,73 +8,74 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Used to authorize if the user has the correct permission access to the content for the content id specified in a +/// query string. +/// +public class + ContentPermissionsQueryStringHandler : PermissionsQueryStringHandler { + private readonly ContentPermissions _contentPermissions; + /// - /// Used to authorize if the user has the correct permission access to the content for the content id specified in a query string. + /// Initializes a new instance of the class. /// - public class ContentPermissionsQueryStringHandler : PermissionsQueryStringHandler + /// Accessor for back-office security. + /// Accessor for the HTTP context of the current request. + /// Service for entity operations. + /// Helper for content authorization checks. + public ContentPermissionsQueryStringHandler( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IHttpContextAccessor httpContextAccessor, + IEntityService entityService, + ContentPermissions contentPermissions) + : base(backOfficeSecurityAccessor, httpContextAccessor, entityService) => + _contentPermissions = contentPermissions; + + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, ContentPermissionsQueryStringRequirement requirement) { - private readonly ContentPermissions _contentPermissions; - - /// - /// Initializes a new instance of the class. - /// - /// Accessor for back-office security. - /// Accessor for the HTTP context of the current request. - /// Service for entity operations. - /// Helper for content authorization checks. - public ContentPermissionsQueryStringHandler( - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IHttpContextAccessor httpContextAccessor, - IEntityService entityService, - ContentPermissions contentPermissions) - : base(backOfficeSecurityAccessor, httpContextAccessor, entityService) => _contentPermissions = contentPermissions; - - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, ContentPermissionsQueryStringRequirement requirement) + int nodeId; + if (requirement.NodeId.HasValue == false) { - int nodeId; - if (requirement.NodeId.HasValue == false) + if (HttpContextAccessor.HttpContext is null || requirement.QueryStringName is null || + !HttpContextAccessor.HttpContext.Request.Query.TryGetValue(requirement.QueryStringName, out StringValues routeVal)) { - if (HttpContextAccessor.HttpContext is null || requirement.QueryStringName is null || !HttpContextAccessor.HttpContext.Request.Query.TryGetValue(requirement.QueryStringName, out StringValues routeVal)) - { - // Must succeed this requirement since we cannot process it - return Task.FromResult(true); - } - else - { - var argument = routeVal.ToString(); - - if (!TryParseNodeId(argument, out nodeId)) - { - // Must succeed this requirement since we cannot process it. - return Task.FromResult(true); - } - } - } - else - { - nodeId = requirement.NodeId.Value; + // Must succeed this requirement since we cannot process it + return Task.FromResult(true); } - ContentPermissions.ContentAccess permissionResult = _contentPermissions.CheckPermissions( - nodeId, - BackOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, - out IContent? contentItem, - new[] { requirement.PermissionToCheck }); + var argument = routeVal.ToString(); - if (HttpContextAccessor.HttpContext is not null && contentItem is not null) + if (!TryParseNodeId(argument, out nodeId)) { - // Store the content item in request cache so it can be resolved in the controller without re-looking it up. - HttpContextAccessor.HttpContext.Items[typeof(IContent).ToString()] = contentItem; + // Must succeed this requirement since we cannot process it. + return Task.FromResult(true); } - - return permissionResult switch - { - ContentPermissions.ContentAccess.Denied => Task.FromResult(false), - _ => Task.FromResult(true), - }; } + else + { + nodeId = requirement.NodeId.Value; + } + + ContentPermissions.ContentAccess permissionResult = _contentPermissions.CheckPermissions( + nodeId, + BackOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, + out IContent? contentItem, + new[] { requirement.PermissionToCheck }); + + if (HttpContextAccessor.HttpContext is not null && contentItem is not null) + { + // Store the content item in request cache so it can be resolved in the controller without re-looking it up. + HttpContextAccessor.HttpContext.Items[typeof(IContent).ToString()] = contentItem; + } + + return permissionResult switch + { + ContentPermissions.ContentAccess.Denied => Task.FromResult(false), + _ => Task.FromResult(true) + }; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringRequirement.cs index e5a432fb93..bdeeeef2cc 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringRequirement.cs @@ -3,49 +3,49 @@ using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// An authorization requirement for +/// +public class ContentPermissionsQueryStringRequirement : IAuthorizationRequirement { /// - /// An authorization requirement for + /// Initializes a new instance of the class for a specific node + /// id. /// - public class ContentPermissionsQueryStringRequirement : IAuthorizationRequirement + /// The node Id. + /// The permission to authorize the current user against. + public ContentPermissionsQueryStringRequirement(int nodeId, char permissionToCheck) { - /// - /// Initializes a new instance of the class for a specific node id. - /// - /// The node Id. - /// The permission to authorize the current user against. - public ContentPermissionsQueryStringRequirement(int nodeId, char permissionToCheck) - { - NodeId = nodeId; - PermissionToCheck = permissionToCheck; - } - - /// - /// Initializes a new instance of the class for a - /// node id based on a query string parameter. - /// - /// The querystring parameter name. - /// The permission to authorize the current user against. - public ContentPermissionsQueryStringRequirement(char permissionToCheck, string paramName = "id") - { - QueryStringName = paramName; - PermissionToCheck = permissionToCheck; - } - - /// - /// Gets the specific node Id. - /// - public int? NodeId { get; } - - /// - /// Gets the querystring parameter name. - /// - public string? QueryStringName { get; } - - /// - /// Gets the permission to authorize the current user against. - /// - public char PermissionToCheck { get; } + NodeId = nodeId; + PermissionToCheck = permissionToCheck; } + + /// + /// Initializes a new instance of the class for a + /// node id based on a query string parameter. + /// + /// The querystring parameter name. + /// The permission to authorize the current user against. + public ContentPermissionsQueryStringRequirement(char permissionToCheck, string paramName = "id") + { + QueryStringName = paramName; + PermissionToCheck = permissionToCheck; + } + + /// + /// Gets the specific node Id. + /// + public int? NodeId { get; } + + /// + /// Gets the querystring parameter name. + /// + public string? QueryStringName { get; } + + /// + /// Gets the permission to authorize the current user against. + /// + public char PermissionToCheck { get; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResource.cs b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResource.cs index cac7ac7917..d83318531a 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResource.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResource.cs @@ -1,64 +1,62 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; using Umbraco.Cms.Core.Models; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// The resource used for the +/// +public class ContentPermissionsResource { /// - /// The resource used for the + /// Initializes a new instance of the class. /// - public class ContentPermissionsResource + /// The content. + /// The permission to authorize. + public ContentPermissionsResource(IContent? content, char permissionToCheck) { - /// - /// Initializes a new instance of the class. - /// - /// The content. - /// The permission to authorize. - public ContentPermissionsResource(IContent? content, char permissionToCheck) - { - PermissionsToCheck = new List { permissionToCheck }; - Content = content; - } - - /// - /// Initializes a new instance of the class. - /// - /// The content. - /// The collection of permissions to authorize. - public ContentPermissionsResource(IContent content, IReadOnlyList permissionsToCheck) - { - Content = content; - PermissionsToCheck = permissionsToCheck; - } - - /// - /// Initializes a new instance of the class. - /// - /// The content. - /// The node Id. - /// The collection of permissions to authorize. - public ContentPermissionsResource(IContent? content, int nodeId, IReadOnlyList permissionsToCheck) - { - Content = content; - NodeId = nodeId; - PermissionsToCheck = permissionsToCheck; - } - - /// - /// Gets the node Id. - /// - public int? NodeId { get; } - - /// - /// Gets the collection of permissions to authorize. - /// - public IReadOnlyList PermissionsToCheck { get; } - - /// - /// Gets the content. - /// - public IContent? Content { get; } + PermissionsToCheck = new List { permissionToCheck }; + Content = content; } + + /// + /// Initializes a new instance of the class. + /// + /// The content. + /// The collection of permissions to authorize. + public ContentPermissionsResource(IContent content, IReadOnlyList permissionsToCheck) + { + Content = content; + PermissionsToCheck = permissionsToCheck; + } + + /// + /// Initializes a new instance of the class. + /// + /// The content. + /// The node Id. + /// The collection of permissions to authorize. + public ContentPermissionsResource(IContent? content, int nodeId, IReadOnlyList permissionsToCheck) + { + Content = content; + NodeId = nodeId; + PermissionsToCheck = permissionsToCheck; + } + + /// + /// Gets the node Id. + /// + public int? NodeId { get; } + + /// + /// Gets the collection of permissions to authorize. + /// + public IReadOnlyList PermissionsToCheck { get; } + + /// + /// Gets the content. + /// + public IContent? Content { get; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResourceHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResourceHandler.cs index 63d90b4565..e453787c33 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResourceHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResourceHandler.cs @@ -1,49 +1,50 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Used to authorize if the user has the correct permission access to the content for the +/// specified. +/// +public class ContentPermissionsResourceHandler : MustSatisfyRequirementAuthorizationHandler< + ContentPermissionsResourceRequirement, ContentPermissionsResource> { + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly ContentPermissions _contentPermissions; + /// - /// Used to authorize if the user has the correct permission access to the content for the specified. + /// Initializes a new instance of the class. /// - public class ContentPermissionsResourceHandler : MustSatisfyRequirementAuthorizationHandler + /// Accessor for back-office security. + /// Helper for content authorization checks. + public ContentPermissionsResourceHandler( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + ContentPermissions contentPermissions) { - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly ContentPermissions _contentPermissions; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _contentPermissions = contentPermissions; + } - /// - /// Initializes a new instance of the class. - /// - /// Accessor for back-office security. - /// Helper for content authorization checks. - public ContentPermissionsResourceHandler( - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - ContentPermissions contentPermissions) - { - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _contentPermissions = contentPermissions; - } + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, + ContentPermissionsResourceRequirement requirement, ContentPermissionsResource resource) + { + ContentPermissions.ContentAccess permissionResult = resource.NodeId.HasValue + ? _contentPermissions.CheckPermissions( + resource.NodeId.Value, + _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, + out IContent? _, + resource.PermissionsToCheck) + : _contentPermissions.CheckPermissions( + resource.Content, + _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, + resource.PermissionsToCheck); - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, ContentPermissionsResourceRequirement requirement, ContentPermissionsResource resource) - { - ContentPermissions.ContentAccess permissionResult = resource.NodeId.HasValue - ? _contentPermissions.CheckPermissions( - resource.NodeId.Value, - _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, - out IContent? _, - resource.PermissionsToCheck) - : _contentPermissions.CheckPermissions( - resource.Content, - _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, - resource.PermissionsToCheck); - - return Task.FromResult(permissionResult != ContentPermissions.ContentAccess.Denied); - } + return Task.FromResult(permissionResult != ContentPermissions.ContentAccess.Denied); } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResourceRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResourceRequirement.cs index af86ab080b..a25d491604 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResourceRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResourceRequirement.cs @@ -3,12 +3,11 @@ using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// An authorization requirement for +/// +public class ContentPermissionsResourceRequirement : IAuthorizationRequirement { - /// - /// An authorization requirement for - /// - public class ContentPermissionsResourceRequirement : IAuthorizationRequirement - { - } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/DenyLocalLoginHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/DenyLocalLoginHandler.cs index dd6b7a6483..7daa9fbdd0 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/DenyLocalLoginHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/DenyLocalLoginHandler.cs @@ -1,27 +1,27 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Umbraco.Cms.Web.BackOffice.Security; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Ensures the resource cannot be accessed if +/// returns true. +/// +public class DenyLocalLoginHandler : MustSatisfyRequirementAuthorizationHandler { + private readonly IBackOfficeExternalLoginProviders _externalLogins; + /// - /// Ensures the resource cannot be accessed if returns true. + /// Initializes a new instance of the class. /// - public class DenyLocalLoginHandler : MustSatisfyRequirementAuthorizationHandler - { - private readonly IBackOfficeExternalLoginProviders _externalLogins; + /// Provides access to instances. + public DenyLocalLoginHandler(IBackOfficeExternalLoginProviders externalLogins) => _externalLogins = externalLogins; - /// - /// Initializes a new instance of the class. - /// - /// Provides access to instances. - public DenyLocalLoginHandler(IBackOfficeExternalLoginProviders externalLogins) => _externalLogins = externalLogins; - - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, DenyLocalLoginRequirement requirement) => - Task.FromResult(!_externalLogins.HasDenyLocalLogin()); - } + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, + DenyLocalLoginRequirement requirement) => + Task.FromResult(!_externalLogins.HasDenyLocalLogin()); } diff --git a/src/Umbraco.Web.BackOffice/Authorization/DenyLocalLoginRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/DenyLocalLoginRequirement.cs index d148342e3a..3aade0f9fd 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/DenyLocalLoginRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/DenyLocalLoginRequirement.cs @@ -3,12 +3,11 @@ using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Marker requirement for the . +/// +public class DenyLocalLoginRequirement : IAuthorizationRequirement { - /// - /// Marker requirement for the . - /// - public class DenyLocalLoginRequirement : IAuthorizationRequirement - { - } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringHandler.cs index fae3da7d63..7b662e5fc0 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringHandler.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -9,62 +8,65 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Used to authorize if the user has the correct permission access to the media for the media id specified in a query +/// string. +/// +public class MediaPermissionsQueryStringHandler : PermissionsQueryStringHandler { + private readonly MediaPermissions _mediaPermissions; + /// - /// Used to authorize if the user has the correct permission access to the media for the media id specified in a query string. + /// Initializes a new instance of the class. /// - public class MediaPermissionsQueryStringHandler : PermissionsQueryStringHandler + /// Accessor for back-office security. + /// Accessor for the HTTP context of the current request. + /// Service for entity operations. + /// Helper for media authorization checks. + public MediaPermissionsQueryStringHandler( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IHttpContextAccessor httpContextAccessor, + IEntityService entityService, + MediaPermissions mediaPermissions) + : base(backOfficeSecurityAccessor, httpContextAccessor, entityService) => _mediaPermissions = mediaPermissions; + + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, + MediaPermissionsQueryStringRequirement requirement) { - private readonly MediaPermissions _mediaPermissions; - - /// - /// Initializes a new instance of the class. - /// - /// Accessor for back-office security. - /// Accessor for the HTTP context of the current request. - /// Service for entity operations. - /// Helper for media authorization checks. - public MediaPermissionsQueryStringHandler( - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IHttpContextAccessor httpContextAccessor, - IEntityService entityService, - MediaPermissions mediaPermissions) - : base(backOfficeSecurityAccessor, httpContextAccessor, entityService) => _mediaPermissions = mediaPermissions; - - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, MediaPermissionsQueryStringRequirement requirement) + if (HttpContextAccessor.HttpContext is null || + !HttpContextAccessor.HttpContext.Request.Query.TryGetValue(requirement.QueryStringName, + out StringValues routeVal)) { - if (HttpContextAccessor.HttpContext is null || !HttpContextAccessor.HttpContext.Request.Query.TryGetValue(requirement.QueryStringName, out StringValues routeVal)) - { - // Must succeed this requirement since we cannot process it. - return Task.FromResult(true); - } - - var argument = routeVal.ToString(); - - if (!TryParseNodeId(argument, out int nodeId)) - { - // Must succeed this requirement since we cannot process it. - return Task.FromResult(true); - } - - MediaPermissions.MediaAccess permissionResult = _mediaPermissions.CheckPermissions( - BackOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, - nodeId, - out IMedia? mediaItem); - - if (mediaItem != null) - { - // Store the media item in request cache so it can be resolved in the controller without re-looking it up. - HttpContextAccessor.HttpContext.Items[typeof(IMedia).ToString()] = mediaItem; - } - - return permissionResult switch - { - MediaPermissions.MediaAccess.Denied => Task.FromResult(false), - _ => Task.FromResult(true), - }; + // Must succeed this requirement since we cannot process it. + return Task.FromResult(true); } + + var argument = routeVal.ToString(); + + if (!TryParseNodeId(argument, out var nodeId)) + { + // Must succeed this requirement since we cannot process it. + return Task.FromResult(true); + } + + MediaPermissions.MediaAccess permissionResult = _mediaPermissions.CheckPermissions( + BackOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, + nodeId, + out IMedia? mediaItem); + + if (mediaItem != null) + { + // Store the media item in request cache so it can be resolved in the controller without re-looking it up. + HttpContextAccessor.HttpContext.Items[typeof(IMedia).ToString()] = mediaItem; + } + + return permissionResult switch + { + MediaPermissions.MediaAccess.Denied => Task.FromResult(false), + _ => Task.FromResult(true) + }; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringRequirement.cs index 6c17d5cfac..5174fe54de 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsQueryStringRequirement.cs @@ -3,22 +3,21 @@ using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// An authorization requirement for +/// +public class MediaPermissionsQueryStringRequirement : IAuthorizationRequirement { /// - /// An authorization requirement for + /// Initializes a new instance of the class. /// - public class MediaPermissionsQueryStringRequirement : IAuthorizationRequirement - { - /// - /// Initializes a new instance of the class. - /// - /// Querystring paramter name. - public MediaPermissionsQueryStringRequirement(string paramName) => QueryStringName = paramName; + /// Querystring paramter name. + public MediaPermissionsQueryStringRequirement(string paramName) => QueryStringName = paramName; - /// - /// Gets the querystring paramter name. - /// - public string QueryStringName { get; } - } + /// + /// Gets the querystring paramter name. + /// + public string QueryStringName { get; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResource.cs b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResource.cs index a1c79ef589..562de479ca 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResource.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResource.cs @@ -3,21 +3,14 @@ using Umbraco.Cms.Core.Models; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +public class MediaPermissionsResource { - public class MediaPermissionsResource - { - public MediaPermissionsResource(IMedia? media) - { - Media = media; - } + public MediaPermissionsResource(IMedia? media) => Media = media; - public MediaPermissionsResource(int nodeId) - { - NodeId = nodeId; - } + public MediaPermissionsResource(int nodeId) => NodeId = nodeId; - public int? NodeId { get; } - public IMedia? Media { get; } - } + public int? NodeId { get; } + public IMedia? Media { get; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResourceHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResourceHandler.cs index 9ddce4c576..06e2b4f89c 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResourceHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResourceHandler.cs @@ -1,47 +1,48 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Used to authorize if the user has the correct permission access to the content for the +/// specified. +/// +public class MediaPermissionsResourceHandler : MustSatisfyRequirementAuthorizationHandler< + MediaPermissionsResourceRequirement, MediaPermissionsResource> { + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly MediaPermissions _mediaPermissions; + /// - /// Used to authorize if the user has the correct permission access to the content for the specified. + /// Initializes a new instance of the class. /// - public class MediaPermissionsResourceHandler : MustSatisfyRequirementAuthorizationHandler + /// Accessor for back-office security. + /// Helper for media authorization checks. + public MediaPermissionsResourceHandler( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + MediaPermissions mediaPermissions) { - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly MediaPermissions _mediaPermissions; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _mediaPermissions = mediaPermissions; + } - /// - /// Initializes a new instance of the class. - /// - /// Accessor for back-office security. - /// Helper for media authorization checks. - public MediaPermissionsResourceHandler( - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - MediaPermissions mediaPermissions) - { - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _mediaPermissions = mediaPermissions; - } + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, + MediaPermissionsResourceRequirement requirement, MediaPermissionsResource resource) + { + MediaPermissions.MediaAccess permissionResult = resource.NodeId.HasValue + ? _mediaPermissions.CheckPermissions( + _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, + resource.NodeId.Value, + out _) + : _mediaPermissions.CheckPermissions( + resource.Media, + _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser); - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, MediaPermissionsResourceRequirement requirement, MediaPermissionsResource resource) - { - MediaPermissions.MediaAccess permissionResult = resource.NodeId.HasValue - ? _mediaPermissions.CheckPermissions( - _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, - resource.NodeId.Value, - out _) - : _mediaPermissions.CheckPermissions( - resource.Media, - _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser); - - return Task.FromResult(permissionResult != MediaPermissions.MediaAccess.Denied); - } + return Task.FromResult(permissionResult != MediaPermissions.MediaAccess.Denied); } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResourceRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResourceRequirement.cs index 637636ede6..5251174761 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResourceRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/MediaPermissionsResourceRequirement.cs @@ -3,12 +3,11 @@ using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// An authorization requirement for +/// +public class MediaPermissionsResourceRequirement : IAuthorizationRequirement { - /// - /// An authorization requirement for - /// - public class MediaPermissionsResourceRequirement : IAuthorizationRequirement - { - } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/MustSatisfyRequirementAuthorizationHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/MustSatisfyRequirementAuthorizationHandler.cs index d64459c94f..6d3f6f3187 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/MustSatisfyRequirementAuthorizationHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/MustSatisfyRequirementAuthorizationHandler.cs @@ -1,78 +1,81 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Abstract handler that must satisfy the requirement so Succeed or Fail will be called no matter what. +/// +/// Authorization requirement. +/// +/// aspnetcore Authz handlers are not required to satisfy the requirement and generally don't explicitly call Fail when +/// the requirement +/// isn't satisfied, however in many simple cases explicitly calling Succeed or Fail is what we want which is what this +/// class is used for. +/// +public abstract class MustSatisfyRequirementAuthorizationHandler : AuthorizationHandler + where T : IAuthorizationRequirement { - /// - /// Abstract handler that must satisfy the requirement so Succeed or Fail will be called no matter what. - /// - /// Authorization requirement. - /// - /// aspnetcore Authz handlers are not required to satisfy the requirement and generally don't explicitly call Fail when the requirement - /// isn't satisfied, however in many simple cases explicitly calling Succeed or Fail is what we want which is what this class is used for. - /// - public abstract class MustSatisfyRequirementAuthorizationHandler : AuthorizationHandler - where T : IAuthorizationRequirement + /// + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, T requirement) { - /// - protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, T requirement) + var isAuth = await IsAuthorized(context, requirement); + if (isAuth) { - var isAuth = await IsAuthorized(context, requirement); - if (isAuth) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } + context.Succeed(requirement); + } + else + { + context.Fail(); } - - /// - /// Return true if the requirement is succeeded or ignored, return false if the requirement is explicitly not met - /// - /// The authorization context. - /// The authorization requirement. - /// True if request is authorized, false if not. - protected abstract Task IsAuthorized(AuthorizationHandlerContext context, T requirement); } /// - /// Abstract handler that must satisfy the requirement so Succeed or Fail will be called no matter what. + /// Return true if the requirement is succeeded or ignored, return false if the requirement is explicitly not met /// - /// Authorization requirement. - /// Resource to authorize access to. - /// - /// aspnetcore Authz handlers are not required to satisfy the requirement and generally don't explicitly call Fail when the requirement - /// isn't satisfied, however in many simple cases explicitly calling Succeed or Fail is what we want which is what this class is used for. - /// - public abstract class MustSatisfyRequirementAuthorizationHandler : AuthorizationHandler - where T : IAuthorizationRequirement - { - /// - protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, T requirement, TResource resource) - { - var isAuth = await IsAuthorized(context, requirement, resource); - if (isAuth) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - } - - /// - /// Return true if the requirement is succeeded or ignored, return false if the requirement is explicitly not met - /// - /// The authorization context. - /// The authorization requirement. - /// The resource to authorize access to. - /// True if request is authorized, false if not. - protected abstract Task IsAuthorized(AuthorizationHandlerContext context, T requirement, TResource resource); - } + /// The authorization context. + /// The authorization requirement. + /// True if request is authorized, false if not. + protected abstract Task IsAuthorized(AuthorizationHandlerContext context, T requirement); +} + +/// +/// Abstract handler that must satisfy the requirement so Succeed or Fail will be called no matter what. +/// +/// Authorization requirement. +/// Resource to authorize access to. +/// +/// aspnetcore Authz handlers are not required to satisfy the requirement and generally don't explicitly call Fail when +/// the requirement +/// isn't satisfied, however in many simple cases explicitly calling Succeed or Fail is what we want which is what this +/// class is used for. +/// +public abstract class MustSatisfyRequirementAuthorizationHandler : AuthorizationHandler + where T : IAuthorizationRequirement +{ + /// + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, T requirement, + TResource resource) + { + var isAuth = await IsAuthorized(context, requirement, resource); + if (isAuth) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + + /// + /// Return true if the requirement is succeeded or ignored, return false if the requirement is explicitly not met + /// + /// The authorization context. + /// The authorization requirement. + /// The resource to authorize access to. + /// True if request is authorized, false if not. + protected abstract Task IsAuthorized(AuthorizationHandlerContext context, T requirement, TResource resource); } diff --git a/src/Umbraco.Web.BackOffice/Authorization/PermissionsQueryStringHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/PermissionsQueryStringHandler.cs index cdb9ba63d9..5367208c79 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/PermissionsQueryStringHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/PermissionsQueryStringHandler.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; using System.Globalization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -10,78 +9,77 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Abstract base class providing common functionality for authorization checks based on querystrings. +/// +/// Authorization requirement +public abstract class PermissionsQueryStringHandler : MustSatisfyRequirementAuthorizationHandler + where T : IAuthorizationRequirement { /// - /// Abstract base class providing common functionality for authorization checks based on querystrings. + /// Initializes a new instance of the class. /// - /// Authorization requirement - public abstract class PermissionsQueryStringHandler : MustSatisfyRequirementAuthorizationHandler - where T : IAuthorizationRequirement + /// Accessor for back-office security. + /// Accessor for the HTTP context of the current request. + /// Service for entity operations. + public PermissionsQueryStringHandler( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IHttpContextAccessor httpContextAccessor, + IEntityService entityService) { - /// - /// Initializes a new instance of the class. - /// - /// Accessor for back-office security. - /// Accessor for the HTTP context of the current request. - /// Service for entity operations. - public PermissionsQueryStringHandler( - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IHttpContextAccessor httpContextAccessor, - IEntityService entityService) + BackOfficeSecurityAccessor = backOfficeSecurityAccessor; + HttpContextAccessor = httpContextAccessor; + EntityService = entityService; + } + + /// + /// Gets or sets the instance. + /// + protected IBackOfficeSecurityAccessor BackOfficeSecurityAccessor { get; set; } + + /// + /// Gets or sets the instance. + /// + protected IHttpContextAccessor HttpContextAccessor { get; set; } + + /// + /// Gets or sets the instance. + /// + protected IEntityService EntityService { get; set; } + + /// + /// Attempts to parse a node ID from a string representation found in a querystring value. + /// + /// Querystring value. + /// Output parsed Id. + /// True of node ID could be parased, false it not. + protected bool TryParseNodeId(string argument, out int nodeId) + { + // If the argument is an int, it will parse and can be assigned to nodeId. + // It might be a udi, so check that next. + // Otherwise treat it as a guid - unlikely we ever get here. + // Failing that, we can't parse it. + if (int.TryParse(argument, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedId)) { - BackOfficeSecurityAccessor = backOfficeSecurityAccessor; - HttpContextAccessor = httpContextAccessor; - EntityService = entityService; + nodeId = parsedId; + return true; } - /// - /// Gets or sets the instance. - /// - protected IBackOfficeSecurityAccessor BackOfficeSecurityAccessor { get; set; } - - /// - /// Gets or sets the instance. - /// - protected IHttpContextAccessor HttpContextAccessor { get; set; } - - /// - /// Gets or sets the instance. - /// - protected IEntityService EntityService { get; set; } - - /// - /// Attempts to parse a node ID from a string representation found in a querystring value. - /// - /// Querystring value. - /// Output parsed Id. - /// True of node ID could be parased, false it not. - protected bool TryParseNodeId(string argument, out int nodeId) + if (UdiParser.TryParse(argument, true, out Udi? udi)) { - // If the argument is an int, it will parse and can be assigned to nodeId. - // It might be a udi, so check that next. - // Otherwise treat it as a guid - unlikely we ever get here. - // Failing that, we can't parse it. - if (int.TryParse(argument, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedId)) - { - nodeId = parsedId; - return true; - } - else if (UdiParser.TryParse(argument, true, out Udi? udi)) - { - nodeId = EntityService.GetId(udi).Result; - return true; - } - else if (Guid.TryParse(argument, out Guid key)) - { - nodeId = EntityService.GetId(key, UmbracoObjectTypes.Document).Result; - return true; - } - else - { - nodeId = 0; - return false; - } + nodeId = EntityService.GetId(udi).Result; + return true; } + + if (Guid.TryParse(argument, out Guid key)) + { + nodeId = EntityService.GetId(key, UmbracoObjectTypes.Document).Result; + return true; + } + + nodeId = 0; + return false; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/SectionHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/SectionHandler.cs index f081c65817..2bcaa9a89d 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/SectionHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/SectionHandler.cs @@ -1,38 +1,36 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Umbraco.Cms.Core.Security; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Ensures that the current user has access to the section +/// +/// +/// The user only needs access to one of the sections specified, not all of the sections. +/// +public class SectionHandler : MustSatisfyRequirementAuthorizationHandler { + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + /// - /// Ensures that the current user has access to the section + /// Initializes a new instance of the class. /// - /// - /// The user only needs access to one of the sections specified, not all of the sections. - /// - public class SectionHandler : MustSatisfyRequirementAuthorizationHandler + /// Accessor for back-office security. + public SectionHandler(IBackOfficeSecurityAccessor backOfficeSecurityAccessor) => + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, SectionRequirement requirement) { - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + var authorized = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser != null && + requirement.SectionAliases + .Any(app => _backOfficeSecurityAccessor.BackOfficeSecurity.UserHasSectionAccess( + app, _backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser)); - /// - /// Initializes a new instance of the class. - /// - /// Accessor for back-office security. - public SectionHandler(IBackOfficeSecurityAccessor backOfficeSecurityAccessor) => _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, SectionRequirement requirement) - { - var authorized = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser != null && - requirement.SectionAliases - .Any(app => _backOfficeSecurityAccessor.BackOfficeSecurity.UserHasSectionAccess( - app, _backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser)); - - return Task.FromResult(authorized); - } + return Task.FromResult(authorized); } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/SectionRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/SectionRequirement.cs index efd4a3b3bb..ab0d3c47f9 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/SectionRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/SectionRequirement.cs @@ -1,25 +1,23 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Authorization requirements for +/// +public class SectionRequirement : IAuthorizationRequirement { /// - /// Authorization requirements for + /// Initializes a new instance of the class. /// - public class SectionRequirement : IAuthorizationRequirement - { - /// - /// Initializes a new instance of the class. - /// - /// Aliases for sections that the user will need access to. - public SectionRequirement(params string[] aliases) => SectionAliases = aliases; + /// Aliases for sections that the user will need access to. + public SectionRequirement(params string[] aliases) => SectionAliases = aliases; - /// - /// Gets the aliases for sections that the user will need access to. - /// - public IReadOnlyCollection SectionAliases { get; } - } + /// + /// Gets the aliases for sections that the user will need access to. + /// + public IReadOnlyCollection SectionAliases { get; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/TreeHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/TreeHandler.cs index e580495907..07f2b96eed 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/TreeHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/TreeHandler.cs @@ -1,54 +1,51 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Ensures that the current user has access to the section for which the specified tree(s) belongs +/// +/// +/// This would allow a tree to be moved between sections. +/// The user only needs access to one of the trees specified, not all of the trees. +/// +public class TreeHandler : MustSatisfyRequirementAuthorizationHandler { + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly ITreeService _treeService; + /// - /// Ensures that the current user has access to the section for which the specified tree(s) belongs + /// Initializes a new instance of the class. /// - /// - /// This would allow a tree to be moved between sections. - /// The user only needs access to one of the trees specified, not all of the trees. - /// - public class TreeHandler : MustSatisfyRequirementAuthorizationHandler + /// Service for section tree operations. + /// Accessor for back-office security. + public TreeHandler(ITreeService treeService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { - private readonly ITreeService _treeService; - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + _treeService = treeService ?? throw new ArgumentNullException(nameof(treeService)); + _backOfficeSecurityAccessor = backOfficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backOfficeSecurityAccessor)); + } - /// - /// Initializes a new instance of the class. - /// - /// Service for section tree operations. - /// Accessor for back-office security. - public TreeHandler(ITreeService treeService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) - { - _treeService = treeService ?? throw new ArgumentNullException(nameof(treeService)); - _backOfficeSecurityAccessor = backOfficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backOfficeSecurityAccessor)); - } + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, TreeRequirement requirement) + { + var apps = requirement.TreeAliases + .Select(x => _treeService.GetByAlias(x)) + .WhereNotNull() + .Select(x => x.SectionAlias) + .Distinct() + .ToArray(); - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, TreeRequirement requirement) - { - var apps = requirement.TreeAliases - .Select(x => _treeService.GetByAlias(x)) - .WhereNotNull() - .Select(x => x.SectionAlias) - .Distinct() - .ToArray(); + var isAuth = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser != null && + apps.Any(app => _backOfficeSecurityAccessor.BackOfficeSecurity.UserHasSectionAccess( + app, _backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser)); - var isAuth = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser != null && - apps.Any(app => _backOfficeSecurityAccessor.BackOfficeSecurity.UserHasSectionAccess( - app, _backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser)); - - return Task.FromResult(isAuth); - } + return Task.FromResult(isAuth); } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/TreeRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/TreeRequirement.cs index c8c7d2853a..b5bf5bf815 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/TreeRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/TreeRequirement.cs @@ -1,25 +1,23 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Authorization requirements for +/// +public class TreeRequirement : IAuthorizationRequirement { /// - /// Authorization requirements for + /// Initializes a new instance of the class. /// - public class TreeRequirement : IAuthorizationRequirement - { - /// - /// Initializes a new instance of the class. - /// - /// The aliases for trees that the user will need access to. - public TreeRequirement(params string[] aliases) => TreeAliases = aliases; + /// The aliases for trees that the user will need access to. + public TreeRequirement(params string[] aliases) => TreeAliases = aliases; - /// - /// Gets the aliases for trees that the user will need access to. - /// - public IReadOnlyCollection TreeAliases { get; } - } + /// + /// Gets the aliases for trees that the user will need access to. + /// + public IReadOnlyCollection TreeAliases { get; } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/UserGroupHandler.cs b/src/Umbraco.Web.BackOffice/Authorization/UserGroupHandler.cs index 037d59c89e..595dcf8663 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/UserGroupHandler.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/UserGroupHandler.cs @@ -1,10 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -14,82 +11,83 @@ using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Controllers; -using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Authorizes that the current user has access to the user group Id in the request +/// +public class UserGroupHandler : MustSatisfyRequirementAuthorizationHandler { + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IContentService _contentService; + private readonly IEntityService _entityService; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IMediaService _mediaService; + private readonly IUserService _userService; + /// - /// Authorizes that the current user has access to the user group Id in the request + /// Initializes a new instance of the class. /// - public class UserGroupHandler : MustSatisfyRequirementAuthorizationHandler + /// Accessor for the HTTP context of the current request. + /// Service for user related operations. + /// Service for content related operations. + /// Service for media related operations. + /// Service for entity related operations. + /// Accessor for back-office security. + public UserGroupHandler( + IHttpContextAccessor httpContextAccessor, + IUserService userService, + IContentService contentService, + IMediaService mediaService, + IEntityService entityService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + AppCaches appCaches) { - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IUserService _userService; - private readonly IContentService _contentService; - private readonly IMediaService _mediaService; - private readonly IEntityService _entityService; - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly AppCaches _appCaches; + _httpContextAccessor = httpContextAccessor; + _userService = userService; + _contentService = contentService; + _mediaService = mediaService; + _entityService = entityService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _appCaches = appCaches; + } - /// - /// Initializes a new instance of the class. - /// - /// Accessor for the HTTP context of the current request. - /// Service for user related operations. - /// Service for content related operations. - /// Service for media related operations. - /// Service for entity related operations. - /// Accessor for back-office security. - public UserGroupHandler( - IHttpContextAccessor httpContextAccessor, - IUserService userService, - IContentService contentService, - IMediaService mediaService, - IEntityService entityService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - AppCaches appCaches) + /// + protected override Task IsAuthorized(AuthorizationHandlerContext context, UserGroupRequirement requirement) + { + IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + + StringValues? querystring = _httpContextAccessor.HttpContext?.Request.Query[requirement.QueryStringName]; + if (querystring is null) { - _httpContextAccessor = httpContextAccessor; - _userService = userService; - _contentService = contentService; - _mediaService = mediaService; - _entityService = entityService; - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _appCaches = appCaches; + // Must succeed this requirement since we cannot process it. + return Task.FromResult(true); } - /// - protected override Task IsAuthorized(AuthorizationHandlerContext context, UserGroupRequirement requirement) + if (querystring.Value.Count == 0) { - IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - - var querystring = _httpContextAccessor.HttpContext?.Request.Query[requirement.QueryStringName]; - if (querystring is null) - { - // Must succeed this requirement since we cannot process it. - return Task.FromResult(true); - } - - if (querystring.Value.Count == 0) - { - // Must succeed this requirement since we cannot process it. - return Task.FromResult(true); - } - - var intIds = querystring.Value.ToString().Split(Constants.CharArrays.Comma) - .Select(x => int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var output) ? Attempt.Succeed(output) : Attempt.Fail()) - .Where(x => x.Success).Select(x => x.Result).ToArray(); - - var authHelper = new UserGroupEditorAuthorizationHelper( - _userService, - _contentService, - _mediaService, - _entityService, - _appCaches); - - Attempt isAuth = authHelper.AuthorizeGroupAccess(currentUser, intIds); - - return Task.FromResult(isAuth.Success); + // Must succeed this requirement since we cannot process it. + return Task.FromResult(true); } + + var intIds = querystring.Value.ToString().Split(Constants.CharArrays.Comma) + .Select(x => + int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var output) + ? Attempt.Succeed(output) + : Attempt.Fail()) + .Where(x => x.Success).Select(x => x.Result).ToArray(); + + var authHelper = new UserGroupEditorAuthorizationHelper( + _userService, + _contentService, + _mediaService, + _entityService, + _appCaches); + + Attempt isAuth = authHelper.AuthorizeGroupAccess(currentUser, intIds); + + return Task.FromResult(isAuth.Success); } } diff --git a/src/Umbraco.Web.BackOffice/Authorization/UserGroupRequirement.cs b/src/Umbraco.Web.BackOffice/Authorization/UserGroupRequirement.cs index ae82309f8d..c06638f273 100644 --- a/src/Umbraco.Web.BackOffice/Authorization/UserGroupRequirement.cs +++ b/src/Umbraco.Web.BackOffice/Authorization/UserGroupRequirement.cs @@ -3,22 +3,21 @@ using Microsoft.AspNetCore.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Authorization +namespace Umbraco.Cms.Web.BackOffice.Authorization; + +/// +/// Authorization requirement for the +/// +public class UserGroupRequirement : IAuthorizationRequirement { /// - /// Authorization requirement for the + /// Initializes a new instance of the class. /// - public class UserGroupRequirement : IAuthorizationRequirement - { - /// - /// Initializes a new instance of the class. - /// - /// Query string name from which to authorize values. - public UserGroupRequirement(string queryStringName = "id") => QueryStringName = queryStringName; + /// Query string name from which to authorize values. + public UserGroupRequirement(string queryStringName = "id") => QueryStringName = queryStringName; - /// - /// Gets the query string name from which to authorize values. - /// - public string QueryStringName { get; } - } + /// + /// Gets the query string name from which to authorize values. + /// + public string QueryStringName { get; } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/AnalyticsController.cs b/src/Umbraco.Web.BackOffice/Controllers/AnalyticsController.cs index e1aac7319b..1820f2b5e0 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/AnalyticsController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/AnalyticsController.cs @@ -1,36 +1,30 @@ -using System; -using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +public class AnalyticsController : UmbracoAuthorizedJsonController { - public class AnalyticsController : UmbracoAuthorizedJsonController + private readonly IMetricsConsentService _metricsConsentService; + + public AnalyticsController(IMetricsConsentService metricsConsentService) => + _metricsConsentService = metricsConsentService; + + public TelemetryLevel GetConsentLevel() => _metricsConsentService.GetConsentLevel(); + + [HttpPost] + public IActionResult SetConsentLevel([FromBody] TelemetryResource telemetryResource) { - private readonly IMetricsConsentService _metricsConsentService; - public AnalyticsController(IMetricsConsentService metricsConsentService) + if (!ModelState.IsValid) { - _metricsConsentService = metricsConsentService; + return BadRequest(); } - public TelemetryLevel GetConsentLevel() - { - return _metricsConsentService.GetConsentLevel(); - } - - [HttpPost] - public IActionResult SetConsentLevel([FromBody]TelemetryResource telemetryResource) - { - if (!ModelState.IsValid) - { - return BadRequest(); - } - - _metricsConsentService.SetConsentLevel(telemetryResource.TelemetryLevel); - return Ok(); - } - - public IEnumerable GetAllLevels() => new[] { TelemetryLevel.Minimal, TelemetryLevel.Basic, TelemetryLevel.Detailed }; + _metricsConsentService.SetConsentLevel(telemetryResource.TelemetryLevel); + return Ok(); } + + public IEnumerable GetAllLevels() => + new[] { TelemetryLevel.Minimal, TelemetryLevel.Basic, TelemetryLevel.Detailed }; } diff --git a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs index f15adfd28b..390482276e 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/AuthenticationController.cs @@ -1,9 +1,6 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Security.Claims; -using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -24,644 +21,666 @@ using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Net; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Security; using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Controllers; -using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.Models; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; +// See +// for a bigger example of this type of controller implementation in netcore: +// https://github.com/dotnet/AspNetCore.Docs/blob/2efb4554f8f659be97ee7cd5dd6143b871b330a5/aspnetcore/migration/1x-to-2x/samples/AspNetCoreDotNetCore2App/AspNetCoreDotNetCore2App/Controllers/AccountController.cs +// https://github.com/dotnet/AspNetCore.Docs/blob/ad16f5e1da6c04fa4996ee67b513f2a90fa0d712/aspnetcore/common/samples/WebApplication1/Controllers/AccountController.cs +// with authenticator app +// https://github.com/dotnet/AspNetCore.Docs/blob/master/aspnetcore/security/authentication/identity/sample/src/ASPNETCore-IdentityDemoComplete/IdentityDemo/Controllers/AccountController.cs + +[PluginController(Constants.Web.Mvc + .BackOfficeApiArea)] // TODO: Maybe this could be applied with our Application Model conventions +//[ValidationFilter] // TODO: I don't actually think this is required with our custom Application Model conventions applied +[AngularJsonOnlyConfiguration] // TODO: This could be applied with our Application Model conventions +[IsBackOffice] +[DisableBrowserCache] +public class AuthenticationController : UmbracoApiControllerBase { - // See - // for a bigger example of this type of controller implementation in netcore: - // https://github.com/dotnet/AspNetCore.Docs/blob/2efb4554f8f659be97ee7cd5dd6143b871b330a5/aspnetcore/migration/1x-to-2x/samples/AspNetCoreDotNetCore2App/AspNetCoreDotNetCore2App/Controllers/AccountController.cs - // https://github.com/dotnet/AspNetCore.Docs/blob/ad16f5e1da6c04fa4996ee67b513f2a90fa0d712/aspnetcore/common/samples/WebApplication1/Controllers/AccountController.cs - // with authenticator app - // https://github.com/dotnet/AspNetCore.Docs/blob/master/aspnetcore/security/authentication/identity/sample/src/ASPNETCore-IdentityDemoComplete/IdentityDemo/Controllers/AccountController.cs + // NOTE: Each action must either be explicitly authorized or explicitly [AllowAnonymous], the latter is optional because + // this controller itself doesn't require authz but it's more clear what the intention is. - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] // TODO: Maybe this could be applied with our Application Model conventions - //[ValidationFilter] // TODO: I don't actually think this is required with our custom Application Model conventions applied - [AngularJsonOnlyConfiguration] // TODO: This could be applied with our Application Model conventions - [IsBackOffice] - [DisableBrowserCache] - public class AuthenticationController : UmbracoApiControllerBase + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions; + private readonly IEmailSender _emailSender; + private readonly IBackOfficeExternalLoginProviders _externalAuthenticationOptions; + private readonly GlobalSettings _globalSettings; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IIpResolver _ipResolver; + private readonly LinkGenerator _linkGenerator; + private readonly ILogger _logger; + private readonly UserPasswordConfigurationSettings _passwordConfiguration; + private readonly SecuritySettings _securitySettings; + private readonly IBackOfficeSignInManager _signInManager; + private readonly ISmsSender _smsSender; + private readonly ILocalizedTextService _textService; + private readonly ITwoFactorLoginService _twoFactorLoginService; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IBackOfficeUserManager _userManager; + private readonly IUserService _userService; + private readonly WebRoutingSettings _webRoutingSettings; + + // TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here + [ActivatorUtilitiesConstructor] + public AuthenticationController( + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IBackOfficeUserManager backOfficeUserManager, + IBackOfficeSignInManager signInManager, + IUserService userService, + ILocalizedTextService textService, + IUmbracoMapper umbracoMapper, + IOptionsSnapshot globalSettings, + IOptionsSnapshot securitySettings, + ILogger logger, + IIpResolver ipResolver, + IOptionsSnapshot passwordConfiguration, + IEmailSender emailSender, + ISmsSender smsSender, + IHostingEnvironment hostingEnvironment, + LinkGenerator linkGenerator, + IBackOfficeExternalLoginProviders externalAuthenticationOptions, + IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions, + IHttpContextAccessor httpContextAccessor, + IOptions webRoutingSettings, + ITwoFactorLoginService twoFactorLoginService) { - // NOTE: Each action must either be explicitly authorized or explicitly [AllowAnonymous], the latter is optional because - // this controller itself doesn't require authz but it's more clear what the intention is. + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _userManager = backOfficeUserManager; + _signInManager = signInManager; + _userService = userService; + _textService = textService; + _umbracoMapper = umbracoMapper; + _globalSettings = globalSettings.Value; + _securitySettings = securitySettings.Value; + _logger = logger; + _ipResolver = ipResolver; + _passwordConfiguration = passwordConfiguration.Value; + _emailSender = emailSender; + _smsSender = smsSender; + _hostingEnvironment = hostingEnvironment; + _linkGenerator = linkGenerator; + _externalAuthenticationOptions = externalAuthenticationOptions; + _backOfficeTwoFactorOptions = backOfficeTwoFactorOptions; + _httpContextAccessor = httpContextAccessor; + _webRoutingSettings = webRoutingSettings.Value; + _twoFactorLoginService = twoFactorLoginService; + } - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IBackOfficeUserManager _userManager; - private readonly IBackOfficeSignInManager _signInManager; - private readonly IUserService _userService; - private readonly ILocalizedTextService _textService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly GlobalSettings _globalSettings; - private readonly SecuritySettings _securitySettings; - private readonly ILogger _logger; - private readonly IIpResolver _ipResolver; - private readonly UserPasswordConfigurationSettings _passwordConfiguration; - private readonly IEmailSender _emailSender; - private readonly ISmsSender _smsSender; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly LinkGenerator _linkGenerator; - private readonly IBackOfficeExternalLoginProviders _externalAuthenticationOptions; - private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly ITwoFactorLoginService _twoFactorLoginService; - private readonly WebRoutingSettings _webRoutingSettings; + /// + /// Returns the configuration for the backoffice user membership provider - used to configure the change password + /// dialog + /// + [AllowAnonymous] // Needed for users that are invited when they use the link from the mail they are not authorized + [Authorize(Policy = + AuthorizationPolicies.BackOfficeAccess)] // Needed to enforce the principle set on the request, if one exists. + public IDictionary GetPasswordConfig(int userId) + { + Attempt currentUserId = + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId() ?? Attempt.Fail(); + return _passwordConfiguration.GetConfiguration( + currentUserId.Success + ? currentUserId.Result != userId + : true); + } - // TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here - [ActivatorUtilitiesConstructor] - public AuthenticationController( - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IBackOfficeUserManager backOfficeUserManager, - IBackOfficeSignInManager signInManager, - IUserService userService, - ILocalizedTextService textService, - IUmbracoMapper umbracoMapper, - IOptionsSnapshot globalSettings, - IOptionsSnapshot securitySettings, - ILogger logger, - IIpResolver ipResolver, - IOptionsSnapshot passwordConfiguration, - IEmailSender emailSender, - ISmsSender smsSender, - IHostingEnvironment hostingEnvironment, - LinkGenerator linkGenerator, - IBackOfficeExternalLoginProviders externalAuthenticationOptions, - IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions, - IHttpContextAccessor httpContextAccessor, - IOptions webRoutingSettings, - ITwoFactorLoginService twoFactorLoginService) + /// + /// Checks if a valid token is specified for an invited user and if so logs the user in and returns the user object + /// + /// + /// + /// + /// + /// This will also update the security stamp for the user so it can only be used once + /// + [ValidateAngularAntiForgeryToken] + [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] + public async Task> PostVerifyInvite([FromQuery] int id, [FromQuery] string token) + { + if (string.IsNullOrWhiteSpace(token)) { - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _userManager = backOfficeUserManager; - _signInManager = signInManager; - _userService = userService; - _textService = textService; - _umbracoMapper = umbracoMapper; - _globalSettings = globalSettings.Value; - _securitySettings = securitySettings.Value; - _logger = logger; - _ipResolver = ipResolver; - _passwordConfiguration = passwordConfiguration.Value; - _emailSender = emailSender; - _smsSender = smsSender; - _hostingEnvironment = hostingEnvironment; - _linkGenerator = linkGenerator; - _externalAuthenticationOptions = externalAuthenticationOptions; - _backOfficeTwoFactorOptions = backOfficeTwoFactorOptions; - _httpContextAccessor = httpContextAccessor; - _webRoutingSettings = webRoutingSettings.Value; - _twoFactorLoginService = twoFactorLoginService; + return NotFound(); } - /// - /// Returns the configuration for the backoffice user membership provider - used to configure the change password dialog - /// - [AllowAnonymous] // Needed for users that are invited when they use the link from the mail they are not authorized - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] // Needed to enforce the principle set on the request, if one exists. - public IDictionary GetPasswordConfig(int userId) + var decoded = token.FromUrlBase64(); + if (decoded.IsNullOrWhiteSpace()) { - Attempt currentUserId = _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId() ?? Attempt.Fail(); - return _passwordConfiguration.GetConfiguration( - currentUserId.Success - ? currentUserId.Result != userId - : true); + return NotFound(); } - /// - /// Checks if a valid token is specified for an invited user and if so logs the user in and returns the user object - /// - /// - /// - /// - /// - /// This will also update the security stamp for the user so it can only be used once - /// - [ValidateAngularAntiForgeryToken] - [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] - public async Task> PostVerifyInvite([FromQuery] int id, [FromQuery] string token) + BackOfficeIdentityUser? identityUser = await _userManager.FindByIdAsync(id.ToString()); + if (identityUser == null) { - if (string.IsNullOrWhiteSpace(token)) - return NotFound(); - - var decoded = token.FromUrlBase64(); - if (decoded.IsNullOrWhiteSpace()) - return NotFound(); - - var identityUser = await _userManager.FindByIdAsync(id.ToString()); - if (identityUser == null) - return NotFound(); - - var result = await _userManager.ConfirmEmailAsync(identityUser, decoded!); - - if (result.Succeeded == false) - { - return ValidationErrorResult.CreateNotificationValidationErrorResult(result.Errors.ToErrorMessage()); - } - - await _signInManager.SignOutAsync(); - - await _signInManager.SignInAsync(identityUser, false); - - var user = _userService.GetUserById(id); - - return _umbracoMapper.Map(user); + return NotFound(); } - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - [ValidateAngularAntiForgeryToken] - public async Task PostUnLinkLogin(UnLinkLoginModel unlinkLoginModel) + IdentityResult result = await _userManager.ConfirmEmailAsync(identityUser, decoded!); + + if (result.Succeeded == false) { - var user = await _userManager.FindByIdAsync(User.Identity?.GetUserId()); - if (user == null) throw new InvalidOperationException("Could not find user"); + return ValidationErrorResult.CreateNotificationValidationErrorResult(result.Errors.ToErrorMessage()); + } - var authType = (await _signInManager.GetExternalAuthenticationSchemesAsync()) - .FirstOrDefault(x => x.Name == unlinkLoginModel.LoginProvider); + await _signInManager.SignOutAsync(); - if (authType == null) + await _signInManager.SignInAsync(identityUser, false); + + IUser? user = _userService.GetUserById(id); + + return _umbracoMapper.Map(user); + } + + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] + [ValidateAngularAntiForgeryToken] + public async Task PostUnLinkLogin(UnLinkLoginModel unlinkLoginModel) + { + BackOfficeIdentityUser? user = await _userManager.FindByIdAsync(User.Identity?.GetUserId()); + if (user == null) + { + throw new InvalidOperationException("Could not find user"); + } + + AuthenticationScheme? authType = (await _signInManager.GetExternalAuthenticationSchemesAsync()) + .FirstOrDefault(x => x.Name == unlinkLoginModel.LoginProvider); + + if (authType == null) + { + _logger.LogWarning("Could not find external authentication provider registered: {LoginProvider}", unlinkLoginModel.LoginProvider); + } + else + { + BackOfficeExternaLoginProviderScheme? opt = await _externalAuthenticationOptions.GetAsync(authType.Name); + if (opt == null) { - _logger.LogWarning("Could not find external authentication provider registered: {LoginProvider}", unlinkLoginModel.LoginProvider); - } - else - { - BackOfficeExternaLoginProviderScheme? opt = await _externalAuthenticationOptions.GetAsync(authType.Name); - if (opt == null) - { - return BadRequest($"Could not find external authentication options registered for provider {unlinkLoginModel.LoginProvider}"); - } - else - { - if (!opt.ExternalLoginProvider.Options.AutoLinkOptions.AllowManualLinking) - { - // If AllowManualLinking is disabled for this provider we cannot unlink - return BadRequest(); - } - } + return BadRequest( + $"Could not find external authentication options registered for provider {unlinkLoginModel.LoginProvider}"); } - var result = await _userManager.RemoveLoginAsync( - user, - unlinkLoginModel.LoginProvider, - unlinkLoginModel.ProviderKey); - - if (result.Succeeded) + if (!opt.ExternalLoginProvider.Options.AutoLinkOptions.AllowManualLinking) { - await _signInManager.SignInAsync(user, true); - return Ok(); - } - else - { - AddModelErrors(result); - return new ValidationErrorResult(ModelState); + // If AllowManualLinking is disabled for this provider we cannot unlink + return BadRequest(); } } - [HttpGet] - [AllowAnonymous] - public async Task GetRemainingTimeoutSeconds() + IdentityResult result = await _userManager.RemoveLoginAsync( + user, + unlinkLoginModel.LoginProvider, + unlinkLoginModel.ProviderKey); + + if (result.Succeeded) { - // force authentication to occur since this is not an authorized endpoint - var result = await this.AuthenticateBackOfficeAsync(); - if (!result.Succeeded) - { - return 0; - } - - var remainingSeconds = result.Principal.GetRemainingAuthSeconds(); - if (remainingSeconds <= 30) - { - var username = result.Principal.FindFirst(ClaimTypes.Name)?.Value; - - //NOTE: We are using 30 seconds because that is what is coded into angular to force logout to give some headway in - // the timeout process. - - _logger.LogInformation( - "User logged will be logged out due to timeout: {Username}, IP Address: {IPAddress}", - username ?? "unknown", - _ipResolver.GetCurrentRequestIpAddress()); - } - - return remainingSeconds; + await _signInManager.SignInAsync(user, true); + return Ok(); } - /// - /// Checks if the current user's cookie is valid and if so returns OK or a 400 (BadRequest) - /// - /// - [HttpGet] - [AllowAnonymous] - public async Task IsAuthenticated() + AddModelErrors(result); + return new ValidationErrorResult(ModelState); + } + + [HttpGet] + [AllowAnonymous] + public async Task GetRemainingTimeoutSeconds() + { + // force authentication to occur since this is not an authorized endpoint + AuthenticateResult result = await this.AuthenticateBackOfficeAsync(); + if (!result.Succeeded) { - // force authentication to occur since this is not an authorized endpoint - var result = await this.AuthenticateBackOfficeAsync(); - return result.Succeeded; + return 0; } - /// - /// Returns the currently logged in Umbraco user - /// - /// - /// - /// We have the attribute [SetAngularAntiForgeryTokens] applied because this method is called initially to determine if the user - /// is valid before the login screen is displayed. The Auth cookie can be persisted for up to a day but the csrf cookies are only session - /// cookies which means that the auth cookie could be valid but the csrf cookies are no longer there, in that case we need to re-set the csrf cookies. - /// - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - [SetAngularAntiForgeryTokens] - [CheckIfUserTicketDataIsStale] - public UserDetail? GetCurrentUser() + var remainingSeconds = result.Principal.GetRemainingAuthSeconds(); + if (remainingSeconds <= 30) { - var user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - var result = _umbracoMapper.Map(user); + var username = result.Principal.FindFirst(ClaimTypes.Name)?.Value; - if (result is not null) - { - //set their remaining seconds - result.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds(); - } + //NOTE: We are using 30 seconds because that is what is coded into angular to force logout to give some headway in + // the timeout process. - return result; + _logger.LogInformation( + "User logged will be logged out due to timeout: {Username}, IP Address: {IPAddress}", + username ?? "unknown", + _ipResolver.GetCurrentRequestIpAddress()); } - /// - /// When a user is invited they are not approved but we need to resolve the partially logged on (non approved) - /// user. - /// - /// - /// - /// We cannot user GetCurrentUser since that requires they are approved, this is the same as GetCurrentUser but doesn't require them to be approved - /// - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccessWithoutApproval)] - [SetAngularAntiForgeryTokens] - [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] - public ActionResult GetCurrentInvitedUser() + return remainingSeconds; + } + + /// + /// Checks if the current user's cookie is valid and if so returns OK or a 400 (BadRequest) + /// + /// + [HttpGet] + [AllowAnonymous] + public async Task IsAuthenticated() + { + // force authentication to occur since this is not an authorized endpoint + AuthenticateResult result = await this.AuthenticateBackOfficeAsync(); + return result.Succeeded; + } + + /// + /// Returns the currently logged in Umbraco user + /// + /// + /// + /// We have the attribute [SetAngularAntiForgeryTokens] applied because this method is called initially to determine if + /// the user + /// is valid before the login screen is displayed. The Auth cookie can be persisted for up to a day but the csrf + /// cookies are only session + /// cookies which means that the auth cookie could be valid but the csrf cookies are no longer there, in that case we + /// need to re-set the csrf cookies. + /// + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] + [SetAngularAntiForgeryTokens] + [CheckIfUserTicketDataIsStale] + public UserDetail? GetCurrentUser() + { + IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + UserDetail? result = _umbracoMapper.Map(user); + + if (result is not null) { - var user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - - if (user?.IsApproved ?? false) - { - // if they are approved, than they are no longer invited and we can return an error - return Forbid(); - } - - var result = _umbracoMapper.Map(user); - - if (result is not null) - { - // set their remaining seconds - result.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds(); - } - - return result; + //set their remaining seconds + result.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds(); } - /// - /// Logs a user in - /// - /// - [SetAngularAntiForgeryTokens] - [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] - public async Task> PostLogin(LoginModel loginModel) - { - // Sign the user in with username/password, this also gives a chance for developers to - // custom verify the credentials and auto-link user accounts with a custom IBackOfficePasswordChecker - SignInResult result = await _signInManager.PasswordSignInAsync( - loginModel.Username, loginModel.Password, isPersistent: true, lockoutOnFailure: true); + return result; + } - if (result.Succeeded) + /// + /// When a user is invited they are not approved but we need to resolve the partially logged on (non approved) + /// user. + /// + /// + /// + /// We cannot user GetCurrentUser since that requires they are approved, this is the same as GetCurrentUser but doesn't + /// require them to be approved + /// + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccessWithoutApproval)] + [SetAngularAntiForgeryTokens] + [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] + public ActionResult GetCurrentInvitedUser() + { + IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + + if (user?.IsApproved ?? false) + { + // if they are approved, than they are no longer invited and we can return an error + return Forbid(); + } + + UserDetail? result = _umbracoMapper.Map(user); + + if (result is not null) + { + // set their remaining seconds + result.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds(); + } + + return result; + } + + /// + /// Logs a user in + /// + /// + [SetAngularAntiForgeryTokens] + [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] + public async Task> PostLogin(LoginModel loginModel) + { + // Sign the user in with username/password, this also gives a chance for developers to + // custom verify the credentials and auto-link user accounts with a custom IBackOfficePasswordChecker + SignInResult result = await _signInManager.PasswordSignInAsync( + loginModel.Username, loginModel.Password, true, true); + + if (result.Succeeded) + { + // return the user detail + return GetUserDetail(_userService.GetByUsername(loginModel.Username)); + } + + if (result.RequiresTwoFactor) + { + var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(loginModel.Username); + if (twofactorView.IsNullOrWhiteSpace()) { - // return the user detail - return GetUserDetail(_userService.GetByUsername(loginModel.Username)); + return new ValidationErrorResult( + $"The registered {typeof(IBackOfficeTwoFactorOptions)} of type {_backOfficeTwoFactorOptions.GetType()} did not return a view for two factor auth "); } - if (result.RequiresTwoFactor) - { - var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(loginModel.Username); - if (twofactorView.IsNullOrWhiteSpace()) - { - return new ValidationErrorResult($"The registered {typeof(IBackOfficeTwoFactorOptions)} of type {_backOfficeTwoFactorOptions.GetType()} did not return a view for two factor auth "); - } + IUser? attemptedUser = _userService.GetByUsername(loginModel.Username); - IUser? attemptedUser = _userService.GetByUsername(loginModel.Username); - - // create a with information to display a custom two factor send code view - var verifyResponse = new ObjectResult(new - { - twoFactorView = twofactorView, - userId = attemptedUser?.Id - }) + // create a with information to display a custom two factor send code view + var verifyResponse = + new ObjectResult(new { twoFactorView = twofactorView, userId = attemptedUser?.Id }) { StatusCode = StatusCodes.Status402PaymentRequired }; - return verifyResponse; - } + return verifyResponse; + } - // TODO: We can check for these and respond differently if we think it's important - // result.IsLockedOut - // result.IsNotAllowed + // TODO: We can check for these and respond differently if we think it's important + // result.IsLockedOut + // result.IsNotAllowed - // return BadRequest (400), we don't want to return a 401 because that get's intercepted - // by our angular helper because it thinks that we need to re-perform the request once we are - // authorized and we don't want to return a 403 because angular will show a warning message indicating - // that the user doesn't have access to perform this function, we just want to return a normal invalid message. + // return BadRequest (400), we don't want to return a 401 because that get's intercepted + // by our angular helper because it thinks that we need to re-perform the request once we are + // authorized and we don't want to return a 403 because angular will show a warning message indicating + // that the user doesn't have access to perform this function, we just want to return a normal invalid message. + return BadRequest(); + } + + /// + /// Processes a password reset request. Looks for a match on the provided email address + /// and if found sends an email with a link to reset it + /// + /// + [SetAngularAntiForgeryTokens] + [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] + public async Task PostRequestPasswordReset(RequestPasswordResetModel model) + { + // If this feature is switched off in configuration the UI will be amended to not make the request to reset password available. + // So this is just a server-side secondary check. + if (_securitySettings.AllowPasswordReset == false) + { return BadRequest(); } - /// - /// Processes a password reset request. Looks for a match on the provided email address - /// and if found sends an email with a link to reset it - /// - /// - [SetAngularAntiForgeryTokens] - [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] - public async Task PostRequestPasswordReset(RequestPasswordResetModel model) + BackOfficeIdentityUser? identityUser = await _userManager.FindByEmailAsync(model.Email); + if (identityUser != null) { - // If this feature is switched off in configuration the UI will be amended to not make the request to reset password available. - // So this is just a server-side secondary check. - if (_securitySettings.AllowPasswordReset == false) - return BadRequest(); - - var identityUser = await _userManager.FindByEmailAsync(model.Email); - if (identityUser != null) + IUser? user = _userService.GetByEmail(model.Email); + if (user != null) { - var user = _userService.GetByEmail(model.Email); - if (user != null) - { - var from = _globalSettings.Smtp?.From; - var code = await _userManager.GeneratePasswordResetTokenAsync(identityUser); - var callbackUrl = ConstructCallbackUrl(identityUser.Id, code); + var from = _globalSettings.Smtp?.From; + var code = await _userManager.GeneratePasswordResetTokenAsync(identityUser); + var callbackUrl = ConstructCallbackUrl(identityUser.Id, code); - var message = _textService.Localize("login","resetPasswordEmailCopyFormat", - // Ensure the culture of the found user is used for the email! - UmbracoUserExtensions.GetUserCulture(identityUser.Culture, _textService, _globalSettings), - new[] { identityUser.UserName, callbackUrl }); + var message = _textService.Localize("login", "resetPasswordEmailCopyFormat", + // Ensure the culture of the found user is used for the email! + UmbracoUserExtensions.GetUserCulture(identityUser.Culture, _textService, _globalSettings), + new[] { identityUser.UserName, callbackUrl }); - var subject = _textService.Localize("login","resetPasswordEmailCopySubject", - // Ensure the culture of the found user is used for the email! - UmbracoUserExtensions.GetUserCulture(identityUser.Culture, _textService, _globalSettings)); + var subject = _textService.Localize("login", "resetPasswordEmailCopySubject", + // Ensure the culture of the found user is used for the email! + UmbracoUserExtensions.GetUserCulture(identityUser.Culture, _textService, _globalSettings)); - var mailMessage = new EmailMessage(from, user.Email, subject, message, true); - - await _emailSender.SendAsync(mailMessage, Constants.Web.EmailTypes.PasswordReset); - - _userManager.NotifyForgotPasswordRequested(User, user.Id.ToString()); - } - } - - return Ok(); - } - - /// - /// Used to retrieve the 2FA providers for code submission - /// - /// - [SetAngularAntiForgeryTokens] - [AllowAnonymous] - public async Task>> Get2FAProviders() - { - var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); - if (user == null) - { - _logger.LogWarning("Get2FAProviders :: No verified user found, returning 404"); - return NotFound(); - } - - var userFactors = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(user.Key); - - return new ObjectResult(userFactors); - } - - [SetAngularAntiForgeryTokens] - [AllowAnonymous] - public async Task PostSend2FACode([FromBody] string provider) - { - if (provider.IsNullOrWhiteSpace()) - return NotFound(); - - var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); - if (user == null) - { - _logger.LogWarning("PostSend2FACode :: No verified user found, returning 404"); - return NotFound(); - } - - var from = _globalSettings.Smtp?.From; - // Generate the token and send it - var code = await _userManager.GenerateTwoFactorTokenAsync(user, provider); - if (string.IsNullOrWhiteSpace(code)) - { - _logger.LogWarning("PostSend2FACode :: Could not generate 2FA code"); - return BadRequest("Invalid code"); - } - - var subject = _textService.Localize("login","mfaSecurityCodeSubject", - // Ensure the culture of the found user is used for the email! - UmbracoUserExtensions.GetUserCulture(user.Culture, _textService, _globalSettings)); - - var message = _textService.Localize("login","mfaSecurityCodeMessage", - // Ensure the culture of the found user is used for the email! - UmbracoUserExtensions.GetUserCulture(user.Culture, _textService, _globalSettings), - new[] { code }); - - if (provider == "Email") - { var mailMessage = new EmailMessage(from, user.Email, subject, message, true); - await _emailSender.SendAsync(mailMessage, Constants.Web.EmailTypes.TwoFactorAuth); + await _emailSender.SendAsync(mailMessage, Constants.Web.EmailTypes.PasswordReset); + + _userManager.NotifyForgotPasswordRequested(User, user.Id.ToString()); } - else if (provider == "Phone") + } + + return Ok(); + } + + /// + /// Used to retrieve the 2FA providers for code submission + /// + /// + [SetAngularAntiForgeryTokens] + [AllowAnonymous] + public async Task>> Get2FAProviders() + { + BackOfficeIdentityUser? user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + _logger.LogWarning("Get2FAProviders :: No verified user found, returning 404"); + return NotFound(); + } + + IEnumerable userFactors = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(user.Key); + + return new ObjectResult(userFactors); + } + + [SetAngularAntiForgeryTokens] + [AllowAnonymous] + public async Task PostSend2FACode([FromBody] string provider) + { + if (provider.IsNullOrWhiteSpace()) + { + return NotFound(); + } + + BackOfficeIdentityUser? user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + _logger.LogWarning("PostSend2FACode :: No verified user found, returning 404"); + return NotFound(); + } + + var from = _globalSettings.Smtp?.From; + // Generate the token and send it + var code = await _userManager.GenerateTwoFactorTokenAsync(user, provider); + if (string.IsNullOrWhiteSpace(code)) + { + _logger.LogWarning("PostSend2FACode :: Could not generate 2FA code"); + return BadRequest("Invalid code"); + } + + var subject = _textService.Localize("login", "mfaSecurityCodeSubject", + // Ensure the culture of the found user is used for the email! + UmbracoUserExtensions.GetUserCulture(user.Culture, _textService, _globalSettings)); + + var message = _textService.Localize("login", "mfaSecurityCodeMessage", + // Ensure the culture of the found user is used for the email! + UmbracoUserExtensions.GetUserCulture(user.Culture, _textService, _globalSettings), + new[] { code }); + + if (provider == "Email") + { + var mailMessage = new EmailMessage(from, user.Email, subject, message, true); + + await _emailSender.SendAsync(mailMessage, Constants.Web.EmailTypes.TwoFactorAuth); + } + else if (provider == "Phone") + { + await _smsSender.SendSmsAsync(await _userManager.GetPhoneNumberAsync(user), message); + } + + return Ok(); + } + + [SetAngularAntiForgeryTokens] + [AllowAnonymous] + public async Task> PostVerify2FACode(Verify2FACodeModel model) + { + if (ModelState.IsValid == false) + { + return new ValidationErrorResult(ModelState); + } + + BackOfficeIdentityUser? user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + _logger.LogWarning("PostVerify2FACode :: No verified user found, returning 404"); + return NotFound(); + } + + SignInResult result = + await _signInManager.TwoFactorSignInAsync(model.Provider, model.Code, model.IsPersistent, model.RememberClient); + if (result.Succeeded) + { + return GetUserDetail(_userService.GetByUsername(user.UserName)); + } + + if (result.IsLockedOut) + { + return new ValidationErrorResult("User is locked out"); + } + + if (result.IsNotAllowed) + { + return new ValidationErrorResult("User is not allowed"); + } + + return new ValidationErrorResult("Invalid code"); + } + + /// + /// Processes a set password request. Validates the request and sets a new password. + /// + /// + [SetAngularAntiForgeryTokens] + [AllowAnonymous] + public async Task PostSetPassword(SetPasswordModel model) + { + BackOfficeIdentityUser? identityUser = + await _userManager.FindByIdAsync(model.UserId.ToString(CultureInfo.InvariantCulture)); + + IdentityResult result = await _userManager.ResetPasswordAsync(identityUser, model.ResetCode, model.Password); + if (result.Succeeded) + { + var lockedOut = await _userManager.IsLockedOutAsync(identityUser); + if (lockedOut) { - await _smsSender.SendSmsAsync(await _userManager.GetPhoneNumberAsync(user), message); + _logger.LogInformation( + "User {UserId} is currently locked out, unlocking and resetting AccessFailedCount", model.UserId); + + //// var user = await UserManager.FindByIdAsync(model.UserId); + IdentityResult unlockResult = + await _userManager.SetLockoutEndDateAsync(identityUser, DateTimeOffset.Now); + if (unlockResult.Succeeded == false) + { + _logger.LogWarning("Could not unlock for user {UserId} - error {UnlockError}", model.UserId, + unlockResult.Errors.First().Description); + } + + IdentityResult resetAccessFailedCountResult = + await _userManager.ResetAccessFailedCountAsync(identityUser); + if (resetAccessFailedCountResult.Succeeded == false) + { + _logger.LogWarning("Could not reset access failed count {UserId} - error {UnlockError}", + model.UserId, unlockResult.Errors.First().Description); + } } + // They've successfully set their password, we can now update their user account to be confirmed + // if user was only invited, then they have not been approved + // but a successful forgot password flow (e.g. if their token had expired and they did a forgot password instead of request new invite) + // means we have verified their email + if (!await _userManager.IsEmailConfirmedAsync(identityUser)) + { + await _userManager.ConfirmEmailAsync(identityUser, model.ResetCode); + } + + // invited is not approved, never logged in, invited date present + /* + if (LastLoginDate == default && IsApproved == false && InvitedDate != null) + return UserState.Invited; + */ + if (identityUser != null && !identityUser.IsApproved) + { + IUser? user = _userService.GetByUsername(identityUser.UserName); + // also check InvitedDate and never logged in, otherwise this would allow a disabled user to reactivate their account with a forgot password + if (user?.LastLoginDate == default && user?.InvitedDate != null) + { + user.IsApproved = true; + user.InvitedDate = null; + _userService.Save(user); + } + } + + _userManager.NotifyForgotPasswordChanged(User, model.UserId.ToString(CultureInfo.InvariantCulture)); return Ok(); } - [SetAngularAntiForgeryTokens] - [AllowAnonymous] - public async Task> PostVerify2FACode(Verify2FACodeModel model) + return new ValidationErrorResult( + result.Errors.Any() ? result.Errors.First().Description : "Set password failed"); + } + + /// + /// Logs the current user out + /// + /// + [ValidateAngularAntiForgeryToken] + [AllowAnonymous] + public async Task PostLogout() + { + // force authentication to occur since this is not an authorized endpoint + AuthenticateResult result = await this.AuthenticateBackOfficeAsync(); + if (!result.Succeeded) { - if (ModelState.IsValid == false) - { - return new ValidationErrorResult(ModelState); - } - - var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); - if (user == null) - { - _logger.LogWarning("PostVerify2FACode :: No verified user found, returning 404"); - return NotFound(); - } - - var result = await _signInManager.TwoFactorSignInAsync(model.Provider, model.Code, model.IsPersistent, model.RememberClient); - if (result.Succeeded) - { - return GetUserDetail(_userService.GetByUsername(user.UserName)); - } - - if (result.IsLockedOut) - { - return new ValidationErrorResult("User is locked out"); - } - if (result.IsNotAllowed) - { - return new ValidationErrorResult("User is not allowed"); - } - - return new ValidationErrorResult("Invalid code"); - } - - /// - /// Processes a set password request. Validates the request and sets a new password. - /// - /// - [SetAngularAntiForgeryTokens] - [AllowAnonymous] - public async Task PostSetPassword(SetPasswordModel model) - { - var identityUser = await _userManager.FindByIdAsync(model.UserId.ToString(CultureInfo.InvariantCulture)); - - var result = await _userManager.ResetPasswordAsync(identityUser, model.ResetCode, model.Password); - if (result.Succeeded) - { - var lockedOut = await _userManager.IsLockedOutAsync(identityUser); - if (lockedOut) - { - _logger.LogInformation("User {UserId} is currently locked out, unlocking and resetting AccessFailedCount", model.UserId); - - //// var user = await UserManager.FindByIdAsync(model.UserId); - var unlockResult = await _userManager.SetLockoutEndDateAsync(identityUser, DateTimeOffset.Now); - if (unlockResult.Succeeded == false) - { - _logger.LogWarning("Could not unlock for user {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First().Description); - } - - var resetAccessFailedCountResult = await _userManager.ResetAccessFailedCountAsync(identityUser); - if (resetAccessFailedCountResult.Succeeded == false) - { - _logger.LogWarning("Could not reset access failed count {UserId} - error {UnlockError}", model.UserId, unlockResult.Errors.First().Description); - } - } - - // They've successfully set their password, we can now update their user account to be confirmed - // if user was only invited, then they have not been approved - // but a successful forgot password flow (e.g. if their token had expired and they did a forgot password instead of request new invite) - // means we have verified their email - if (!await _userManager.IsEmailConfirmedAsync(identityUser)) - { - await _userManager.ConfirmEmailAsync(identityUser, model.ResetCode); - } - - // invited is not approved, never logged in, invited date present - /* - if (LastLoginDate == default && IsApproved == false && InvitedDate != null) - return UserState.Invited; - */ - if (identityUser != null && !identityUser.IsApproved) - { - var user = _userService.GetByUsername(identityUser.UserName); - // also check InvitedDate and never logged in, otherwise this would allow a disabled user to reactivate their account with a forgot password - if (user?.LastLoginDate == default && user?.InvitedDate != null) - { - user.IsApproved = true; - user.InvitedDate = null; - _userService.Save(user); - } - } - - _userManager.NotifyForgotPasswordChanged(User, model.UserId.ToString(CultureInfo.InvariantCulture)); - return Ok(); - } - - return new ValidationErrorResult(result.Errors.Any() ? result.Errors.First().Description : "Set password failed"); - } - - /// - /// Logs the current user out - /// - /// - [ValidateAngularAntiForgeryToken] - [AllowAnonymous] - public async Task PostLogout() - { - // force authentication to occur since this is not an authorized endpoint - var result = await this.AuthenticateBackOfficeAsync(); - if (!result.Succeeded) return Ok(); - - await _signInManager.SignOutAsync(); - - _logger.LogInformation("User {UserName} from IP address {RemoteIpAddress} has logged out", User.Identity == null ? "UNKNOWN" : User.Identity.Name, HttpContext.Connection.RemoteIpAddress); - - var userId = result.Principal.Identity?.GetUserId(); - var args = _userManager.NotifyLogoutSuccess(User, userId); - if (!args.SignOutRedirectUrl.IsNullOrWhiteSpace()) - { - return new ObjectResult(new - { - signOutRedirectUrl = args.SignOutRedirectUrl - }); - } - return Ok(); } + await _signInManager.SignOutAsync(); - /// - /// Return the for the given - /// - /// - /// - /// - private UserDetail? GetUserDetail(IUser? user) + _logger.LogInformation("User {UserName} from IP address {RemoteIpAddress} has logged out", + User.Identity == null ? "UNKNOWN" : User.Identity.Name, HttpContext.Connection.RemoteIpAddress); + + var userId = result.Principal.Identity?.GetUserId(); + SignOutSuccessResult args = _userManager.NotifyLogoutSuccess(User, userId); + if (!args.SignOutRedirectUrl.IsNullOrWhiteSpace()) { - if (user == null) throw new ArgumentNullException(nameof(user)); - - var userDetail = _umbracoMapper.Map(user); - - if (userDetail is not null) - { - // update the userDetail and set their remaining seconds - userDetail.SecondsUntilTimeout = _globalSettings.TimeOut.TotalSeconds; - } - - return userDetail; + return new ObjectResult(new { signOutRedirectUrl = args.SignOutRedirectUrl }); } - private string ConstructCallbackUrl(string userId, string code) - { - // Get an mvc helper to get the url - var action = _linkGenerator.GetPathByAction( - nameof(BackOfficeController.ValidatePasswordResetCode), - ControllerExtensions.GetControllerName(), - new - { - area = Constants.Web.Mvc.BackOfficeArea, - u = userId, - r = code - }); + return Ok(); + } - // 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(); + + /// + /// Return the for the given + /// + /// + /// + /// + private UserDetail? GetUserDetail(IUser? user) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); } - private void AddModelErrors(IdentityResult result, string prefix = "") + UserDetail? userDetail = _umbracoMapper.Map(user); + + if (userDetail is not null) { - foreach (var error in result.Errors) - { - ModelState.AddModelError(prefix, error.Description); - } + // update the userDetail and set their remaining seconds + userDetail.SecondsUntilTimeout = _globalSettings.TimeOut.TotalSeconds; + } + + return userDetail; + } + + private string ConstructCallbackUrl(string userId, string code) + { + // Get an mvc helper to get the url + var action = _linkGenerator.GetPathByAction( + nameof(BackOfficeController.ValidatePasswordResetCode), + ControllerExtensions.GetControllerName(), + new { area = Constants.Web.Mvc.BackOfficeArea, u = userId, r = code }); + + // 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(); + } + + private void AddModelErrors(IdentityResult result, string prefix = "") + { + foreach (IdentityError? error in result.Errors) + { + ModelState.AddModelError(prefix, error.Description); } } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeAssetsController.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeAssetsController.cs index bb7f86c1c9..b73788fe30 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeAssetsController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeAssetsController.cs @@ -1,51 +1,47 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using Microsoft.AspNetCore.Mvc; 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.IO; using Umbraco.Cms.Web.Common.Attributes; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class BackOfficeAssetsController : UmbracoAuthorizedJsonController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class BackOfficeAssetsController : UmbracoAuthorizedJsonController + private readonly IFileSystem _jsLibFileSystem; + + public BackOfficeAssetsController(IIOHelper ioHelper, IHostingEnvironment hostingEnvironment, ILoggerFactory loggerFactory, IOptionsSnapshot globalSettings) { - private readonly IFileSystem _jsLibFileSystem; + var path = globalSettings.Value.UmbracoPath + Path.DirectorySeparatorChar + "lib"; + _jsLibFileSystem = new PhysicalFileSystem( + ioHelper, + hostingEnvironment, + loggerFactory.CreateLogger(), + hostingEnvironment.MapPathWebRoot(path), + hostingEnvironment.ToAbsolute(path)); + } - public BackOfficeAssetsController(IIOHelper ioHelper, IHostingEnvironment hostingEnvironment, ILoggerFactory loggerFactory, IOptionsSnapshot globalSettings) + [HttpGet] + public object GetSupportedLocales() + { + const string momentLocaleFolder = "moment"; + const string flatpickrLocaleFolder = "flatpickr/l10n"; + + return new { moment = GetLocales(momentLocaleFolder), flatpickr = GetLocales(flatpickrLocaleFolder) }; + } + + private IEnumerable GetLocales(string path) + { + var cultures = _jsLibFileSystem.GetFiles(path, "*.js").ToList(); + for (var i = 0; i < cultures.Count; i++) { - var path = globalSettings.Value.UmbracoPath + Path.DirectorySeparatorChar + "lib"; - _jsLibFileSystem = new PhysicalFileSystem(ioHelper, hostingEnvironment, loggerFactory.CreateLogger(), hostingEnvironment.MapPathWebRoot(path), hostingEnvironment.ToAbsolute(path)); + cultures[i] = cultures[i].Substring(cultures[i].IndexOf(path, StringComparison.Ordinal) + path.Length + 1); } - [HttpGet] - public object GetSupportedLocales() - { - const string momentLocaleFolder = "moment"; - const string flatpickrLocaleFolder = "flatpickr/l10n"; - - return new - { - moment = GetLocales(momentLocaleFolder), - flatpickr = GetLocales(flatpickrLocaleFolder) - }; - } - - private IEnumerable GetLocales(string path) - { - var cultures = _jsLibFileSystem.GetFiles(path, "*.js").ToList(); - for (var i = 0; i < cultures.Count; i++) - { - cultures[i] = cultures[i] - .Substring(cultures[i].IndexOf(path, StringComparison.Ordinal) + path.Length + 1); - } - return cultures; - } + return cultures; } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs index 26f146cfb0..beee83cbb4 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.IO; -using System.Linq; +using System.Net; using System.Security.Claims; -using System.Threading.Tasks; +using System.Security.Principal; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -32,541 +29,560 @@ 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; using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[DisableBrowserCache] +[UmbracoRequireHttps] +[PluginController(Constants.Web.Mvc.BackOfficeArea)] +[IsBackOffice] +public class BackOfficeController : UmbracoController { - [DisableBrowserCache] - [UmbracoRequireHttps] - [PluginController(Constants.Web.Mvc.BackOfficeArea)] - [IsBackOffice] - public class BackOfficeController : UmbracoController + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly BackOfficeServerVariables _backOfficeServerVariables; + private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions; + private readonly IBackOfficeExternalLoginProviders _externalLogins; + private readonly IGridConfig _gridConfig; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IJsonSerializer _jsonSerializer; + private readonly ILogger _logger; + private readonly IManifestParser _manifestParser; + private readonly IRuntimeMinifier _runtimeMinifier; + private readonly IRuntimeState _runtimeState; + private readonly IOptions _securitySettings; + private readonly ServerVariablesParser _serverVariables; + private readonly IBackOfficeSignInManager _signInManager; + + private readonly ILocalizedTextService _textService; + // See here for examples of what a lot of this is doing: https://github.com/dotnet/aspnetcore/blob/main/src/Identity/samples/IdentitySample.Mvc/Controllers/AccountController.cs + // along with our AuthenticationController + + // NOTE: Each action must either be explicitly authorized or explicitly [AllowAnonymous], the latter is optional because + // this controller itself doesn't require authz but it's more clear what the intention is. + + private readonly IBackOfficeUserManager _userManager; + private readonly GlobalSettings _globalSettings; + + + [ActivatorUtilitiesConstructor] + public BackOfficeController( + IBackOfficeUserManager userManager, + IRuntimeState runtimeState, + IRuntimeMinifier runtimeMinifier, + IOptionsSnapshot 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, + IOptions securitySettings) { - // See here for examples of what a lot of this is doing: https://github.com/dotnet/aspnetcore/blob/main/src/Identity/samples/IdentitySample.Mvc/Controllers/AccountController.cs - // along with our AuthenticationController + _userManager = userManager; + _runtimeState = runtimeState; + _runtimeMinifier = runtimeMinifier; + _globalSettings = globalSettings.Value; + _hostingEnvironment = hostingEnvironment; + _textService = textService; + _gridConfig = gridConfig ?? throw new ArgumentNullException(nameof(gridConfig)); + _backOfficeServerVariables = backOfficeServerVariables; + _appCaches = appCaches; + _signInManager = signInManager; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _logger = logger; + _jsonSerializer = jsonSerializer; + _externalLogins = externalLogins; + _httpContextAccessor = httpContextAccessor; + _backOfficeTwoFactorOptions = backOfficeTwoFactorOptions; + _manifestParser = manifestParser; + _serverVariables = serverVariables; + _securitySettings = securitySettings; + } - // NOTE: Each action must either be explicitly authorized or explicitly [AllowAnonymous], the latter is optional because - // this controller itself doesn't require authz but it's more clear what the intention is. - - private readonly IBackOfficeUserManager _userManager; - private readonly IRuntimeState _runtimeState; - private readonly IRuntimeMinifier _runtimeMinifier; - private GlobalSettings _globalSettings; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly ILocalizedTextService _textService; - private readonly IGridConfig _gridConfig; - private readonly BackOfficeServerVariables _backOfficeServerVariables; - private readonly AppCaches _appCaches; - private readonly IBackOfficeSignInManager _signInManager; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly ILogger _logger; - private readonly IJsonSerializer _jsonSerializer; - private readonly IBackOfficeExternalLoginProviders _externalLogins; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions; - private readonly IManifestParser _manifestParser; - private readonly ServerVariablesParser _serverVariables; - private readonly IOptions _securitySettings; - - - [ActivatorUtilitiesConstructor] - public BackOfficeController( - IBackOfficeUserManager userManager, - IRuntimeState runtimeState, - IRuntimeMinifier runtimeMinifier, - IOptionsSnapshot 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, - IOptions securitySettings) + [HttpGet] + [AllowAnonymous] + public async Task Default() + { + // Check if we not are in an run state, if so we need to redirect + if (_runtimeState.Level != RuntimeLevel.Run) { - _userManager = userManager; - _runtimeState = runtimeState; - _runtimeMinifier = runtimeMinifier; - _globalSettings = globalSettings.Value; - _hostingEnvironment = hostingEnvironment; - _textService = textService; - _gridConfig = gridConfig ?? throw new ArgumentNullException(nameof(gridConfig)); - _backOfficeServerVariables = backOfficeServerVariables; - _appCaches = appCaches; - _signInManager = signInManager; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _logger = logger; - _jsonSerializer = jsonSerializer; - _externalLogins = externalLogins; - _httpContextAccessor = httpContextAccessor; - _backOfficeTwoFactorOptions = backOfficeTwoFactorOptions; - _manifestParser = manifestParser; - _serverVariables = serverVariables; - _securitySettings = securitySettings; + return Redirect("/"); } - [HttpGet] - [AllowAnonymous] - public async Task Default() + // force authentication to occur since this is not an authorized endpoint + AuthenticateResult result = await this.AuthenticateBackOfficeAsync(); + + var viewPath = Path.Combine(Constants.SystemDirectories.Umbraco, Constants.Web.Mvc.BackOfficeArea, nameof(Default) + ".cshtml") + .Replace("\\", "/"); // convert to forward slashes since it's a virtual path + + return await RenderDefaultOrProcessExternalLoginAsync( + result, + () => View(viewPath), + () => View(viewPath)); + } + + [HttpGet] + [AllowAnonymous] + public async Task VerifyInvite(string invite) + { + AuthenticateResult authenticate = await this.AuthenticateBackOfficeAsync(); + + //if you are hitting VerifyInvite, you're already signed in as a different user, and the token is invalid + //you'll exit on one of the return RedirectToAction(nameof(Default)) but you're still logged in so you just get + //dumped at the default admin view with no detail + if (authenticate.Succeeded) { - // Check if we not are in an run state, if so we need to redirect - if (_runtimeState.Level != RuntimeLevel.Run) + await _signInManager.SignOutAsync(); + } + + if (invite == null) + { + _logger.LogWarning("VerifyUser endpoint reached with invalid token: NULL"); + return RedirectToAction(nameof(Default)); + } + + var parts = WebUtility.UrlDecode(invite).Split('|'); + + if (parts.Length != 2) + { + _logger.LogWarning("VerifyUser endpoint reached with invalid token: {Invite}", invite); + return RedirectToAction(nameof(Default)); + } + + var token = parts[1]; + + var decoded = token.FromUrlBase64(); + if (decoded.IsNullOrWhiteSpace()) + { + _logger.LogWarning("VerifyUser endpoint reached with invalid token: {Invite}", invite); + return RedirectToAction(nameof(Default)); + } + + var id = parts[0]; + + BackOfficeIdentityUser? identityUser = await _userManager.FindByIdAsync(id); + if (identityUser == null) + { + _logger.LogWarning("VerifyUser endpoint reached with non existing user: {UserId}", id); + return RedirectToAction(nameof(Default)); + } + + IdentityResult result = await _userManager.ConfirmEmailAsync(identityUser, decoded!); + + if (result.Succeeded == false) + { + _logger.LogWarning("Could not verify email, Error: {Errors}, Token: {Invite}", result.Errors.ToErrorMessage(), invite); + return new RedirectResult(Url.Action(nameof(Default)) + "#/login/false?invite=3"); + } + + //sign the user in + DateTime? previousLastLoginDate = identityUser.LastLoginDateUtc; + await _signInManager.SignInAsync(identityUser, false); + //reset the lastlogindate back to previous as the user hasn't actually logged in, to add a flag or similar to BackOfficeSignInManager would be a breaking change + identityUser.LastLoginDateUtc = previousLastLoginDate; + await _userManager.UpdateAsync(identityUser); + + return new RedirectResult(Url.Action(nameof(Default)) + "#/login/false?invite=1"); + } + + /// + /// This Action is used by the installer when an upgrade is detected but the admin user is not logged in. We need to + /// ensure the user is authenticated before the install takes place so we redirect here to show the standard login + /// screen. + /// + /// + [HttpGet] + [StatusCodeResult(HttpStatusCode.ServiceUnavailable)] + [AllowAnonymous] + public async Task AuthorizeUpgrade() + { + // force authentication to occur since this is not an authorized endpoint + AuthenticateResult result = await this.AuthenticateBackOfficeAsync(); + + var viewPath = Path.Combine(Constants.SystemDirectories.Umbraco, Constants.Web.Mvc.BackOfficeArea, nameof(AuthorizeUpgrade) + ".cshtml"); + + return await RenderDefaultOrProcessExternalLoginAsync( + result, + //The default view to render when there is no external login info or errors + () => View(viewPath), + //The IActionResult to perform if external login is successful + () => Redirect("/")); + } + + /// + /// Returns the JavaScript main file including all references found in manifests + /// + /// + [MinifyJavaScriptResult(Order = 0)] + [HttpGet] + [AllowAnonymous] + public async Task Application() + { + var result = await _runtimeMinifier.GetScriptForLoadingBackOfficeAsync( + _globalSettings, + _hostingEnvironment, + _manifestParser); + + return new JavaScriptResult(result); + } + + /// + /// Get the json localized text for a given culture or the culture for the current user + /// + /// + /// + [HttpGet] + [AllowAnonymous] + public async Task>> LocalizedText(string? culture = null) + { + CultureInfo? cultureInfo; + if (string.IsNullOrWhiteSpace(culture)) + { + // Force authentication to occur since this is not an authorized endpoint, we need this to get a user. + AuthenticateResult authenticationResult = await this.AuthenticateBackOfficeAsync(); + // We have to get the culture from the Identity, we can't rely on thread culture + // It's entirely likely for a user to have a different culture in the backoffice, than their system. + IIdentity? user = authenticationResult.Principal?.Identity; + + cultureInfo = authenticationResult.Succeeded && user is not null + ? user.GetCulture() + : CultureInfo.GetCultureInfo(_globalSettings.DefaultUILanguage); + } + else + { + cultureInfo = CultureInfo.GetCultureInfo(culture); + } + + IDictionary allValues = _textService.GetAllStoredValues(cultureInfo!); + var pathedValues = allValues.Select(kv => + { + var slashIndex = kv.Key.IndexOf('/'); + var areaAlias = kv.Key[..slashIndex]; + var valueAlias = kv.Key[(slashIndex + 1)..]; + return new { areaAlias, valueAlias, value = kv.Value }; + }); + + var nestedDictionary = pathedValues + .GroupBy(pv => pv.areaAlias) + .ToDictionary(pv => pv.Key, pv => + pv.ToDictionary(pve => pve.valueAlias, pve => pve.value)); + + return nestedDictionary; + } + + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] + [AngularJsonOnlyConfiguration] + [HttpGet] + public IEnumerable GetGridConfig() => _gridConfig.EditorsConfig.Editors; + + /// + /// Returns the JavaScript object representing the static server variables javascript object + /// + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] + [MinifyJavaScriptResult(Order = 1)] + public async Task ServerVariables() + { + // cache the result if debugging is disabled + var serverVars = await _serverVariables.ParseAsync(await _backOfficeServerVariables.GetServerVariablesAsync()); + var result = _hostingEnvironment.IsDebugMode + ? serverVars + : _appCaches.RuntimeCache.GetCacheItem( + typeof(BackOfficeController) + "ServerVariables", + () => serverVars, + new TimeSpan(0, 10, 0)); + + return new JavaScriptResult(result); + } + + [HttpPost] + [AllowAnonymous] + public ActionResult ExternalLogin(string provider, string? redirectUrl = null) + { + if (redirectUrl == null || Uri.TryCreate(redirectUrl, UriKind.Absolute, out _)) + { + redirectUrl = Url.Action(nameof(Default), this.GetControllerName()); + } + + // Configures the redirect URL and user identifier for the specified external login + AuthenticationProperties properties = + _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); + + return Challenge(properties, provider); + } + + /// + /// Called when a user links an external login provider in the back office + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] + [HttpPost] + public ActionResult LinkLogin(string provider) + { + // Request a redirect to the external login provider to link a login for the current user + var redirectUrl = Url.Action(nameof(ExternalLinkLoginCallback), this.GetControllerName()); + + // Configures the redirect URL and user identifier for the specified external login including xsrf data + AuthenticationProperties properties = + _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User)); + + return Challenge(properties, provider); + } + + [HttpGet] + [AllowAnonymous] + public async Task ValidatePasswordResetCode([Bind(Prefix = "u")] int userId, [Bind(Prefix = "r")] string resetCode) + { + BackOfficeIdentityUser? user = await _userManager.FindByIdAsync(userId.ToString(CultureInfo.InvariantCulture)); + if (user != null) + { + var result = await _userManager.VerifyUserTokenAsync(user, "Default", "ResetPassword", resetCode); + if (result) { + //Add a flag and redirect for it to be displayed + TempData[ViewDataExtensions.TokenPasswordResetCode] = + _jsonSerializer.Serialize( + new ValidatePasswordResetCodeModel { UserId = userId, ResetCode = resetCode }); + return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); + } + } + + //Add error and redirect for it to be displayed + TempData[ViewDataExtensions.TokenPasswordResetCode] = + new[] { _textService.Localize("login", "resetCodeExpired") }; + return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); + } + + /// + /// Callback path when the user initiates a link login request from the back office to the external provider from the + /// action + /// + /// + /// An example of this is here + /// https://github.com/dotnet/aspnetcore/blob/main/src/Identity/samples/IdentitySample.Mvc/Controllers/AccountController.cs#L155 + /// which this is based on + /// + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] + [HttpGet] + public async Task ExternalLinkLoginCallback() + { + BackOfficeIdentityUser user = await _userManager.GetUserAsync(User); + if (user == null) + { + // ... this should really not happen + TempData[ViewDataExtensions.TokenExternalSignInError] = new[] { "Local user does not exist" }; + return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); + } + + ExternalLoginInfo? info = + await _signInManager.GetExternalLoginInfoAsync(await _userManager.GetUserIdAsync(user)); + + if (info == null) + { + //Add error and redirect for it to be displayed + TempData[ViewDataExtensions.TokenExternalSignInError] = + new[] { "An error occurred, could not get external login info" }; + return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); + } + + IdentityResult addLoginResult = await _userManager.AddLoginAsync(user, info); + if (addLoginResult.Succeeded) + { + // Update any authentication tokens if succeeded + await _signInManager.UpdateExternalAuthenticationTokensAsync(info); + + return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); + } + + //Add errors and redirect for it to be displayed + TempData[ViewDataExtensions.TokenExternalSignInError] = addLoginResult.Errors; + return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); + } + + /// + /// Used by Default and AuthorizeUpgrade to render as per normal if there's no external login info, + /// otherwise process the external login info. + /// + /// + private async Task RenderDefaultOrProcessExternalLoginAsync( + AuthenticateResult authenticateResult, + Func defaultResponse, + Func externalSignInResponse) + { + if (defaultResponse is null) + { + throw new ArgumentNullException(nameof(defaultResponse)); + } + + if (externalSignInResponse is null) + { + throw new ArgumentNullException(nameof(externalSignInResponse)); + } + + ViewData.SetUmbracoPath(_globalSettings.GetUmbracoMvcArea(_hostingEnvironment)); + + //check if there is the TempData or cookies with the any token name specified, if so, assign to view bag and render the view + if (ViewData.FromBase64CookieData( + _httpContextAccessor.HttpContext, + ViewDataExtensions.TokenExternalSignInError, + _jsonSerializer) || + ViewData.FromTempData(TempData, ViewDataExtensions.TokenExternalSignInError) || ViewData.FromTempData(TempData, ViewDataExtensions.TokenPasswordResetCode)) + { + return defaultResponse(); + } + + //First check if there's external login info, if there's not proceed as normal + ExternalLoginInfo? loginInfo = await _signInManager.GetExternalLoginInfoAsync(); + + if (loginInfo == null || loginInfo.Principal == null) + { + // if the user is not logged in, check if there's any auto login redirects specified + if (!authenticateResult.Succeeded) + { + var oauthRedirectAuthProvider = _externalLogins.GetAutoLoginProvider(); + if (!oauthRedirectAuthProvider.IsNullOrWhiteSpace()) + { + return ExternalLogin(oauthRedirectAuthProvider!); + } + } + + return defaultResponse(); + } + + //we're just logging in with an external source, not linking accounts + return await ExternalSignInAsync(loginInfo, externalSignInResponse); + } + + private async Task ExternalSignInAsync(ExternalLoginInfo loginInfo, Func response) + { + if (loginInfo == null) + { + throw new ArgumentNullException(nameof(loginInfo)); + } + + 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, false, _securitySettings.Value.UserBypassTwoFactorForExternalLogins); + + var errors = new List(); + + if (result == SignInResult.Success) + { + // Update any authentication tokens if succeeded + await _signInManager.UpdateExternalAuthenticationTokensAsync(loginInfo); + + // Check if we are in an upgrade state, if so we need to redirect + if (_runtimeState.Level == RuntimeLevel.Upgrade) + { + // redirect to the the installer return Redirect("/"); } - - // force authentication to occur since this is not an authorized endpoint - AuthenticateResult result = await this.AuthenticateBackOfficeAsync(); - - var viewPath = Path.Combine(Constants.SystemDirectories.Umbraco, Constants.Web.Mvc.BackOfficeArea, nameof(Default) + ".cshtml") - .Replace("\\", "/"); // convert to forward slashes since it's a virtual path - - return await RenderDefaultOrProcessExternalLoginAsync( - result, - () => View(viewPath), - () => View(viewPath)); } - - [HttpGet] - [AllowAnonymous] - public async Task VerifyInvite(string invite) + else if (result == SignInResult.TwoFactorRequired) { - var authenticate = await this.AuthenticateBackOfficeAsync(); - - //if you are hitting VerifyInvite, you're already signed in as a different user, and the token is invalid - //you'll exit on one of the return RedirectToAction(nameof(Default)) but you're still logged in so you just get - //dumped at the default admin view with no detail - if (authenticate.Succeeded) + BackOfficeIdentityUser? attemptedUser = + await _userManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); + if (attemptedUser == null) { - await _signInManager.SignOutAsync(); + return new ValidationErrorResult( + $"No local user found for the login provider {loginInfo.LoginProvider} - {loginInfo.ProviderKey}"); } - if (invite == null) + var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(attemptedUser.UserName); + if (twofactorView.IsNullOrWhiteSpace()) { - _logger.LogWarning("VerifyUser endpoint reached with invalid token: NULL"); - return RedirectToAction(nameof(Default)); + return new ValidationErrorResult( + $"The registered {typeof(IBackOfficeTwoFactorOptions)} of type {_backOfficeTwoFactorOptions.GetType()} did not return a view for two factor auth "); } - var parts = System.Net.WebUtility.UrlDecode(invite).Split('|'); - - if (parts.Length != 2) - { - _logger.LogWarning("VerifyUser endpoint reached with invalid token: {Invite}", invite); - return RedirectToAction(nameof(Default)); - } - - var token = parts[1]; - - var decoded = token.FromUrlBase64(); - if (decoded.IsNullOrWhiteSpace()) - { - _logger.LogWarning("VerifyUser endpoint reached with invalid token: {Invite}", invite); - return RedirectToAction(nameof(Default)); - } - - var id = parts[0]; - - var identityUser = await _userManager.FindByIdAsync(id); - if (identityUser == null) - { - _logger.LogWarning("VerifyUser endpoint reached with non existing user: {UserId}", id); - return RedirectToAction(nameof(Default)); - } - - var result = await _userManager.ConfirmEmailAsync(identityUser, decoded!); - - if (result.Succeeded == false) - { - _logger.LogWarning("Could not verify email, Error: {Errors}, Token: {Invite}", result.Errors.ToErrorMessage(), invite); - return new RedirectResult(Url.Action(nameof(Default)) + "#/login/false?invite=3"); - } - - //sign the user in - DateTime? previousLastLoginDate = identityUser.LastLoginDateUtc; - await _signInManager.SignInAsync(identityUser, false); - //reset the lastlogindate back to previous as the user hasn't actually logged in, to add a flag or similar to BackOfficeSignInManager would be a breaking change - identityUser.LastLoginDateUtc = previousLastLoginDate; - await _userManager.UpdateAsync(identityUser); - - return new RedirectResult(Url.Action(nameof(Default)) + "#/login/false?invite=1"); - } - - /// - /// This Action is used by the installer when an upgrade is detected but the admin user is not logged in. We need to - /// ensure the user is authenticated before the install takes place so we redirect here to show the standard login screen. - /// - /// - [HttpGet] - [StatusCodeResult(System.Net.HttpStatusCode.ServiceUnavailable)] - [AllowAnonymous] - public async Task AuthorizeUpgrade() - { - // force authentication to occur since this is not an authorized endpoint - var result = await this.AuthenticateBackOfficeAsync(); - - var viewPath = Path.Combine(Constants.SystemDirectories.Umbraco, Constants.Web.Mvc.BackOfficeArea, nameof(AuthorizeUpgrade) + ".cshtml"); - - return await RenderDefaultOrProcessExternalLoginAsync( - result, - //The default view to render when there is no external login info or errors - () => View(viewPath), - //The IActionResult to perform if external login is successful - () => Redirect("/")); - } - - /// - /// Returns the JavaScript main file including all references found in manifests - /// - /// - [MinifyJavaScriptResult(Order = 0)] - [HttpGet] - [AllowAnonymous] - public async Task Application() - { - var result = await _runtimeMinifier.GetScriptForLoadingBackOfficeAsync( - _globalSettings, - _hostingEnvironment, - _manifestParser); - - return new JavaScriptResult(result); - } - - /// - /// Get the json localized text for a given culture or the culture for the current user - /// - /// - /// - [HttpGet] - [AllowAnonymous] - public async Task>> LocalizedText(string? culture = null) - { - CultureInfo? cultureInfo; - if (string.IsNullOrWhiteSpace(culture)) - { - // Force authentication to occur since this is not an authorized endpoint, we need this to get a user. - AuthenticateResult authenticationResult = await this.AuthenticateBackOfficeAsync(); - // We have to get the culture from the Identity, we can't rely on thread culture - // It's entirely likely for a user to have a different culture in the backoffice, than their system. - var user = authenticationResult.Principal?.Identity; - - cultureInfo = (authenticationResult.Succeeded && user is not null) - ? user.GetCulture() - : CultureInfo.GetCultureInfo(_globalSettings.DefaultUILanguage); - } - else - { - cultureInfo = CultureInfo.GetCultureInfo(culture); - } - - var allValues = _textService.GetAllStoredValues(cultureInfo!); - var pathedValues = allValues.Select(kv => - { - var slashIndex = kv.Key.IndexOf('/'); - var areaAlias = kv.Key.Substring(0, slashIndex); - var valueAlias = kv.Key.Substring(slashIndex + 1); - return new - { - areaAlias, - valueAlias, - value = kv.Value - }; - }); - - var nestedDictionary = pathedValues - .GroupBy(pv => pv.areaAlias) - .ToDictionary(pv => pv.Key, pv => - pv.ToDictionary(pve => pve.valueAlias, pve => pve.value)); - - return nestedDictionary; - } - - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - [AngularJsonOnlyConfiguration] - [HttpGet] - public IEnumerable GetGridConfig() - { - return _gridConfig.EditorsConfig.Editors; - } - - /// - /// Returns the JavaScript object representing the static server variables javascript object - /// - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - [MinifyJavaScriptResult(Order = 1)] - public async Task ServerVariables() - { - // cache the result if debugging is disabled - var serverVars = await _serverVariables.ParseAsync(await _backOfficeServerVariables.GetServerVariablesAsync()); - var result = _hostingEnvironment.IsDebugMode - ? serverVars - : _appCaches.RuntimeCache.GetCacheItem( - typeof(BackOfficeController) + "ServerVariables", - () => serverVars, - new TimeSpan(0, 10, 0)); - - return new JavaScriptResult(result); - } - - [HttpPost] - [AllowAnonymous] - public ActionResult ExternalLogin(string provider, string? redirectUrl = null) - { - if (redirectUrl == null || Uri.TryCreate(redirectUrl, UriKind.Absolute, out _)) - { - redirectUrl = Url.Action(nameof(Default), this.GetControllerName()); - } - - // Configures the redirect URL and user identifier for the specified external login - var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); - - return Challenge(properties, provider); - } - - /// - /// Called when a user links an external login provider in the back office - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - [HttpPost] - public ActionResult LinkLogin(string provider) - { - // Request a redirect to the external login provider to link a login for the current user - var redirectUrl = Url.Action(nameof(ExternalLinkLoginCallback), this.GetControllerName()); - - // Configures the redirect URL and user identifier for the specified external login including xsrf data - var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User)); - - return Challenge(properties, provider); - } - - [HttpGet] - [AllowAnonymous] - public async Task ValidatePasswordResetCode([Bind(Prefix = "u")]int userId, [Bind(Prefix = "r")]string resetCode) - { - var user = await _userManager.FindByIdAsync(userId.ToString(CultureInfo.InvariantCulture)); - if (user != null) - { - var result = await _userManager.VerifyUserTokenAsync(user, "Default", "ResetPassword", resetCode); - if (result) - { - //Add a flag and redirect for it to be displayed - TempData[ViewDataExtensions.TokenPasswordResetCode] = _jsonSerializer.Serialize(new ValidatePasswordResetCodeModel { UserId = userId, ResetCode = resetCode }); - return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); - } - } - - //Add error and redirect for it to be displayed - TempData[ViewDataExtensions.TokenPasswordResetCode] = new[] { _textService.Localize("login","resetCodeExpired") }; - return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); - } - - /// - /// Callback path when the user initiates a link login request from the back office to the external provider from the action - /// - /// - /// An example of this is here https://github.com/dotnet/aspnetcore/blob/main/src/Identity/samples/IdentitySample.Mvc/Controllers/AccountController.cs#L155 - /// which this is based on - /// - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - [HttpGet] - public async Task ExternalLinkLoginCallback() - { - BackOfficeIdentityUser user = await _userManager.GetUserAsync(User); - if (user == null) - { - // ... this should really not happen - TempData[ViewDataExtensions.TokenExternalSignInError] = new[] { "Local user does not exist" }; - return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); - } - - ExternalLoginInfo? info = await _signInManager.GetExternalLoginInfoAsync(await _userManager.GetUserIdAsync(user)); - - if (info == null) - { - //Add error and redirect for it to be displayed - TempData[ViewDataExtensions.TokenExternalSignInError] = new[] { "An error occurred, could not get external login info" }; - return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); - } - - IdentityResult addLoginResult = await _userManager.AddLoginAsync(user, info); - if (addLoginResult.Succeeded) - { - // Update any authentication tokens if succeeded - await _signInManager.UpdateExternalAuthenticationTokensAsync(info); - - return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); - } - - //Add errors and redirect for it to be displayed - TempData[ViewDataExtensions.TokenExternalSignInError] = addLoginResult.Errors; - return RedirectToLocal(Url.Action(nameof(Default), this.GetControllerName())); - } - - /// - /// Used by Default and AuthorizeUpgrade to render as per normal if there's no external login info, - /// otherwise process the external login info. - /// - /// - private async Task RenderDefaultOrProcessExternalLoginAsync( - AuthenticateResult authenticateResult, - Func defaultResponse, - Func externalSignInResponse) - { - if (defaultResponse is null) throw new ArgumentNullException(nameof(defaultResponse)); - if (externalSignInResponse is null) throw new ArgumentNullException(nameof(externalSignInResponse)); - - ViewData.SetUmbracoPath(_globalSettings.GetUmbracoMvcArea(_hostingEnvironment)); - - //check if there is the TempData or cookies with the any token name specified, if so, assign to view bag and render the view - if (ViewData.FromBase64CookieData(_httpContextAccessor.HttpContext, ViewDataExtensions.TokenExternalSignInError, _jsonSerializer) || - ViewData.FromTempData(TempData, ViewDataExtensions.TokenExternalSignInError) || - ViewData.FromTempData(TempData, ViewDataExtensions.TokenPasswordResetCode)) - { - return defaultResponse(); - } - - //First check if there's external login info, if there's not proceed as normal - var loginInfo = await _signInManager.GetExternalLoginInfoAsync(); - - if (loginInfo == null || loginInfo.Principal == null) - { - // if the user is not logged in, check if there's any auto login redirects specified - if (!authenticateResult.Succeeded) - { - var oauthRedirectAuthProvider = _externalLogins.GetAutoLoginProvider(); - if (!oauthRedirectAuthProvider.IsNullOrWhiteSpace()) - { - return ExternalLogin(oauthRedirectAuthProvider!); - } - } - - return defaultResponse(); - } - - //we're just logging in with an external source, not linking accounts - return await ExternalSignInAsync(loginInfo, externalSignInResponse); - } - - private async Task ExternalSignInAsync(ExternalLoginInfo loginInfo, Func response) - { - if (loginInfo == null) throw new ArgumentNullException(nameof(loginInfo)); - 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, bypassTwoFactor: _securitySettings.Value.UserBypassTwoFactorForExternalLogins); - - var errors = new List(); - - if (result == SignInResult.Success) - { - // Update any authentication tokens if succeeded - await _signInManager.UpdateExternalAuthenticationTokensAsync(loginInfo); - - // Check if we are in an upgrade state, if so we need to redirect - if (_runtimeState.Level == Core.RuntimeLevel.Upgrade) - { - // redirect to the the installer - return Redirect("/"); - } - } - else if (result == SignInResult.TwoFactorRequired) - { - - var attemptedUser = await _userManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); - if (attemptedUser == null) - { - return new ValidationErrorResult($"No local user found for the login provider {loginInfo.LoginProvider} - {loginInfo.ProviderKey}"); - } - - var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(attemptedUser.UserName); - if (twofactorView.IsNullOrWhiteSpace()) - { - return new ValidationErrorResult($"The registered {typeof(IBackOfficeTwoFactorOptions)} of type {_backOfficeTwoFactorOptions.GetType()} did not return a view for two factor auth "); - } - - // create a with information to display a custom two factor send code view - var verifyResponse = new ObjectResult(new - { - twoFactorView = twofactorView, - userId = attemptedUser.Id - }) + // create a with information to display a custom two factor send code view + var verifyResponse = + new ObjectResult(new { twoFactorView = twofactorView, userId = attemptedUser.Id }) { StatusCode = StatusCodes.Status402PaymentRequired }; - return verifyResponse; - - } - else if (result == SignInResult.LockedOut) - { - errors.Add($"The local user {loginInfo.Principal.Identity?.Name} for the external provider {loginInfo.ProviderDisplayName} is locked out."); - } - else if (result == SignInResult.NotAllowed) - { - // This occurs when SignInManager.CanSignInAsync fails which is when RequireConfirmedEmail , RequireConfirmedPhoneNumber or RequireConfirmedAccount fails - // however since we don't enforce those rules (yet) this shouldn't happen. - errors.Add($"The user {loginInfo.Principal.Identity?.Name} for the external provider {loginInfo.ProviderDisplayName} has not confirmed their details and cannot sign in."); - } - else if (result == SignInResult.Failed) - { - // Failed only occurs when the user does not exist - errors.Add("The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account, the provider must be linked from the back office."); - } - else if (result == ExternalLoginSignInResult.NotAllowed) - { - // This occurs when the external provider has approved the login but custom logic in OnExternalLogin has denined it. - errors.Add($"The user {loginInfo.Principal.Identity?.Name} for the external provider {loginInfo.ProviderDisplayName} has not been accepted and cannot sign in."); - } - else if (result == AutoLinkSignInResult.FailedNotLinked) - { - errors.Add("The requested provider (" + loginInfo.LoginProvider + ") has not been linked to an account, the provider must be linked from the back office."); - } - else if (result == AutoLinkSignInResult.FailedNoEmail) - { - errors.Add($"The requested provider ({loginInfo.LoginProvider}) has not provided the email claim {ClaimTypes.Email}, the account cannot be linked."); - } - else if (result is AutoLinkSignInResult autoLinkSignInResult && autoLinkSignInResult.Errors.Count > 0) - { - errors.AddRange(autoLinkSignInResult.Errors); - } - else if (!result.Succeeded) - { - // this shouldn't occur, the above should catch the correct error but we'll be safe just in case - errors.Add($"An unknown error with the requested provider ({loginInfo.LoginProvider}) occurred."); - } - - if (errors.Count > 0) - { - ViewData.SetExternalSignInProviderErrors( - new BackOfficeExternalLoginProviderErrors( - loginInfo.LoginProvider, - errors)); - } - - return response(); + return verifyResponse; } - - private IActionResult RedirectToLocal(string? returnUrl) + else if (result == SignInResult.LockedOut) { - if (Url.IsLocalUrl(returnUrl)) - { - return Redirect(returnUrl); - } - return Redirect("/"); + errors.Add( + $"The local user {loginInfo.Principal.Identity?.Name} for the external provider {loginInfo.ProviderDisplayName} is locked out."); + } + else if (result == SignInResult.NotAllowed) + { + // This occurs when SignInManager.CanSignInAsync fails which is when RequireConfirmedEmail , RequireConfirmedPhoneNumber or RequireConfirmedAccount fails + // however since we don't enforce those rules (yet) this shouldn't happen. + errors.Add( + $"The user {loginInfo.Principal.Identity?.Name} for the external provider {loginInfo.ProviderDisplayName} has not confirmed their details and cannot sign in."); + } + else if (result == SignInResult.Failed) + { + // Failed only occurs when the user does not exist + errors.Add("The requested provider (" + loginInfo.LoginProvider + + ") has not been linked to an account, the provider must be linked from the back office."); + } + else if (result == ExternalLoginSignInResult.NotAllowed) + { + // This occurs when the external provider has approved the login but custom logic in OnExternalLogin has denined it. + errors.Add( + $"The user {loginInfo.Principal.Identity?.Name} for the external provider {loginInfo.ProviderDisplayName} has not been accepted and cannot sign in."); + } + else if (result == AutoLinkSignInResult.FailedNotLinked) + { + errors.Add("The requested provider (" + loginInfo.LoginProvider + + ") has not been linked to an account, the provider must be linked from the back office."); + } + else if (result == AutoLinkSignInResult.FailedNoEmail) + { + errors.Add( + $"The requested provider ({loginInfo.LoginProvider}) has not provided the email claim {ClaimTypes.Email}, the account cannot be linked."); + } + else if (result is AutoLinkSignInResult autoLinkSignInResult && autoLinkSignInResult.Errors.Count > 0) + { + errors.AddRange(autoLinkSignInResult.Errors); + } + else if (!result.Succeeded) + { + // this shouldn't occur, the above should catch the correct error but we'll be safe just in case + errors.Add($"An unknown error with the requested provider ({loginInfo.LoginProvider}) occurred."); } + if (errors.Count > 0) + { + ViewData.SetExternalSignInProviderErrors( + new BackOfficeExternalLoginProviderErrors( + loginInfo.LoginProvider, + errors)); + } + return response(); + } + + private IActionResult RedirectToLocal(string? returnUrl) + { + if (Url.IsLocalUrl(returnUrl)) + { + return Redirect(returnUrl); + } + + return Redirect("/"); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeNotificationsController.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeNotificationsController.cs index ad65941cbf..5508593277 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeNotificationsController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeNotificationsController.cs @@ -1,71 +1,61 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Umbraco.Cms.Core.Models.ContentEditing; -using Umbraco.Cms.Web.BackOffice.ActionResults; using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// An abstract controller that automatically checks if any request is a non-GET and if the +/// resulting message is INotificationModel in which case it will append any Event Messages +/// currently in the request. +/// +[PrefixlessBodyModelValidator] +[AppendCurrentEventMessages] +public abstract class BackOfficeNotificationsController : UmbracoAuthorizedJsonController { /// - /// An abstract controller that automatically checks if any request is a non-GET and if the - /// resulting message is INotificationModel in which case it will append any Event Messages - /// currently in the request. + /// returns a 200 OK response with a notification message /// - [PrefixlessBodyModelValidator] - [AppendCurrentEventMessages] - public abstract class BackOfficeNotificationsController : UmbracoAuthorizedJsonController + /// + /// + protected OkObjectResult Ok(string message) { - /// - /// returns a 200 OK response with a notification message - /// - /// - /// - protected OkObjectResult Ok(string message) - { - var notificationModel = new SimpleNotificationModel - { - Message = message - }; - notificationModel.AddSuccessNotification(message, string.Empty); - - return new OkObjectResult(notificationModel); - } - - /// - /// Overridden to ensure that the error message is an error notification message - /// - /// - /// - protected override ActionResult ValidationProblem(string? errorMessage) - => ValidationProblem(errorMessage, string.Empty); - - /// - /// Creates a notofication validation problem with a header and message - /// - /// - /// - /// - protected ActionResult ValidationProblem(string? errorHeader, string errorMessage) - { - var notificationModel = new SimpleNotificationModel - { - Message = errorMessage - }; - notificationModel.AddErrorNotification(errorHeader, errorMessage); - return new ValidationErrorResult(notificationModel); - } - - /// - /// Overridden to ensure that all queued notifications are sent to the back office - /// - /// - [NonAction] - public override ActionResult ValidationProblem() - // returning an object of INotificationModel will ensure that any pending - // notification messages are added to the response. - => new ValidationErrorResult(new SimpleNotificationModel()); + var notificationModel = new SimpleNotificationModel { Message = message }; + notificationModel.AddSuccessNotification(message, string.Empty); + return new OkObjectResult(notificationModel); } + + /// + /// Overridden to ensure that the error message is an error notification message + /// + /// + /// + protected override ActionResult ValidationProblem(string? errorMessage) + => ValidationProblem(errorMessage, string.Empty); + + /// + /// Creates a notofication validation problem with a header and message + /// + /// + /// + /// + protected ActionResult ValidationProblem(string? errorHeader, string errorMessage) + { + var notificationModel = new SimpleNotificationModel { Message = errorMessage }; + notificationModel.AddErrorNotification(errorHeader, errorMessage); + return new ValidationErrorResult(notificationModel); + } + + /// + /// Overridden to ensure that all queued notifications are sent to the back office + /// + /// + [NonAction] + public override ActionResult ValidationProblem() + // returning an object of INotificationModel will ensure that any pending + // notification messages are added to the response. + => new ValidationErrorResult(new SimpleNotificationModel()); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs index 10f2a738bf..b7fc6a92f5 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Runtime.Serialization; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -30,7 +26,6 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Models; using Umbraco.Extensions; -using Language = Umbraco.Cms.Core.Models.ContentEditing.Language; namespace Umbraco.Cms.Web.BackOffice.Controllers { @@ -162,7 +157,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers {"features", new [] {"disabledFeatures"}} }; //now do the filtering... - var defaults = await GetServerVariablesAsync(); + Dictionary defaults = await GetServerVariablesAsync(); foreach (var key in defaults.Keys.ToArray()) { if (keepOnlyKeys.ContainsKey(key) == false) @@ -202,7 +197,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers /// internal async Task> GetServerVariablesAsync() { - var globalSettings = _globalSettings; + GlobalSettings globalSettings = _globalSettings; var backOfficeControllerName = ControllerExtensions.GetControllerName(); var defaultVals = new Dictionary { @@ -233,7 +228,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers }, { "embedApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( - controller => controller.GetEmbed("", 0, 0)) + controller => controller.GetEmbed(string.Empty, 0, 0)) }, { "userApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( @@ -261,7 +256,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers }, { "imagesApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( - controller => controller.GetBigThumbnail("")) + controller => controller.GetBigThumbnail(string.Empty)) }, { "sectionApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( @@ -369,7 +364,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers }, { "tagsDataBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( - controller => controller.GetTags("", "", null)) + controller => controller.GetTags(string.Empty, string.Empty, null)) }, { "examineMgmtBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( @@ -385,7 +380,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers }, { "codeFileApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( - controller => controller.GetByPath("", "")) + controller => controller.GetByPath(string.Empty, string.Empty)) }, { "publishedStatusBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( @@ -401,7 +396,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers }, { "helpApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( - controller => controller.GetContextHelpForPage("","","")) + controller => controller.GetContextHelpForPage(string.Empty,string.Empty,string.Empty)) }, { "backOfficeAssetsApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( @@ -562,19 +557,23 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers // do this instead // inheriting from TreeControllerBase and marked with TreeAttribute - foreach (var tree in _treeCollection) + foreach (Tree tree in _treeCollection) { - var treeType = tree.TreeControllerType; + Type treeType = tree.TreeControllerType; // exclude anything marked with CoreTreeAttribute - var coreTree = treeType.GetCustomAttribute(false); + CoreTreeAttribute? coreTree = treeType.GetCustomAttribute(false); if (coreTree != null) + { continue; + } // exclude anything not marked with PluginControllerAttribute - var pluginController = treeType.GetCustomAttribute(false); + PluginControllerAttribute? pluginController = treeType.GetCustomAttribute(false); if (pluginController == null) + { continue; + } yield return new PluginTree { Alias = tree.TreeAlias, PackageFolder = pluginController.AreaName }; } diff --git a/src/Umbraco.Web.BackOffice/Controllers/CodeFileController.cs b/src/Umbraco.Web.BackOffice/Controllers/CodeFileController.cs index fbcfe283ea..8587939dfd 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/CodeFileController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/CodeFileController.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Web; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -10,6 +11,7 @@ using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Snippets; @@ -22,740 +24,823 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; using Stylesheet = Umbraco.Cms.Core.Models.Stylesheet; using StylesheetRule = Umbraco.Cms.Core.Models.ContentEditing.StylesheetRule; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +// TODO: Put some exception filters in our webapi to return 404 instead of 500 when we throw ArgumentNullException +// ref: https://www.exceptionnotfound.net/the-asp-net-web-api-exception-handling-pipeline-a-guided-tour/ +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +//[PrefixlessBodyModelValidator] +[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] +public class CodeFileController : BackOfficeNotificationsController { - // TODO: Put some exception filters in our webapi to return 404 instead of 500 when we throw ArgumentNullException - // ref: https://www.exceptionnotfound.net/the-asp-net-web-api-exception-handling-pipeline-a-guided-tour/ - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - //[PrefixlessBodyModelValidator] - [Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] - public class CodeFileController : BackOfficeNotificationsController + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IFileService _fileService; + private readonly FileSystems _fileSystems; + private readonly GlobalSettings _globalSettings; + private readonly IHostingEnvironment _hostingEnvironment; + + private readonly ILocalizedTextService _localizedTextService; + private readonly PartialViewMacroSnippetCollection _partialViewMacroSnippetCollection; + private readonly PartialViewSnippetCollection _partialViewSnippetCollection; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; + + [ActivatorUtilitiesConstructor] + public CodeFileController( + IHostingEnvironment hostingEnvironment, + FileSystems fileSystems, + IFileService fileService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + ILocalizedTextService localizedTextService, + IUmbracoMapper umbracoMapper, + IShortStringHelper shortStringHelper, + IOptionsSnapshot globalSettings, + PartialViewSnippetCollection partialViewSnippetCollection, + PartialViewMacroSnippetCollection partialViewMacroSnippetCollection) { - private readonly IHostingEnvironment _hostingEnvironment; - private readonly FileSystems _fileSystems; - private readonly IFileService _fileService; - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + _hostingEnvironment = hostingEnvironment; + _fileSystems = fileSystems; + _fileService = fileService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _localizedTextService = localizedTextService; + _umbracoMapper = umbracoMapper; + _shortStringHelper = shortStringHelper; + _globalSettings = globalSettings.Value; + _partialViewSnippetCollection = partialViewSnippetCollection; + _partialViewMacroSnippetCollection = partialViewMacroSnippetCollection; + } - private readonly ILocalizedTextService _localizedTextService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IShortStringHelper _shortStringHelper; - private readonly GlobalSettings _globalSettings; - private readonly PartialViewSnippetCollection _partialViewSnippetCollection; - private readonly PartialViewMacroSnippetCollection _partialViewMacroSnippetCollection; + [Obsolete("Use ctor will all params. Scheduled for removal in V12.")] + public CodeFileController( + IHostingEnvironment hostingEnvironment, + FileSystems fileSystems, + IFileService fileService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + ILocalizedTextService localizedTextService, + IUmbracoMapper umbracoMapper, + IShortStringHelper shortStringHelper, + IOptionsSnapshot globalSettings) : this( + hostingEnvironment, + fileSystems, + fileService, + backOfficeSecurityAccessor, + localizedTextService, + umbracoMapper, + shortStringHelper, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } - [ActivatorUtilitiesConstructor] - public CodeFileController( - IHostingEnvironment hostingEnvironment, - FileSystems fileSystems, - IFileService fileService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - ILocalizedTextService localizedTextService, - IUmbracoMapper umbracoMapper, - IShortStringHelper shortStringHelper, - IOptionsSnapshot globalSettings, - PartialViewSnippetCollection partialViewSnippetCollection, - PartialViewMacroSnippetCollection partialViewMacroSnippetCollection) + /// + /// Used to create a brand new file + /// + /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' + /// + /// Will return a simple 200 if file creation succeeds + [ValidationFilter] + public ActionResult PostCreate(string type, CodeFileDisplay display) + { + if (display == null) { - _hostingEnvironment = hostingEnvironment; - _fileSystems = fileSystems; - _fileService = fileService; - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _localizedTextService = localizedTextService; - _umbracoMapper = umbracoMapper; - _shortStringHelper = shortStringHelper; - _globalSettings = globalSettings.Value; - _partialViewSnippetCollection = partialViewSnippetCollection; - _partialViewMacroSnippetCollection = partialViewMacroSnippetCollection; + throw new ArgumentNullException("display"); } - [Obsolete("Use ctor will all params. Scheduled for removal in V12.")] - public CodeFileController( - IHostingEnvironment hostingEnvironment, - FileSystems fileSystems, - IFileService fileService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - ILocalizedTextService localizedTextService, - IUmbracoMapper umbracoMapper, - IShortStringHelper shortStringHelper, - IOptionsSnapshot globalSettings) : this( - hostingEnvironment, - fileSystems, - fileService, - backOfficeSecurityAccessor, - localizedTextService, - umbracoMapper, - shortStringHelper, - globalSettings, - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService()) + if (string.IsNullOrWhiteSpace(type)) { + throw new ArgumentException("Value cannot be null or whitespace.", "type"); } - /// - /// Used to create a brand new file - /// - /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' - /// - /// Will return a simple 200 if file creation succeeds - [ValidationFilter] - public ActionResult PostCreate(string type, CodeFileDisplay display) + IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + switch (type) { - if (display == null) throw new ArgumentNullException("display"); - if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Value cannot be null or whitespace.", "type"); - - var currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - switch (type) - { - case Constants.Trees.PartialViews: - var view = new PartialView(PartialViewType.PartialView, display.VirtualPath ?? string.Empty); - view.Content = display.Content; - var result = _fileService.CreatePartialView(view, display.Snippet, currentUser?.Id); - if (result.Success) - { - return Ok(); - } - else - { - return ValidationProblem(result.Exception?.Message); - } - - case Constants.Trees.PartialViewMacros: - var viewMacro = new PartialView(PartialViewType.PartialViewMacro, display.VirtualPath ?? string.Empty); - viewMacro.Content = display.Content; - var resultMacro = _fileService.CreatePartialViewMacro(viewMacro, display.Snippet, currentUser?.Id); - if (resultMacro.Success) - return Ok(); - else - return ValidationProblem(resultMacro.Exception?.Message); - - case Constants.Trees.Scripts: - var script = new Script(display.VirtualPath ?? string.Empty); - _fileService.SaveScript(script, currentUser?.Id); + case Constants.Trees.PartialViews: + var view = new PartialView(PartialViewType.PartialView, display.VirtualPath ?? string.Empty) + { + Content = display.Content, + }; + Attempt result = _fileService.CreatePartialView(view, display.Snippet, currentUser?.Id); + if (result.Success) + { return Ok(); + } - default: - return NotFound(); - } + return ValidationProblem(result.Exception?.Message); + + case Constants.Trees.PartialViewMacros: + var viewMacro = new PartialView(PartialViewType.PartialViewMacro, display.VirtualPath ?? string.Empty) + { + Content = display.Content, + }; + Attempt resultMacro = + _fileService.CreatePartialViewMacro(viewMacro, display.Snippet, currentUser?.Id); + if (resultMacro.Success) + { + return Ok(); + } + + return ValidationProblem(resultMacro.Exception?.Message); + + case Constants.Trees.Scripts: + var script = new Script(display.VirtualPath ?? string.Empty); + _fileService.SaveScript(script, currentUser?.Id); + return Ok(); + + default: + return NotFound(); + } + } + + /// + /// Used to create a container/folder in 'partialViews', 'partialViewMacros', 'scripts' or 'stylesheets' + /// + /// 'partialViews', 'partialViewMacros' or 'scripts' + /// The virtual path of the parent. + /// The name of the container/folder + /// + [HttpPost] + public ActionResult PostCreateContainer(string type, string parentId, string name) + { + if (string.IsNullOrWhiteSpace(type)) + { + throw new ArgumentException("Value cannot be null or whitespace.", "type"); } - /// - /// Used to create a container/folder in 'partialViews', 'partialViewMacros', 'scripts' or 'stylesheets' - /// - /// 'partialViews', 'partialViewMacros' or 'scripts' - /// The virtual path of the parent. - /// The name of the container/folder - /// - [HttpPost] - public ActionResult PostCreateContainer(string type, string parentId, string name) + if (string.IsNullOrWhiteSpace(parentId)) { - if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Value cannot be null or whitespace.", "type"); - if (string.IsNullOrWhiteSpace(parentId)) throw new ArgumentException("Value cannot be null or whitespace.", "parentId"); - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value cannot be null or whitespace.", "name"); - if (name.ContainsAny(Path.GetInvalidPathChars())) { - return ValidationProblem(_localizedTextService.Localize("codefile", "createFolderIllegalChars")); - } - - // if the parentId is root (-1) then we just need an empty string as we are - // creating the path below and we don't want -1 in the path - if (parentId == Constants.System.RootString) - { - parentId = string.Empty; - } - - name = System.Web.HttpUtility.UrlDecode(name); - - if (parentId.IsNullOrWhiteSpace() == false) - { - parentId = System.Web.HttpUtility.UrlDecode(parentId); - name = parentId.EnsureEndsWith("/") + name; - } - - var virtualPath = string.Empty; - switch (type) - { - case Constants.Trees.PartialViews: - virtualPath = NormalizeVirtualPath(name, Constants.SystemDirectories.PartialViews); - _fileService.CreatePartialViewFolder(virtualPath); - break; - case Constants.Trees.PartialViewMacros: - virtualPath = NormalizeVirtualPath(name, Constants.SystemDirectories.MacroPartials); - _fileService.CreatePartialViewMacroFolder(virtualPath); - break; - case Constants.Trees.Scripts: - virtualPath = NormalizeVirtualPath(name, _globalSettings.UmbracoScriptsPath); - _fileService.CreateScriptFolder(virtualPath); - break; - case Constants.Trees.Stylesheets: - virtualPath = NormalizeVirtualPath(name, _globalSettings.UmbracoCssPath); - _fileService.CreateStyleSheetFolder(virtualPath); - break; - - } - - return new CodeFileDisplay - { - VirtualPath = virtualPath, - Path = Url.GetTreePathFromFilePath(virtualPath) - }; + throw new ArgumentException("Value cannot be null or whitespace.", "parentId"); } - /// - /// Used to get a specific file from disk via the FileService - /// - /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' or 'stylesheets' - /// The filename or URL encoded path of the file to open - /// The file and its contents from the virtualPath - public ActionResult GetByPath(string type, string virtualPath) + if (string.IsNullOrWhiteSpace(name)) { - if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Value cannot be null or whitespace.", "type"); - if (string.IsNullOrWhiteSpace(virtualPath)) throw new ArgumentException("Value cannot be null or whitespace.", "virtualPath"); - - virtualPath = System.Web.HttpUtility.UrlDecode(virtualPath); - - switch (type) - { - case Constants.Trees.PartialViews: - var view = _fileService.GetPartialView(virtualPath); - if (view != null) - { - var display = _umbracoMapper.Map(view); - - if (display is not null) - { - display.FileType = Constants.Trees.PartialViews; - display.Path = Url.GetTreePathFromFilePath(view.Path); - display.Id = System.Web.HttpUtility.UrlEncode(view.Path); - } - - return display; - } - - break; - case Constants.Trees.PartialViewMacros: - var viewMacro = _fileService.GetPartialViewMacro(virtualPath); - if (viewMacro != null) - { - var display = _umbracoMapper.Map(viewMacro); - - if (display is not null) - { - display.FileType = Constants.Trees.PartialViewMacros; - display.Path = Url.GetTreePathFromFilePath(viewMacro.Path); - display.Id = System.Web.HttpUtility.UrlEncode(viewMacro.Path); - } - - return display; - } - break; - case Constants.Trees.Scripts: - var script = _fileService.GetScript(virtualPath); - if (script != null) - { - var display = _umbracoMapper.Map(script); - - if (display is not null) - { - display.FileType = Constants.Trees.Scripts; - display.Path = Url.GetTreePathFromFilePath(script.Path); - display.Id = System.Web.HttpUtility.UrlEncode(script.Path); - } - - return display; - } - break; - case Constants.Trees.Stylesheets: - var stylesheet = _fileService.GetStylesheet(virtualPath); - if (stylesheet != null) - { - var display = _umbracoMapper.Map(stylesheet); - - if (display is not null) - { - display.FileType = Constants.Trees.Stylesheets; - display.Path = Url.GetTreePathFromFilePath(stylesheet.Path); - display.Id = System.Web.HttpUtility.UrlEncode(stylesheet.Path); - } - - return display; - } - break; - } - - return NotFound(); + throw new ArgumentException("Value cannot be null or whitespace.", "name"); } - /// - /// Used to get a list of available templates/snippets to base a new Partial View or Partial View Macro from - /// - /// This is a string but will be 'partialViews', 'partialViewMacros' - /// Returns a list of if a correct type is sent - public ActionResult> GetSnippets(string type) + if (name.ContainsAny(Path.GetInvalidPathChars())) { - if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Value cannot be null or whitespace.", "type"); - - IEnumerable snippets; - switch (type) - { - case Constants.Trees.PartialViews: - snippets = _partialViewSnippetCollection.GetNames(); - break; - case Constants.Trees.PartialViewMacros: - snippets = _partialViewMacroSnippetCollection.GetNames(); - break; - default: - return NotFound(); - } - - return snippets.Select(snippet => new SnippetDisplay() { Name = snippet.SplitPascalCasing(_shortStringHelper).ToFirstUpperInvariant(), FileName = snippet }).ToList(); + return ValidationProblem(_localizedTextService.Localize("codefile", "createFolderIllegalChars")); } - /// - /// Used to scaffold the json object for the editors for 'scripts', 'partialViews', 'partialViewMacros' and 'stylesheets' - /// - /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' or 'stylesheets' - /// - /// - /// - public ActionResult GetScaffold(string type, string id, string? snippetName = null) + // if the parentId is root (-1) then we just need an empty string as we are + // creating the path below and we don't want -1 in the path + if (parentId == Constants.System.RootString) { - if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Value cannot be null or whitespace.", "type"); - if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("Value cannot be null or whitespace.", "id"); + parentId = string.Empty; + } - CodeFileDisplay? codeFileDisplay; + name = HttpUtility.UrlDecode(name); - switch (type) - { - case Constants.Trees.PartialViews: - codeFileDisplay = _umbracoMapper.Map(new PartialView(PartialViewType.PartialView, string.Empty)); - if (codeFileDisplay is not null) + if (parentId.IsNullOrWhiteSpace() == false) + { + parentId = HttpUtility.UrlDecode(parentId); + name = parentId.EnsureEndsWith("/") + name; + } + + var virtualPath = string.Empty; + switch (type) + { + case Constants.Trees.PartialViews: + virtualPath = NormalizeVirtualPath(name, Constants.SystemDirectories.PartialViews); + _fileService.CreatePartialViewFolder(virtualPath); + break; + case Constants.Trees.PartialViewMacros: + virtualPath = NormalizeVirtualPath(name, Constants.SystemDirectories.MacroPartials); + _fileService.CreatePartialViewMacroFolder(virtualPath); + break; + case Constants.Trees.Scripts: + virtualPath = NormalizeVirtualPath(name, _globalSettings.UmbracoScriptsPath); + _fileService.CreateScriptFolder(virtualPath); + break; + case Constants.Trees.Stylesheets: + virtualPath = NormalizeVirtualPath(name, _globalSettings.UmbracoCssPath); + _fileService.CreateStyleSheetFolder(virtualPath); + break; + } + + return new CodeFileDisplay { VirtualPath = virtualPath, Path = Url.GetTreePathFromFilePath(virtualPath) }; + } + + /// + /// Used to get a specific file from disk via the FileService + /// + /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' or 'stylesheets' + /// The filename or URL encoded path of the file to open + /// The file and its contents from the virtualPath + public ActionResult GetByPath(string type, string virtualPath) + { + if (string.IsNullOrWhiteSpace(type)) + { + throw new ArgumentException("Value cannot be null or whitespace.", "type"); + } + + if (string.IsNullOrWhiteSpace(virtualPath)) + { + throw new ArgumentException("Value cannot be null or whitespace.", "virtualPath"); + } + + virtualPath = HttpUtility.UrlDecode(virtualPath); + + switch (type) + { + case Constants.Trees.PartialViews: + IPartialView? view = _fileService.GetPartialView(virtualPath); + if (view != null) + { + CodeFileDisplay? display = _umbracoMapper.Map(view); + + if (display is not null) { - codeFileDisplay.VirtualPath = Constants.SystemDirectories.PartialViews; - if (snippetName.IsNullOrWhiteSpace() == false) - { - codeFileDisplay.Content = _partialViewSnippetCollection.GetContentFromName(snippetName!); - } + display.FileType = Constants.Trees.PartialViews; + display.Path = Url.GetTreePathFromFilePath(view.Path); + display.Id = HttpUtility.UrlEncode(view.Path); } - break; - case Constants.Trees.PartialViewMacros: - codeFileDisplay = _umbracoMapper.Map(new PartialView(PartialViewType.PartialViewMacro, string.Empty)); - if (codeFileDisplay is not null) + return display; + } + + break; + case Constants.Trees.PartialViewMacros: + IPartialView? viewMacro = _fileService.GetPartialViewMacro(virtualPath); + if (viewMacro != null) + { + CodeFileDisplay? display = _umbracoMapper.Map(viewMacro); + + if (display is not null) { - codeFileDisplay.VirtualPath = Constants.SystemDirectories.MacroPartials; - if (snippetName.IsNullOrWhiteSpace() == false) - { - codeFileDisplay.Content = _partialViewMacroSnippetCollection.GetContentFromName(snippetName!); - } + display.FileType = Constants.Trees.PartialViewMacros; + display.Path = Url.GetTreePathFromFilePath(viewMacro.Path); + display.Id = HttpUtility.UrlEncode(viewMacro.Path); } - break; - case Constants.Trees.Scripts: - codeFileDisplay = _umbracoMapper.Map(new Script(string.Empty)); - if (codeFileDisplay is not null) + return display; + } + + break; + case Constants.Trees.Scripts: + IScript? script = _fileService.GetScript(virtualPath); + if (script != null) + { + CodeFileDisplay? display = _umbracoMapper.Map(script); + + if (display is not null) { - codeFileDisplay.VirtualPath = _globalSettings.UmbracoScriptsPath; + display.FileType = Constants.Trees.Scripts; + display.Path = Url.GetTreePathFromFilePath(script.Path); + display.Id = HttpUtility.UrlEncode(script.Path); } - break; - case Constants.Trees.Stylesheets: - codeFileDisplay = _umbracoMapper.Map(new Stylesheet(string.Empty)); - if (codeFileDisplay is not null) + return display; + } + + break; + case Constants.Trees.Stylesheets: + IStylesheet? stylesheet = _fileService.GetStylesheet(virtualPath); + if (stylesheet != null) + { + CodeFileDisplay? display = _umbracoMapper.Map(stylesheet); + + if (display is not null) { - codeFileDisplay.VirtualPath = _globalSettings.UmbracoCssPath; + display.FileType = Constants.Trees.Stylesheets; + display.Path = Url.GetTreePathFromFilePath(stylesheet.Path); + display.Id = HttpUtility.UrlEncode(stylesheet.Path); } - break; - default: - return new UmbracoProblemResult("Unsupported editortype", HttpStatusCode.BadRequest); - } + return display; + } - if (codeFileDisplay is null) - { - return codeFileDisplay; - } - // Make sure that the root virtual path ends with '/' - codeFileDisplay.VirtualPath = codeFileDisplay.VirtualPath?.EnsureEndsWith("/"); + break; + } - if (id != Constants.System.RootString) - { - codeFileDisplay.VirtualPath += id.TrimStart(Constants.CharArrays.ForwardSlash).EnsureEndsWith("/"); - //if it's not new then it will have a path, otherwise it won't - codeFileDisplay.Path = Url.GetTreePathFromFilePath(id); - } + return NotFound(); + } - codeFileDisplay.VirtualPath = codeFileDisplay.VirtualPath?.TrimStart("~"); - codeFileDisplay.FileType = type; + /// + /// Used to get a list of available templates/snippets to base a new Partial View or Partial View Macro from + /// + /// This is a string but will be 'partialViews', 'partialViewMacros' + /// Returns a list of if a correct type is sent + public ActionResult> GetSnippets(string type) + { + if (string.IsNullOrWhiteSpace(type)) + { + throw new ArgumentException("Value cannot be null or whitespace.", "type"); + } + + IEnumerable snippets; + switch (type) + { + case Constants.Trees.PartialViews: + snippets = _partialViewSnippetCollection.GetNames(); + break; + case Constants.Trees.PartialViewMacros: + snippets = _partialViewMacroSnippetCollection.GetNames(); + break; + default: + return NotFound(); + } + + return snippets.Select(snippet => new SnippetDisplay + { + Name = snippet.SplitPascalCasing(_shortStringHelper).ToFirstUpperInvariant(), + FileName = snippet, + }).ToList(); + } + + /// + /// Used to scaffold the json object for the editors for 'scripts', 'partialViews', 'partialViewMacros' and + /// 'stylesheets' + /// + /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' or 'stylesheets' + /// + /// + /// + public ActionResult GetScaffold(string type, string id, string? snippetName = null) + { + if (string.IsNullOrWhiteSpace(type)) + { + throw new ArgumentException("Value cannot be null or whitespace.", "type"); + } + + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Value cannot be null or whitespace.", "id"); + } + + CodeFileDisplay? codeFileDisplay; + + switch (type) + { + case Constants.Trees.PartialViews: + codeFileDisplay = + _umbracoMapper.Map(new PartialView(PartialViewType.PartialView, string.Empty)); + if (codeFileDisplay is not null) + { + codeFileDisplay.VirtualPath = Constants.SystemDirectories.PartialViews; + if (snippetName.IsNullOrWhiteSpace() == false) + { + codeFileDisplay.Content = _partialViewSnippetCollection.GetContentFromName(snippetName!); + } + } + + break; + case Constants.Trees.PartialViewMacros: + codeFileDisplay = + _umbracoMapper.Map(new PartialView(PartialViewType.PartialViewMacro, string.Empty)); + if (codeFileDisplay is not null) + { + codeFileDisplay.VirtualPath = Constants.SystemDirectories.MacroPartials; + if (snippetName.IsNullOrWhiteSpace() == false) + { + codeFileDisplay.Content = _partialViewMacroSnippetCollection.GetContentFromName(snippetName!); + } + } + + break; + case Constants.Trees.Scripts: + codeFileDisplay = _umbracoMapper.Map(new Script(string.Empty)); + if (codeFileDisplay is not null) + { + codeFileDisplay.VirtualPath = _globalSettings.UmbracoScriptsPath; + } + + break; + case Constants.Trees.Stylesheets: + codeFileDisplay = _umbracoMapper.Map(new Stylesheet(string.Empty)); + if (codeFileDisplay is not null) + { + codeFileDisplay.VirtualPath = _globalSettings.UmbracoCssPath; + } + + break; + default: + return new UmbracoProblemResult("Unsupported editortype", HttpStatusCode.BadRequest); + } + + if (codeFileDisplay is null) + { return codeFileDisplay; } - /// - /// Used to delete a specific file from disk via the FileService - /// - /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' or 'stylesheets' - /// The filename or URL encoded path of the file to delete - /// Will return a simple 200 if file deletion succeeds - [HttpDelete] - [HttpPost] - public IActionResult Delete(string type, string virtualPath) + // Make sure that the root virtual path ends with '/' + codeFileDisplay.VirtualPath = codeFileDisplay.VirtualPath?.EnsureEndsWith("/"); + + if (id != Constants.System.RootString) { - if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Value cannot be null or whitespace.", "type"); - if (string.IsNullOrWhiteSpace(virtualPath)) throw new ArgumentException("Value cannot be null or whitespace.", "virtualPath"); - - virtualPath = System.Web.HttpUtility.UrlDecode(virtualPath); - var currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - switch (type) - { - case Constants.Trees.PartialViews: - if (IsDirectory(_hostingEnvironment.MapPathContentRoot(Path.Combine(Constants.SystemDirectories.PartialViews, virtualPath)))) - { - _fileService.DeletePartialViewFolder(virtualPath); - return Ok(); - } - if (_fileService.DeletePartialView(virtualPath, currentUser?.Id)) - { - return Ok(); - } - return new UmbracoProblemResult("No Partial View or folder found with the specified path", HttpStatusCode.NotFound); - - case Constants.Trees.PartialViewMacros: - if (IsDirectory(_hostingEnvironment.MapPathContentRoot(Path.Combine(Constants.SystemDirectories.MacroPartials, virtualPath)))) - { - _fileService.DeletePartialViewMacroFolder(virtualPath); - return Ok(); - } - if (_fileService.DeletePartialViewMacro(virtualPath, currentUser?.Id)) - { - return Ok(); - } - return new UmbracoProblemResult("No Partial View Macro or folder found with the specified path", HttpStatusCode.NotFound); - - case Constants.Trees.Scripts: - if (IsDirectory(_hostingEnvironment.MapPathWebRoot(Path.Combine(_globalSettings.UmbracoScriptsPath, virtualPath)))) - { - _fileService.DeleteScriptFolder(virtualPath); - return Ok(); - } - if (_fileService.GetScript(virtualPath) != null) - { - _fileService.DeleteScript(virtualPath, currentUser?.Id); - return Ok(); - } - return new UmbracoProblemResult("No Script or folder found with the specified path", HttpStatusCode.NotFound); - case Constants.Trees.Stylesheets: - if (IsDirectory(_hostingEnvironment.MapPathWebRoot(Path.Combine(_globalSettings.UmbracoCssPath, virtualPath)))) - { - _fileService.DeleteStyleSheetFolder(virtualPath); - return Ok(); - } - if (_fileService.GetStylesheet(virtualPath) != null) - { - _fileService.DeleteStylesheet(virtualPath, currentUser?.Id); - return Ok(); - } - return new UmbracoProblemResult("No Stylesheet found with the specified path", HttpStatusCode.NotFound); - default: - return NotFound(); - } + codeFileDisplay.VirtualPath += id.TrimStart(Constants.CharArrays.ForwardSlash).EnsureEndsWith("/"); + //if it's not new then it will have a path, otherwise it won't + codeFileDisplay.Path = Url.GetTreePathFromFilePath(id); } - /// - /// Used to create or update a 'partialview', 'partialviewmacro', 'script' or 'stylesheets' file - /// - /// - /// The updated CodeFileDisplay model - public ActionResult PostSave(CodeFileDisplay display) + codeFileDisplay.VirtualPath = codeFileDisplay.VirtualPath?.TrimStart("~"); + codeFileDisplay.FileType = type; + return codeFileDisplay; + } + + /// + /// Used to delete a specific file from disk via the FileService + /// + /// This is a string but will be 'scripts' 'partialViews', 'partialViewMacros' or 'stylesheets' + /// The filename or URL encoded path of the file to delete + /// Will return a simple 200 if file deletion succeeds + [HttpDelete] + [HttpPost] + public IActionResult Delete(string type, string virtualPath) + { + if (string.IsNullOrWhiteSpace(type)) { - if (display == null) throw new ArgumentNullException("display"); - - TryValidateModel(display); - if (ModelState.IsValid == false) - { - return ValidationProblem(ModelState); - } - - switch (display.FileType) - { - case Constants.Trees.PartialViews: - var partialViewResult = CreateOrUpdatePartialView(display); - if (partialViewResult.Success) - { - display = _umbracoMapper.Map(partialViewResult.Result, display); - display.Path = Url.GetTreePathFromFilePath(partialViewResult.Result?.Path); - display.Id = System.Web.HttpUtility.UrlEncode(partialViewResult.Result?.Path); - return display; - } - - display.AddErrorNotification( - _localizedTextService.Localize("speechBubbles", "partialViewErrorHeader"), - _localizedTextService.Localize("speechBubbles", "partialViewErrorText")); - break; - - case Constants.Trees.PartialViewMacros: - var partialViewMacroResult = CreateOrUpdatePartialViewMacro(display); - if (partialViewMacroResult.Success) - { - display = _umbracoMapper.Map(partialViewMacroResult.Result, display); - display.Path = Url.GetTreePathFromFilePath(partialViewMacroResult.Result?.Path); - display.Id = System.Web.HttpUtility.UrlEncode(partialViewMacroResult.Result?.Path); - return display; - } - - display.AddErrorNotification( - _localizedTextService.Localize("speechBubbles", "partialViewErrorHeader"), - _localizedTextService.Localize("speechBubbles", "partialViewErrorText")); - break; - - case Constants.Trees.Scripts: - - var scriptResult = CreateOrUpdateScript(display); - display = _umbracoMapper.Map(scriptResult, display); - display.Path = Url.GetTreePathFromFilePath(scriptResult?.Path); - display.Id = System.Web.HttpUtility.UrlEncode(scriptResult?.Path); - return display; - - //display.AddErrorNotification( - // _localizedTextService.Localize("speechBubbles/partialViewErrorHeader"), - // _localizedTextService.Localize("speechBubbles/partialViewErrorText")); - - case Constants.Trees.Stylesheets: - - var stylesheetResult = CreateOrUpdateStylesheet(display); - display = _umbracoMapper.Map(stylesheetResult, display); - display.Path = Url.GetTreePathFromFilePath(stylesheetResult?.Path); - display.Id = System.Web.HttpUtility.UrlEncode(stylesheetResult?.Path); - return display; - - default: - return NotFound(); - } - - return display; + throw new ArgumentException("Value cannot be null or whitespace.", "type"); } - /// - /// Extracts "umbraco style rules" from a style sheet - /// - /// The style sheet data - /// The style rules - public StylesheetRule[]? PostExtractStylesheetRules(StylesheetData data) + if (string.IsNullOrWhiteSpace(virtualPath)) { - if (data.Content.IsNullOrWhiteSpace()) - { - return new StylesheetRule[0]; - } - - return StylesheetHelper.ParseRules(data.Content)?.Select(rule => new StylesheetRule - { - Name = rule.Name, - Selector = rule.Selector, - Styles = rule.Styles - }).ToArray(); + throw new ArgumentException("Value cannot be null or whitespace.", "virtualPath"); } - /// - /// Creates a style sheet from CSS and style rules - /// - /// The style sheet data - /// The style sheet combined from the CSS and the rules - /// - /// Any "umbraco style rules" in the CSS will be removed and replaced with the rules passed in - /// - public string? PostInterpolateStylesheetRules(StylesheetData data) + virtualPath = HttpUtility.UrlDecode(virtualPath); + IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + switch (type) { - // first remove all existing rules - var existingRules = data.Content.IsNullOrWhiteSpace() - ? new Cms.Core.Strings.Css.StylesheetRule[0] - : StylesheetHelper.ParseRules(data.Content).ToArray(); - foreach (var rule in existingRules) - { - data.Content = StylesheetHelper.ReplaceRule(data.Content, rule.Name, null); - } - - data.Content = data.Content?.TrimEnd(Constants.CharArrays.LineFeedCarriageReturn); - - // now add all the posted rules - if (data.Rules != null && data.Rules.Any()) - { - foreach (var rule in data.Rules) + case Constants.Trees.PartialViews: + if (IsDirectory( + _hostingEnvironment.MapPathContentRoot(Path.Combine(Constants.SystemDirectories.PartialViews, virtualPath)))) { - data.Content = StylesheetHelper.AppendRule(data.Content, new Cms.Core.Strings.Css.StylesheetRule + _fileService.DeletePartialViewFolder(virtualPath); + return Ok(); + } + + if (_fileService.DeletePartialView(virtualPath, currentUser?.Id)) + { + return Ok(); + } + + return new UmbracoProblemResult("No Partial View or folder found with the specified path", HttpStatusCode.NotFound); + + case Constants.Trees.PartialViewMacros: + if (IsDirectory( + _hostingEnvironment.MapPathContentRoot(Path.Combine(Constants.SystemDirectories.MacroPartials, virtualPath)))) + { + _fileService.DeletePartialViewMacroFolder(virtualPath); + return Ok(); + } + + if (_fileService.DeletePartialViewMacro(virtualPath, currentUser?.Id)) + { + return Ok(); + } + + return new UmbracoProblemResult("No Partial View Macro or folder found with the specified path", HttpStatusCode.NotFound); + + case Constants.Trees.Scripts: + if (IsDirectory( + _hostingEnvironment.MapPathWebRoot(Path.Combine(_globalSettings.UmbracoScriptsPath, virtualPath)))) + { + _fileService.DeleteScriptFolder(virtualPath); + return Ok(); + } + + if (_fileService.GetScript(virtualPath) != null) + { + _fileService.DeleteScript(virtualPath, currentUser?.Id); + return Ok(); + } + + return new UmbracoProblemResult("No Script or folder found with the specified path", HttpStatusCode.NotFound); + case Constants.Trees.Stylesheets: + if (IsDirectory( + _hostingEnvironment.MapPathWebRoot(Path.Combine(_globalSettings.UmbracoCssPath, virtualPath)))) + { + _fileService.DeleteStyleSheetFolder(virtualPath); + return Ok(); + } + + if (_fileService.GetStylesheet(virtualPath) != null) + { + _fileService.DeleteStylesheet(virtualPath, currentUser?.Id); + return Ok(); + } + + return new UmbracoProblemResult("No Stylesheet found with the specified path", HttpStatusCode.NotFound); + default: + return NotFound(); + } + } + + /// + /// Used to create or update a 'partialview', 'partialviewmacro', 'script' or 'stylesheets' file + /// + /// + /// The updated CodeFileDisplay model + public ActionResult PostSave(CodeFileDisplay display) + { + if (display == null) + { + throw new ArgumentNullException("display"); + } + + TryValidateModel(display); + if (ModelState.IsValid == false) + { + return ValidationProblem(ModelState); + } + + switch (display.FileType) + { + case Constants.Trees.PartialViews: + Attempt partialViewResult = CreateOrUpdatePartialView(display); + if (partialViewResult.Success) + { + display = _umbracoMapper.Map(partialViewResult.Result, display); + display.Path = Url.GetTreePathFromFilePath(partialViewResult.Result?.Path); + display.Id = HttpUtility.UrlEncode(partialViewResult.Result?.Path); + return display; + } + + display.AddErrorNotification( + _localizedTextService.Localize("speechBubbles", "partialViewErrorHeader"), + _localizedTextService.Localize("speechBubbles", "partialViewErrorText")); + break; + + case Constants.Trees.PartialViewMacros: + Attempt partialViewMacroResult = CreateOrUpdatePartialViewMacro(display); + if (partialViewMacroResult.Success) + { + display = _umbracoMapper.Map(partialViewMacroResult.Result, display); + display.Path = Url.GetTreePathFromFilePath(partialViewMacroResult.Result?.Path); + display.Id = HttpUtility.UrlEncode(partialViewMacroResult.Result?.Path); + return display; + } + + display.AddErrorNotification( + _localizedTextService.Localize("speechBubbles", "partialViewErrorHeader"), + _localizedTextService.Localize("speechBubbles", "partialViewErrorText")); + break; + + case Constants.Trees.Scripts: + + IScript? scriptResult = CreateOrUpdateScript(display); + display = _umbracoMapper.Map(scriptResult, display); + display.Path = Url.GetTreePathFromFilePath(scriptResult?.Path); + display.Id = HttpUtility.UrlEncode(scriptResult?.Path); + return display; + + //display.AddErrorNotification( + // _localizedTextService.Localize("speechBubbles/partialViewErrorHeader"), + // _localizedTextService.Localize("speechBubbles/partialViewErrorText")); + + case Constants.Trees.Stylesheets: + + IStylesheet? stylesheetResult = CreateOrUpdateStylesheet(display); + display = _umbracoMapper.Map(stylesheetResult, display); + display.Path = Url.GetTreePathFromFilePath(stylesheetResult?.Path); + display.Id = HttpUtility.UrlEncode(stylesheetResult?.Path); + return display; + + default: + return NotFound(); + } + + return display; + } + + /// + /// Extracts "umbraco style rules" from a style sheet + /// + /// The style sheet data + /// The style rules + public StylesheetRule[]? PostExtractStylesheetRules(StylesheetData data) + { + if (data.Content.IsNullOrWhiteSpace()) + { + return new StylesheetRule[0]; + } + + return StylesheetHelper.ParseRules(data.Content)?.Select(rule => new StylesheetRule + { + Name = rule.Name, + Selector = rule.Selector, + Styles = rule.Styles + }).ToArray(); + } + + /// + /// Creates a style sheet from CSS and style rules + /// + /// The style sheet data + /// The style sheet combined from the CSS and the rules + /// + /// Any "umbraco style rules" in the CSS will be removed and replaced with the rules passed in + /// + public string? PostInterpolateStylesheetRules(StylesheetData data) + { + // first remove all existing rules + Core.Strings.Css.StylesheetRule[] existingRules = data.Content.IsNullOrWhiteSpace() + ? new Core.Strings.Css.StylesheetRule[0] + : StylesheetHelper.ParseRules(data.Content).ToArray(); + foreach (Core.Strings.Css.StylesheetRule rule in existingRules) + { + data.Content = StylesheetHelper.ReplaceRule(data.Content, rule.Name, null); + } + + data.Content = data.Content?.TrimEnd(Constants.CharArrays.LineFeedCarriageReturn); + + // now add all the posted rules + if (data.Rules != null && data.Rules.Any()) + { + foreach (StylesheetRule rule in data.Rules) + { + data.Content = StylesheetHelper.AppendRule( + data.Content, + new Core.Strings.Css.StylesheetRule { Name = rule.Name, Selector = rule.Selector, Styles = rule.Styles }); - } - - data.Content += Environment.NewLine; } - return data.Content; + data.Content += Environment.NewLine; } - /// - /// Create or Update a Script - /// - /// - /// - /// - /// It's important to note that Scripts are DIFFERENT from cshtml files since scripts use IFileSystem and cshtml files - /// use a normal file system because they must exist on a real file system for ASP.NET to work. - /// - private IScript? CreateOrUpdateScript(CodeFileDisplay display) + return data.Content; + } + + /// + /// Create or Update a Script + /// + /// + /// + /// + /// It's important to note that Scripts are DIFFERENT from cshtml files since scripts use IFileSystem and cshtml files + /// use a normal file system because they must exist on a real file system for ASP.NET to work. + /// + private IScript? CreateOrUpdateScript(CodeFileDisplay display) => + CreateOrUpdateFile( + display, + ".js", + _fileSystems.ScriptsFileSystem, + name => _fileService.GetScript(name), + (script, userId) => _fileService.SaveScript(script, userId), + name => new Script(name ?? string.Empty)); + + private IStylesheet? CreateOrUpdateStylesheet(CodeFileDisplay display) => + CreateOrUpdateFile( + display, + ".css", + _fileSystems.StylesheetsFileSystem, + name => _fileService.GetStylesheet(name), + (stylesheet, userId) => _fileService.SaveStylesheet(stylesheet, userId), + name => new Stylesheet(name ?? string.Empty)); + + private T CreateOrUpdateFile(CodeFileDisplay display, string extension, IFileSystem? fileSystem, Func getFileByName, Action saveFile, Func createFile) + where T : IFile? + { + //must always end with the correct extension + display.Name = EnsureCorrectFileExtension(display.Name, extension); + + var virtualPath = display.VirtualPath ?? string.Empty; + // this is all weird, should be using relative paths everywhere! + var relPath = fileSystem?.GetRelativePath(virtualPath); + + if (relPath?.EndsWith(extension) == false) { - return CreateOrUpdateFile(display, ".js", _fileSystems.ScriptsFileSystem, - name => _fileService.GetScript(name), - (script, userId) => _fileService.SaveScript(script, userId), - name => new Script(name ?? string.Empty)); + //this would typically mean it's new + relPath = relPath.IsNullOrWhiteSpace() + ? relPath + display.Name + : relPath.EnsureEndsWith('/') + display.Name; } - private IStylesheet? CreateOrUpdateStylesheet(CodeFileDisplay display) + IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + T file = getFileByName(relPath); + if (file != null) { - return CreateOrUpdateFile(display, ".css", _fileSystems.StylesheetsFileSystem, - name => _fileService.GetStylesheet(name), - (stylesheet, userId) => _fileService.SaveStylesheet(stylesheet, userId), - name => new Stylesheet(name ?? string.Empty) - ); + // might need to find the path + var orgPath = file.Name is null + ? string.Empty + : file.OriginalPath.Substring(0, file.OriginalPath.IndexOf(file.Name)); + file.Path = orgPath + display.Name; + + file.Content = display.Content; + //try/catch? since this doesn't return an Attempt? + saveFile(file, currentUser?.Id); } - - private T CreateOrUpdateFile(CodeFileDisplay display, string extension, IFileSystem? fileSystem, - Func getFileByName, Action saveFile, Func createFile) where T : IFile? + else { - //must always end with the correct extension - display.Name = EnsureCorrectFileExtension(display.Name, extension); - - var virtualPath = display.VirtualPath ?? string.Empty; - // this is all weird, should be using relative paths everywhere! - var relPath = fileSystem?.GetRelativePath(virtualPath); - - if (relPath?.EndsWith(extension) == false) + file = createFile(relPath); + if (file is not null) { - //this would typically mean it's new - relPath = relPath.IsNullOrWhiteSpace() - ? relPath + display.Name - : relPath.EnsureEndsWith('/') + display.Name; - } - var currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - var file = getFileByName(relPath); - if (file != null) - { - // might need to find the path - var orgPath = file.Name is null ? string.Empty : file.OriginalPath.Substring(0, file.OriginalPath.IndexOf(file.Name)); - file.Path = orgPath + display.Name; - file.Content = display.Content; - //try/catch? since this doesn't return an Attempt? - saveFile(file, currentUser?.Id); } - else + + saveFile(file, currentUser?.Id); + } + + return file; + } + + private Attempt CreateOrUpdatePartialView(CodeFileDisplay display) => + CreateOrUpdatePartialView( + display, + Constants.SystemDirectories.PartialViews, + _fileService.GetPartialView, + _fileService.SavePartialView, + _fileService.CreatePartialView); + + private Attempt CreateOrUpdatePartialViewMacro(CodeFileDisplay display) => + CreateOrUpdatePartialView(display, Constants.SystemDirectories.MacroPartials, _fileService.GetPartialViewMacro, _fileService.SavePartialViewMacro, _fileService.CreatePartialViewMacro); + + /// + /// Helper method to take care of persisting partial views or partial view macros - so we're not duplicating the same + /// logic + /// + /// + /// + /// + /// + /// + /// + private Attempt CreateOrUpdatePartialView( + CodeFileDisplay display, + string systemDirectory, + Func getView, + Func> saveView, + Func> createView) + { + //must always end with the correct extension + display.Name = EnsureCorrectFileExtension(display.Name, ".cshtml"); + + Attempt partialViewResult; + IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + + var virtualPath = NormalizeVirtualPath(display.VirtualPath, systemDirectory); + IPartialView? view = getView(virtualPath); + if (view != null) + { + // might need to find the path + var orgPath = view.OriginalPath.Substring(0, view.OriginalPath.IndexOf(view.Name ?? string.Empty)); + view.Path = orgPath + display.Name; + + view.Content = display.Content; + partialViewResult = saveView(view, currentUser?.Id); + } + else + { + view = new PartialView(PartialViewType.PartialView, virtualPath + display.Name) { - file = createFile(relPath); - if (file is not null) - { - file.Content = display.Content; - } - - saveFile(file, currentUser?.Id); - } - - return file; + Content = display.Content + }; + partialViewResult = createView(view, display.Snippet, currentUser?.Id); } - private Attempt CreateOrUpdatePartialView(CodeFileDisplay display) + return partialViewResult; + } + + private string NormalizeVirtualPath(string? virtualPath, string systemDirectory) + { + if (virtualPath.IsNullOrWhiteSpace()) { - return CreateOrUpdatePartialView(display, Constants.SystemDirectories.PartialViews, - _fileService.GetPartialView, _fileService.SavePartialView, _fileService.CreatePartialView); + return string.Empty; } - private Attempt CreateOrUpdatePartialViewMacro(CodeFileDisplay display) + systemDirectory = systemDirectory.TrimStart("~"); + systemDirectory = systemDirectory.Replace('\\', '/'); + virtualPath = virtualPath!.TrimStart("~"); + virtualPath = virtualPath.Replace('\\', '/'); + virtualPath = virtualPath.ReplaceFirst(systemDirectory, string.Empty); + + return virtualPath; + } + + private string? EnsureCorrectFileExtension(string? value, string extension) + { + if (value?.EndsWith(extension) == false) { - return CreateOrUpdatePartialView(display, Constants.SystemDirectories.MacroPartials, - _fileService.GetPartialViewMacro, _fileService.SavePartialViewMacro, _fileService.CreatePartialViewMacro); + value += extension; } - /// - /// Helper method to take care of persisting partial views or partial view macros - so we're not duplicating the same logic - /// - /// - /// - /// - /// - /// - /// - private Attempt CreateOrUpdatePartialView( - CodeFileDisplay display, string systemDirectory, - Func getView, - Func> saveView, - Func> createView) - { - //must always end with the correct extension - display.Name = EnsureCorrectFileExtension(display.Name, ".cshtml"); + return value; + } - Attempt partialViewResult; - var currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + private bool IsDirectory(string path) + { + var dirInfo = new DirectoryInfo(path); - var virtualPath = NormalizeVirtualPath(display.VirtualPath, systemDirectory); - var view = getView(virtualPath); - if (view != null) - { - // might need to find the path - var orgPath = view.OriginalPath.Substring(0, view.OriginalPath.IndexOf(view.Name ?? string.Empty)); - view.Path = orgPath + display.Name; + // If you turn off indexing in Windows this will have the attribute: + // `FileAttributes.Directory | FileAttributes.NotContentIndexed` + return (dirInfo.Attributes & FileAttributes.Directory) != 0; + } - view.Content = display.Content; - partialViewResult = saveView(view, currentUser?.Id); - } - else - { - view = new PartialView(PartialViewType.PartialView, virtualPath + display.Name); - view.Content = display.Content; - partialViewResult = createView(view, display.Snippet, currentUser?.Id); - } + // this is an internal class for passing stylesheet data from the client to the controller while editing + public class StylesheetData + { + public string? Content { get; set; } - return partialViewResult; - } - - private string NormalizeVirtualPath(string? virtualPath, string systemDirectory) - { - if (virtualPath.IsNullOrWhiteSpace()) - return string.Empty; - - systemDirectory = systemDirectory.TrimStart("~"); - systemDirectory = systemDirectory.Replace('\\', '/'); - virtualPath = virtualPath!.TrimStart("~"); - virtualPath = virtualPath.Replace('\\', '/'); - virtualPath = virtualPath.ReplaceFirst(systemDirectory, string.Empty); - - return virtualPath; - } - - private string? EnsureCorrectFileExtension(string? value, string extension) - { - if (value?.EndsWith(extension) == false) - value += extension; - - return value; - } - - private bool IsDirectory(string path) - { - var dirInfo = new DirectoryInfo(path); - - // If you turn off indexing in Windows this will have the attribute: - // `FileAttributes.Directory | FileAttributes.NotContentIndexed` - return (dirInfo.Attributes & FileAttributes.Directory) != 0; - } - - // this is an internal class for passing stylesheet data from the client to the controller while editing - public class StylesheetData - { - public string? Content { get; set; } - - public StylesheetRule[]? Rules { get; set; } - } + public StylesheetRule[]? Rules { get; set; } } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index e1374ea56d..5f8ba74d76 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net.Mime; using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -17,6 +12,7 @@ using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.Validation; using Umbraco.Cms.Core.Persistence.Querying; @@ -35,2714 +31,2937 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// The API controller used for editing content +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +[ParameterSwapControllerActionSelector(nameof(GetNiceUrl), "id", typeof(int), typeof(Guid), typeof(Udi))] +public class ContentController : ContentControllerBase { - /// - /// The API controller used for editing content - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - [ParameterSwapControllerActionSelector(nameof(GetNiceUrl), "id", typeof(int), typeof(Guid), typeof(Udi))] - public class ContentController : ContentControllerBase + private readonly ActionCollection _actionCollection; + private readonly Lazy> _allLangs; + private readonly IAuthorizationService _authorizationService; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IContentService _contentService; + private readonly IContentTypeService _contentTypeService; + private readonly IContentVersionService _contentVersionService; + private readonly IDataTypeService _dataTypeService; + private readonly IDomainService _domainService; + private readonly IFileService _fileService; + private readonly ILocalizationService _localizationService; + private readonly ILocalizedTextService _localizedTextService; + private readonly ILogger _logger; + private readonly INotificationService _notificationService; + private readonly PropertyEditorCollection _propertyEditors; + private readonly IPublishedUrlProvider _publishedUrlProvider; + private readonly ICoreScopeProvider _scopeProvider; + private readonly ISqlContext _sqlContext; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IUserService _userService; + + public ContentController( + ICultureDictionary cultureDictionary, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper, + IEventMessagesFactory eventMessages, + ILocalizedTextService localizedTextService, + PropertyEditorCollection propertyEditors, + IContentService contentService, + IUserService userService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IContentTypeService contentTypeService, + IUmbracoMapper umbracoMapper, + IPublishedUrlProvider publishedUrlProvider, + IDomainService domainService, + IDataTypeService dataTypeService, + ILocalizationService localizationService, + IFileService fileService, + INotificationService notificationService, + ActionCollection actionCollection, + ISqlContext sqlContext, + IJsonSerializer serializer, + ICoreScopeProvider scopeProvider, + IAuthorizationService authorizationService, + IContentVersionService contentVersionService) + : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, serializer) { - private readonly PropertyEditorCollection _propertyEditors; - private readonly IContentService _contentService; - private readonly ILocalizedTextService _localizedTextService; - private readonly IUserService _userService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IContentTypeService _contentTypeService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IPublishedUrlProvider _publishedUrlProvider; - private readonly IDomainService _domainService; - private readonly IDataTypeService _dataTypeService; - private readonly ILocalizationService _localizationService; - private readonly IFileService _fileService; - private readonly INotificationService _notificationService; - private readonly ActionCollection _actionCollection; - private readonly ISqlContext _sqlContext; - private readonly IAuthorizationService _authorizationService; - private readonly IContentVersionService _contentVersionService; - private readonly Lazy> _allLangs; - private readonly ILogger _logger; - private readonly ICoreScopeProvider _scopeProvider; + _propertyEditors = propertyEditors; + _contentService = contentService; + _localizedTextService = localizedTextService; + _userService = userService; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _contentTypeService = contentTypeService; + _umbracoMapper = umbracoMapper; + _publishedUrlProvider = publishedUrlProvider; + _domainService = domainService; + _dataTypeService = dataTypeService; + _localizationService = localizationService; + _fileService = fileService; + _notificationService = notificationService; + _actionCollection = actionCollection; + _sqlContext = sqlContext; + _authorizationService = authorizationService; + _contentVersionService = contentVersionService; + _logger = loggerFactory.CreateLogger(); + _scopeProvider = scopeProvider; + _allLangs = new Lazy>(() => + _localizationService.GetAllLanguages() + .ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase)); + } - public object? Domains { get; private set; } + public object? Domains { get; private set; } - public ContentController( - ICultureDictionary cultureDictionary, - ILoggerFactory loggerFactory, - IShortStringHelper shortStringHelper, - IEventMessagesFactory eventMessages, - ILocalizedTextService localizedTextService, - PropertyEditorCollection propertyEditors, - IContentService contentService, - IUserService userService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IContentTypeService contentTypeService, - IUmbracoMapper umbracoMapper, - IPublishedUrlProvider publishedUrlProvider, - IDomainService domainService, - IDataTypeService dataTypeService, - ILocalizationService localizationService, - IFileService fileService, - INotificationService notificationService, - ActionCollection actionCollection, - ISqlContext sqlContext, - IJsonSerializer serializer, - ICoreScopeProvider scopeProvider, - IAuthorizationService authorizationService, - IContentVersionService contentVersionService) - : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, serializer) + /// + /// Return content for the specified ids + /// + /// + /// + [FilterAllowedOutgoingContent(typeof(IEnumerable))] + public IEnumerable GetByIds([FromQuery] int[] ids) + { + IEnumerable foundContent = _contentService.GetByIds(ids); + return foundContent.Select(MapToDisplay).WhereNotNull(); + } + + /// + /// Updates the permissions for a content item for a particular user group + /// + /// + /// + /// + /// Permission check is done for letter 'R' which is for which the user must have access to + /// update + /// + public async Task?>> PostSaveUserGroupPermissions( + UserGroupPermissionsSave saveModel) + { + if (saveModel.ContentId <= 0) { - _propertyEditors = propertyEditors; - _contentService = contentService; - _localizedTextService = localizedTextService; - _userService = userService; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _contentTypeService = contentTypeService; - _umbracoMapper = umbracoMapper; - _publishedUrlProvider = publishedUrlProvider; - _domainService = domainService; - _dataTypeService = dataTypeService; - _localizationService = localizationService; - _fileService = fileService; - _notificationService = notificationService; - _actionCollection = actionCollection; - _sqlContext = sqlContext; - _authorizationService = authorizationService; - _contentVersionService = contentVersionService; - _logger = loggerFactory.CreateLogger(); - _scopeProvider = scopeProvider; - _allLangs = new Lazy>(() => _localizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase)); - } - - /// - /// Return content for the specified ids - /// - /// - /// - [FilterAllowedOutgoingContent(typeof(IEnumerable))] - public IEnumerable GetByIds([FromQuery] int[] ids) - { - var foundContent = _contentService.GetByIds(ids); - return foundContent.Select(MapToDisplay).WhereNotNull(); - } - - /// - /// Updates the permissions for a content item for a particular user group - /// - /// - /// - /// - /// Permission check is done for letter 'R' which is for which the user must have access to update - /// - public async Task?>> PostSaveUserGroupPermissions(UserGroupPermissionsSave saveModel) - { - if (saveModel.ContentId <= 0) return NotFound(); - - // TODO: Should non-admins be allowed to set granular permissions? - - var content = _contentService.GetById(saveModel.ContentId); - if (content == null) return NotFound(); - - // Authorize... - var resource = new ContentPermissionsResource(content, ActionRights.ActionLetter); - var authorizationResult = await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); - if (!authorizationResult.Succeeded) - { - return Forbid(); - } - - //current permissions explicitly assigned to this content item - var contentPermissions = _contentService.GetPermissions(content) - .ToDictionary(x => x.UserGroupId, x => x); - - var allUserGroups = _userService.GetAllUserGroups().ToArray(); - - //loop through each user group - foreach (var userGroup in allUserGroups) - { - //check if there's a permission set posted up for this user group - IEnumerable? groupPermissions; - if (saveModel.AssignedPermissions.TryGetValue(userGroup.Id, out groupPermissions)) - { - if (groupPermissions is null) - { - _userService.RemoveUserGroupPermissions(userGroup.Id, content.Id); - continue; - } - - // Create a string collection of the assigned letters - var groupPermissionCodes = groupPermissions.ToArray(); - // Check if they are the defaults, if so we should just remove them if they exist since it's more overhead having them stored - if (contentPermissions.ContainsKey(userGroup.Id) == false || contentPermissions[userGroup.Id].AssignedPermissions.UnsortedSequenceEqual(groupPermissionCodes) == false) - { - - _userService.ReplaceUserGroupPermissions(userGroup.Id, groupPermissionCodes.Select(x => x[0]), content.Id); - } - } - } - - return GetDetailedPermissions(content, allUserGroups); - } - - /// - /// Returns the user group permissions for user groups assigned to this node - /// - /// - /// - /// - /// Permission check is done for letter 'R' which is for which the user must have access to view - /// - [Authorize(Policy = AuthorizationPolicies.ContentPermissionAdministrationById)] - public ActionResult?> GetDetailedPermissions(int contentId) - { - if (contentId <= 0) return NotFound(); - var content = _contentService.GetById(contentId); - if (content == null) return NotFound(); - - // TODO: Should non-admins be able to see detailed permissions? - - var allUserGroups = _userService.GetAllUserGroups(); - - return GetDetailedPermissions(content, allUserGroups); - } - - private ActionResult?> GetDetailedPermissions(IContent content, IEnumerable allUserGroups) - { - //get all user groups and map their default permissions to the AssignedUserGroupPermissions model. - //we do this because not all groups will have true assigned permissions for this node so if they don't have assigned permissions, we need to show the defaults. - - var defaultPermissionsByGroup = _umbracoMapper.MapEnumerable(allUserGroups); - - var defaultPermissionsAsDictionary = defaultPermissionsByGroup.WhereNotNull() - .ToDictionary(x => Convert.ToInt32(x.Id), x => x); - - //get the actual assigned permissions - var assignedPermissionsByGroup = _contentService.GetPermissions(content).ToArray(); - - //iterate over assigned and update the defaults with the real values - foreach (var assignedGroupPermission in assignedPermissionsByGroup) - { - var defaultUserGroupPermissions = defaultPermissionsAsDictionary[assignedGroupPermission.UserGroupId]; - - //clone the default permissions model to the assigned ones - defaultUserGroupPermissions.AssignedPermissions = AssignedUserGroupPermissions.ClonePermissions(defaultUserGroupPermissions.DefaultPermissions); - - //since there is custom permissions assigned to this node for this group, we need to clear all of the default permissions - //and we'll re-check it if it's one of the explicitly assigned ones - foreach (var permission in defaultUserGroupPermissions.AssignedPermissions.SelectMany(x => x.Value)) - { - permission.Checked = false; - permission.Checked = assignedGroupPermission.AssignedPermissions.Contains(permission.PermissionCode, StringComparer.InvariantCulture); - } - - } - - return defaultPermissionsByGroup; - } - - /// - /// Returns an item to be used to display the recycle bin for content - /// - /// - public ActionResult GetRecycleBin() - { - var apps = new List(); - apps.Add(ListViewContentAppFactory.CreateContentApp(_dataTypeService, _propertyEditors, "recycleBin", "content", Constants.DataTypes.DefaultMembersListView)); - apps[0].Active = true; - var display = new ContentItemDisplay - { - Id = Constants.System.RecycleBinContent, - ParentId = -1, - ContentTypeAlias = "recycleBin", - IsContainer = true, - Path = "-1," + Constants.System.RecycleBinContent, - Variants = new List - { - new ContentVariantDisplay - { - CreateDate = DateTime.Now, - Name = _localizedTextService.Localize("general","recycleBin") - } - }, - ContentApps = apps - }; - - return display; - } - - public ActionResult GetBlueprintById(int id) - { - var foundContent = _contentService.GetBlueprintById(id); - if (foundContent == null) - { - return HandleContentNotFound(id); - } - - var content = MapToDisplay(foundContent); - - if (content is not null) - { - SetupBlueprint(content, foundContent); - } - - return content; - } - - private static void SetupBlueprint(ContentItemDisplay content, IContent? persistedContent) - { - content.AllowPreview = false; - - //set a custom path since the tree that renders this has the content type id as the parent - content.Path = string.Format("-1,{0},{1}", persistedContent?.ContentTypeId, content.Id); - - content.AllowedActions = new[] { "A" }; - content.IsBlueprint = true; - - // TODO: exclude the content apps here - //var excludeProps = new[] { "_umb_urls", "_umb_releasedate", "_umb_expiredate", "_umb_template" }; - //var propsTab = content.Tabs.Last(); - //propsTab.Properties = propsTab.Properties - // .Where(p => excludeProps.Contains(p.Alias) == false); - } - - /// - /// Gets the content json for the content id - /// - /// - /// - /// - [OutgoingEditorModelEvent] - [Authorize(Policy = AuthorizationPolicies.ContentPermissionBrowseById)] - public ActionResult GetById(int id) - { - var foundContent = GetObjectFromRequest(() => _contentService.GetById(id)); - if (foundContent == null) - { - return HandleContentNotFound(id); - } - - return MapToDisplayWithSchedule(foundContent); - } - - /// - /// Gets the content json for the content guid - /// - /// - /// - [OutgoingEditorModelEvent] - [Authorize(Policy = AuthorizationPolicies.ContentPermissionBrowseById)] - public ActionResult GetById(Guid id) - { - var foundContent = GetObjectFromRequest(() => _contentService.GetById(id)); - if (foundContent == null) - { - return HandleContentNotFound(id); - } - return MapToDisplayWithSchedule(foundContent); - } - - /// - /// Gets the content json for the content udi - /// - /// - /// - [OutgoingEditorModelEvent] - [Authorize(Policy = AuthorizationPolicies.ContentPermissionBrowseById)] - public ActionResult GetById(Udi id) - { - var guidUdi = id as GuidUdi; - if (guidUdi != null) - { - return GetById(guidUdi.Guid); - } - return NotFound(); } - /// - /// Gets an empty content item for the document type. - /// - /// - /// - [OutgoingEditorModelEvent] - public ActionResult GetEmpty(string contentTypeAlias, int parentId) + // TODO: Should non-admins be allowed to set granular permissions? + + IContent? content = _contentService.GetById(saveModel.ContentId); + if (content == null) { - var contentType = _contentTypeService.Get(contentTypeAlias); + return NotFound(); + } + + // Authorize... + var resource = new ContentPermissionsResource(content, ActionRights.ActionLetter); + AuthorizationResult authorizationResult = + await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); + if (!authorizationResult.Succeeded) + { + return Forbid(); + } + + //current permissions explicitly assigned to this content item + var contentPermissions = _contentService.GetPermissions(content) + .ToDictionary(x => x.UserGroupId, x => x); + + IUserGroup[] allUserGroups = _userService.GetAllUserGroups().ToArray(); + + //loop through each user group + foreach (IUserGroup userGroup in allUserGroups) + { + //check if there's a permission set posted up for this user group + if (saveModel.AssignedPermissions.TryGetValue(userGroup.Id, out IEnumerable? groupPermissions)) + { + if (groupPermissions is null) + { + _userService.RemoveUserGroupPermissions(userGroup.Id, content.Id); + continue; + } + + // Create a string collection of the assigned letters + var groupPermissionCodes = groupPermissions.ToArray(); + // Check if they are the defaults, if so we should just remove them if they exist since it's more overhead having them stored + if (contentPermissions.ContainsKey(userGroup.Id) == false || contentPermissions[userGroup.Id] + .AssignedPermissions.UnsortedSequenceEqual(groupPermissionCodes) == false) + { + _userService.ReplaceUserGroupPermissions(userGroup.Id, groupPermissionCodes.Select(x => x[0]), content.Id); + } + } + } + + return GetDetailedPermissions(content, allUserGroups); + } + + /// + /// Returns the user group permissions for user groups assigned to this node + /// + /// + /// + /// + /// Permission check is done for letter 'R' which is for which the user must have access to + /// view + /// + [Authorize(Policy = AuthorizationPolicies.ContentPermissionAdministrationById)] + public ActionResult?> GetDetailedPermissions(int contentId) + { + if (contentId <= 0) + { + return NotFound(); + } + + IContent? content = _contentService.GetById(contentId); + if (content == null) + { + return NotFound(); + } + + // TODO: Should non-admins be able to see detailed permissions? + + IEnumerable allUserGroups = _userService.GetAllUserGroups(); + + return GetDetailedPermissions(content, allUserGroups); + } + + private ActionResult?> GetDetailedPermissions( + IContent content, + IEnumerable allUserGroups) + { + //get all user groups and map their default permissions to the AssignedUserGroupPermissions model. + //we do this because not all groups will have true assigned permissions for this node so if they don't have assigned permissions, we need to show the defaults. + + List defaultPermissionsByGroup = + _umbracoMapper.MapEnumerable(allUserGroups); + + var defaultPermissionsAsDictionary = defaultPermissionsByGroup.WhereNotNull() + .ToDictionary(x => Convert.ToInt32(x.Id), x => x); + + //get the actual assigned permissions + EntityPermission[] assignedPermissionsByGroup = _contentService.GetPermissions(content).ToArray(); + + //iterate over assigned and update the defaults with the real values + foreach (EntityPermission assignedGroupPermission in assignedPermissionsByGroup) + { + AssignedUserGroupPermissions defaultUserGroupPermissions = + defaultPermissionsAsDictionary[assignedGroupPermission.UserGroupId]; + + //clone the default permissions model to the assigned ones + defaultUserGroupPermissions.AssignedPermissions = + AssignedUserGroupPermissions.ClonePermissions(defaultUserGroupPermissions.DefaultPermissions); + + //since there is custom permissions assigned to this node for this group, we need to clear all of the default permissions + //and we'll re-check it if it's one of the explicitly assigned ones + foreach (Permission permission in defaultUserGroupPermissions.AssignedPermissions.SelectMany(x => x.Value)) + { + permission.Checked = false; + permission.Checked = + assignedGroupPermission.AssignedPermissions.Contains( + permission.PermissionCode, + StringComparer.InvariantCulture); + } + } + + return defaultPermissionsByGroup; + } + + /// + /// Returns an item to be used to display the recycle bin for content + /// + /// + public ActionResult GetRecycleBin() + { + var apps = new List + { + ListViewContentAppFactory.CreateContentApp(_dataTypeService, _propertyEditors, "recycleBin", "content", Constants.DataTypes.DefaultMembersListView) + }; + apps[0].Active = true; + var display = new ContentItemDisplay + { + Id = Constants.System.RecycleBinContent, + ParentId = -1, + ContentTypeAlias = "recycleBin", + IsContainer = true, + Path = "-1," + Constants.System.RecycleBinContent, + Variants = new List + { + new() + { + CreateDate = DateTime.Now, + Name = _localizedTextService.Localize("general", "recycleBin") + } + }, + ContentApps = apps + }; + + return display; + } + + public ActionResult GetBlueprintById(int id) + { + IContent? foundContent = _contentService.GetBlueprintById(id); + if (foundContent == null) + { + return HandleContentNotFound(id); + } + + ContentItemDisplay? content = MapToDisplay(foundContent); + + if (content is not null) + { + SetupBlueprint(content, foundContent); + } + + return content; + } + + private static void SetupBlueprint(ContentItemDisplay content, IContent? persistedContent) + { + content.AllowPreview = false; + + //set a custom path since the tree that renders this has the content type id as the parent + content.Path = string.Format("-1,{0},{1}", persistedContent?.ContentTypeId, content.Id); + + content.AllowedActions = new[] { "A" }; + content.IsBlueprint = true; + + // TODO: exclude the content apps here + //var excludeProps = new[] { "_umb_urls", "_umb_releasedate", "_umb_expiredate", "_umb_template" }; + //var propsTab = content.Tabs.Last(); + //propsTab.Properties = propsTab.Properties + // .Where(p => excludeProps.Contains(p.Alias) == false); + } + + /// + /// Gets the content json for the content id + /// + /// + /// + [OutgoingEditorModelEvent] + [Authorize(Policy = AuthorizationPolicies.ContentPermissionBrowseById)] + public ActionResult GetById(int id) + { + IContent? foundContent = GetObjectFromRequest(() => _contentService.GetById(id)); + if (foundContent == null) + { + return HandleContentNotFound(id); + } + + return MapToDisplayWithSchedule(foundContent); + } + + /// + /// Gets the content json for the content guid + /// + /// + /// + [OutgoingEditorModelEvent] + [Authorize(Policy = AuthorizationPolicies.ContentPermissionBrowseById)] + public ActionResult GetById(Guid id) + { + IContent? foundContent = GetObjectFromRequest(() => _contentService.GetById(id)); + if (foundContent == null) + { + return HandleContentNotFound(id); + } + + return MapToDisplayWithSchedule(foundContent); + } + + /// + /// Gets the content json for the content udi + /// + /// + /// + [OutgoingEditorModelEvent] + [Authorize(Policy = AuthorizationPolicies.ContentPermissionBrowseById)] + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi != null) + { + return GetById(guidUdi.Guid); + } + + return NotFound(); + } + + /// + /// Gets an empty content item for the document type. + /// + /// + /// + [OutgoingEditorModelEvent] + public ActionResult GetEmpty(string contentTypeAlias, int parentId) + { + IContentType? contentType = _contentTypeService.Get(contentTypeAlias); + if (contentType == null) + { + return NotFound(); + } + + return GetEmptyInner(contentType, parentId); + } + + /// + /// Gets a dictionary containing empty content items for every alias specified in the contentTypeAliases array in the + /// body of the request. + /// + /// + /// This is a post request in order to support a large amount of aliases without hitting the URL length limit. + /// + /// + /// + [OutgoingEditorModelEvent] + [HttpPost] + public ActionResult> GetEmptyByAliases( + ContentTypesByAliases contentTypesByAliases) + { + // It's important to do this operation within a scope to reduce the amount of readlock queries. + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + IEnumerable? contentTypes = contentTypesByAliases.ContentTypeAliases + ?.Select(alias => _contentTypeService.Get(alias)).WhereNotNull(); + return GetEmpties(contentTypes, contentTypesByAliases.ParentId).ToDictionary(x => x.ContentTypeAlias); + } + + /// + /// Gets an empty content item for the document type. + /// + /// + /// + [OutgoingEditorModelEvent] + public ActionResult GetEmptyByKey(Guid contentTypeKey, int parentId) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + IContentType? contentType = _contentTypeService.Get(contentTypeKey); if (contentType == null) { return NotFound(); } - return GetEmptyInner(contentType, parentId); + ContentItemDisplay? contentItem = GetEmptyInner(contentType, parentId); + scope.Complete(); + + return contentItem; + } + } + + private ContentItemDisplay? GetEmptyInner(IContentType contentType, int parentId) + { + IContent emptyContent = _contentService.Create(string.Empty, parentId, contentType.Alias, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + ContentItemDisplay? mapped = MapToDisplay(emptyContent); + + if (mapped is null) + { + return null; } - /// - /// Gets a dictionary containing empty content items for every alias specified in the contentTypeAliases array in the body of the request. - /// - /// - /// This is a post request in order to support a large amount of aliases without hitting the URL length limit. - /// - /// - /// - [OutgoingEditorModelEvent] - [HttpPost] - public ActionResult> GetEmptyByAliases(ContentTypesByAliases contentTypesByAliases) + return CleanContentItemDisplay(mapped); + } + + private ContentItemDisplay CleanContentItemDisplay(ContentItemDisplay display) + { + // translate the content type name if applicable + display.ContentTypeName = + _localizedTextService.UmbracoDictionaryTranslate(CultureDictionary, display.ContentTypeName); + // if your user type doesn't have access to the Settings section it would not get this property mapped + if (display.DocumentType != null) { - // It's important to do this operation within a scope to reduce the amount of readlock queries. - using var scope = _scopeProvider.CreateCoreScope(autoComplete: true); - var contentTypes = contentTypesByAliases.ContentTypeAliases?.Select(alias => _contentTypeService.Get(alias)).WhereNotNull(); - return GetEmpties(contentTypes, contentTypesByAliases.ParentId).ToDictionary(x => x.ContentTypeAlias); + display.DocumentType.Name = + _localizedTextService.UmbracoDictionaryTranslate(CultureDictionary, display.DocumentType.Name); } - /// - /// Gets an empty content item for the document type. - /// - /// - /// - [OutgoingEditorModelEvent] - public ActionResult GetEmptyByKey(Guid contentTypeKey, int parentId) + //remove the listview app if it exists + display.ContentApps = display.ContentApps.Where(x => x.Alias != "umbListView").ToList(); + + return display; + } + + /// + /// Gets an empty for each content type in the IEnumerable, all with the same parent + /// ID + /// + /// Will attempt to re-use the same permissions for every content as long as the path and user are the same + /// + /// + /// + private IEnumerable GetEmpties(IEnumerable? contentTypes, int parentId) + { + var result = new List(); + IBackOfficeSecurity? backOfficeSecurity = _backofficeSecurityAccessor.BackOfficeSecurity; + + var userId = backOfficeSecurity?.GetUserId().Result ?? -1; + IUser? currentUser = backOfficeSecurity?.CurrentUser; + // We know that if the ID is less than 0 the parent is null. + // Since this is called with parent ID it's safe to assume that the parent is the same for all the content types. + IContent? parent = parentId > 0 ? _contentService.GetById(parentId) : null; + // Since the parent is the same and the path used to get permissions is based on the parent we only have to do it once + var path = parent == null ? "-1" : parent.Path; + var permissions = new Dictionary { - using (var scope = _scopeProvider.CreateCoreScope()) + [path] = _userService.GetPermissionsForPath(currentUser, path) + }; + + if (contentTypes is not null) + { + foreach (IContentType contentType in contentTypes) { - var contentType = _contentTypeService.Get(contentTypeKey); - if (contentType == null) + IContent emptyContent = _contentService.Create(string.Empty, parentId, contentType, userId); + + ContentItemDisplay? mapped = MapToDisplay(emptyContent, context => { - return NotFound(); - } - - var contentItem = GetEmptyInner(contentType, parentId); - scope.Complete(); - - return contentItem; - } - } - - private ContentItemDisplay? GetEmptyInner(IContentType contentType, int parentId) - { - var emptyContent = _contentService.Create("", parentId, contentType.Alias, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - var mapped = MapToDisplay(emptyContent); - - if (mapped is null) - { - return null; - } - return CleanContentItemDisplay(mapped); - } - - private ContentItemDisplay CleanContentItemDisplay(ContentItemDisplay display) - { - // translate the content type name if applicable - display.ContentTypeName = _localizedTextService.UmbracoDictionaryTranslate(CultureDictionary, display.ContentTypeName); - // if your user type doesn't have access to the Settings section it would not get this property mapped - if (display.DocumentType != null) - display.DocumentType.Name = _localizedTextService.UmbracoDictionaryTranslate(CultureDictionary, display.DocumentType.Name); - - //remove the listview app if it exists - display.ContentApps = display.ContentApps.Where(x => x.Alias != "umbListView").ToList(); - - return display; - } - - /// - /// Gets an empty for each content type in the IEnumerable, all with the same parent ID - /// - /// Will attempt to re-use the same permissions for every content as long as the path and user are the same - /// - /// - /// - private IEnumerable GetEmpties(IEnumerable? contentTypes, int parentId) - { - var result = new List(); - var backOfficeSecurity = _backofficeSecurityAccessor.BackOfficeSecurity; - - var userId = backOfficeSecurity?.GetUserId().Result ?? -1; - var currentUser = backOfficeSecurity?.CurrentUser; - // We know that if the ID is less than 0 the parent is null. - // Since this is called with parent ID it's safe to assume that the parent is the same for all the content types. - var parent = parentId > 0 ? _contentService.GetById(parentId) : null; - // Since the parent is the same and the path used to get permissions is based on the parent we only have to do it once - var path = parent == null ? "-1" : parent.Path; - var permissions = new Dictionary - { - [path] = _userService.GetPermissionsForPath(currentUser, path) - }; - - if (contentTypes is not null) - { - foreach (var contentType in contentTypes) + // Since the permissions depend on current user and path, we add both of these to context as well, + // that way we can compare the path and current user when mapping, if they're the same just take permissions + // and skip getting them again, in theory they should always be the same, but better safe than sorry., + context.Items["Parent"] = parent; + context.Items["CurrentUser"] = currentUser; + context.Items["Permissions"] = permissions; + }); + if (mapped is not null) { - var emptyContent = _contentService.Create("", parentId, contentType, userId); - - var mapped = MapToDisplay(emptyContent, context => - { - // Since the permissions depend on current user and path, we add both of these to context as well, - // that way we can compare the path and current user when mapping, if they're the same just take permissions - // and skip getting them again, in theory they should always be the same, but better safe than sorry., - context.Items["Parent"] = parent; - context.Items["CurrentUser"] = currentUser; - context.Items["Permissions"] = permissions; - }); - if (mapped is not null) - { - result.Add(CleanContentItemDisplay(mapped)); - } + result.Add(CleanContentItemDisplay(mapped)); } } - - return result; } - private ActionResult> GetEmptyByKeysInternal(Guid[]? contentTypeKeys, int parentId) + return result; + } + + private ActionResult> GetEmptyByKeysInternal( + Guid[]? contentTypeKeys, + int parentId) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + var contentTypes = _contentTypeService.GetAll(contentTypeKeys).ToList(); + return GetEmpties(contentTypes, parentId).ToDictionary(x => x.ContentTypeKey); + } + + /// + /// Gets a collection of empty content items for all document types. + /// + /// + /// + [OutgoingEditorModelEvent] + public ActionResult> GetEmptyByKeys( + [FromQuery] Guid[] contentTypeKeys, + [FromQuery] int parentId) => GetEmptyByKeysInternal(contentTypeKeys, parentId); + + /// + /// Gets a collection of empty content items for all document types. + /// + /// + /// This is a post request in order to support a large amount of GUIDs without hitting the URL length limit. + /// + /// + /// + [HttpPost] + [OutgoingEditorModelEvent] + public ActionResult> GetEmptyByKeys(ContentTypesByKeys contentTypeByKeys) => + GetEmptyByKeysInternal(contentTypeByKeys.ContentTypeKeys, contentTypeByKeys.ParentId); + + [OutgoingEditorModelEvent] + public ActionResult GetEmptyBlueprint(int blueprintId, int parentId) + { + IContent? blueprint = _contentService.GetBlueprintById(blueprintId); + if (blueprint == null) { - using var scope = _scopeProvider.CreateCoreScope(autoComplete: true); - var contentTypes = _contentTypeService.GetAll(contentTypeKeys).ToList(); - return GetEmpties(contentTypes, parentId).ToDictionary(x => x.ContentTypeKey); - } - - /// - /// Gets a collection of empty content items for all document types. - /// - /// - /// - [OutgoingEditorModelEvent] - public ActionResult> GetEmptyByKeys([FromQuery] Guid[] contentTypeKeys, [FromQuery] int parentId) - { - return GetEmptyByKeysInternal(contentTypeKeys, parentId); - } - - /// - /// Gets a collection of empty content items for all document types. - /// - /// - /// This is a post request in order to support a large amount of GUIDs without hitting the URL length limit. - /// - /// - /// - [HttpPost] - [OutgoingEditorModelEvent] - public ActionResult> GetEmptyByKeys(ContentTypesByKeys contentTypeByKeys) - { - return GetEmptyByKeysInternal(contentTypeByKeys.ContentTypeKeys, contentTypeByKeys.ParentId); - } - - [OutgoingEditorModelEvent] - public ActionResult GetEmptyBlueprint(int blueprintId, int parentId) - { - var blueprint = _contentService.GetBlueprintById(blueprintId); - if (blueprint == null) - { - return NotFound(); - } - - blueprint.Id = 0; - blueprint.Name = string.Empty; - blueprint.ParentId = parentId; - - var mapped = _umbracoMapper.Map(blueprint); - - if (mapped is not null) - { - //remove the listview app if it exists - mapped.ContentApps = mapped.ContentApps.Where(x => x.Alias != "umbListView").ToList(); - } - - return mapped; - } - - /// - /// Gets the Url for a given node ID - /// - /// - /// - public IActionResult GetNiceUrl(int id) - { - var url = _publishedUrlProvider.GetUrl(id); - return Content(url, MediaTypeNames.Text.Plain, Encoding.UTF8); - } - - /// - /// Gets the Url for a given node ID - /// - /// - /// - public IActionResult GetNiceUrl(Guid id) - { - var url = _publishedUrlProvider.GetUrl(id); - return Content(url, MediaTypeNames.Text.Plain, Encoding.UTF8); - } - - /// - /// Gets the Url for a given node ID - /// - /// - /// - public IActionResult GetNiceUrl(Udi id) - { - var guidUdi = id as GuidUdi; - if (guidUdi != null) - { - return GetNiceUrl(guidUdi.Guid); - - } - return NotFound(); } - /// - /// Gets the children for the content id passed in - /// - /// - [FilterAllowedOutgoingContent(typeof(IEnumerable>), "Items")] - public PagedResult> GetChildren( - int id, - string includeProperties, - int pageNumber = 0, - int pageSize = 0, - string orderBy = "SortOrder", - Direction orderDirection = Direction.Ascending, - bool orderBySystemField = true, - string filter = "", - string cultureName = "") // TODO: it's not a NAME it's the ISO CODE + blueprint.Id = 0; + blueprint.Name = string.Empty; + blueprint.ParentId = parentId; + + ContentItemDisplay? mapped = _umbracoMapper.Map(blueprint); + + if (mapped is not null) { - long totalChildren; - List children; + //remove the listview app if it exists + mapped.ContentApps = mapped.ContentApps.Where(x => x.Alias != "umbListView").ToList(); + } - // Sets the culture to the only existing culture if we only have one culture. - if (string.IsNullOrWhiteSpace(cultureName)) + return mapped; + } + + /// + /// Gets the Url for a given node ID + /// + /// + /// + public IActionResult GetNiceUrl(int id) + { + var url = _publishedUrlProvider.GetUrl(id); + return Content(url, MediaTypeNames.Text.Plain, Encoding.UTF8); + } + + /// + /// Gets the Url for a given node ID + /// + /// + /// + public IActionResult GetNiceUrl(Guid id) + { + var url = _publishedUrlProvider.GetUrl(id); + return Content(url, MediaTypeNames.Text.Plain, Encoding.UTF8); + } + + /// + /// Gets the Url for a given node ID + /// + /// + /// + public IActionResult GetNiceUrl(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi != null) + { + return GetNiceUrl(guidUdi.Guid); + } + + return NotFound(); + } + + /// + /// Gets the children for the content id passed in + /// + /// + [FilterAllowedOutgoingContent(typeof(IEnumerable>), "Items")] + public PagedResult> GetChildren( + int id, + string includeProperties, + int pageNumber = 0, + int pageSize = 0, + string orderBy = "SortOrder", + Direction orderDirection = Direction.Ascending, + bool orderBySystemField = true, + string filter = "", + string cultureName = "") // TODO: it's not a NAME it's the ISO CODE + { + long totalChildren; + List children; + + // Sets the culture to the only existing culture if we only have one culture. + if (string.IsNullOrWhiteSpace(cultureName)) + { + if (_allLangs.Value.Count == 1) { - if (_allLangs.Value.Count == 1) - { - cultureName = _allLangs.Value.First().Key; - } + cultureName = _allLangs.Value.First().Key; + } + } + + if (pageNumber > 0 && pageSize > 0) + { + IQuery? queryFilter = null; + if (filter.IsNullOrWhiteSpace() == false) + { + //add the default text filter + queryFilter = _sqlContext.Query() + .Where(x => x.Name != null) + .Where(x => x.Name!.Contains(filter)); } - if (pageNumber > 0 && pageSize > 0) - { - IQuery? queryFilter = null; - if (filter.IsNullOrWhiteSpace() == false) - { - //add the default text filter - queryFilter = _sqlContext.Query() - .Where(x => x.Name != null) - .Where(x => x.Name!.Contains(filter)); - } + children = _contentService + .GetPagedChildren(id, pageNumber - 1, pageSize, out totalChildren, queryFilter, Ordering.By(orderBy, orderDirection, cultureName, !orderBySystemField)).ToList(); + } + else + { + //better to not use this without paging where possible, currently only the sort dialog does + children = _contentService.GetPagedChildren(id, 0, int.MaxValue, out var total).ToList(); + totalChildren = children.Count; + } - children = _contentService - .GetPagedChildren(id, pageNumber - 1, pageSize, out totalChildren, - queryFilter, - Ordering.By(orderBy, orderDirection, cultureName, !orderBySystemField)).ToList(); - } - else - { - //better to not use this without paging where possible, currently only the sort dialog does - children = _contentService.GetPagedChildren(id, 0, int.MaxValue, out var total).ToList(); - totalChildren = children.Count; - } + if (totalChildren == 0) + { + return new PagedResult>(0, 0, 0); + } - if (totalChildren == 0) - { - return new PagedResult>(0, 0, 0); - } - - var pagedResult = new PagedResult>(totalChildren, pageNumber, pageSize); - pagedResult.Items = children.Select(content => - _umbracoMapper.Map>(content, + var pagedResult = new PagedResult>(totalChildren, pageNumber, pageSize) + { + Items = children.Select(content => + _umbracoMapper.Map>( + content, context => { - context.SetCulture(cultureName); // if there's a list of property aliases to map - we will make sure to store this in the mapping context. if (!includeProperties.IsNullOrWhiteSpace()) - context.SetIncludedProperties(includeProperties.Split(new[] { ", ", "," }, StringSplitOptions.RemoveEmptyEntries)); - })) - .WhereNotNull() - .ToList(); // evaluate now - - return pagedResult; - } - - /// - /// Creates a blueprint from a content item - /// - /// The content id to copy - /// The name of the blueprint - /// - [HttpPost] - public ActionResult CreateBlueprintFromContent([FromQuery] int contentId, [FromQuery] string name) - { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)); - - var content = _contentService.GetById(contentId); - if (content == null) - { - return NotFound(); - } - - if (!EnsureUniqueName(name, content, nameof(name))) - { - return ValidationProblem(ModelState); - } - - var blueprint = _contentService.CreateContentFromBlueprint(content, name, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - _contentService.SaveBlueprint(blueprint, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - var notificationModel = new SimpleNotificationModel(); - notificationModel.AddSuccessNotification( - _localizedTextService.Localize("blueprints", "createdBlueprintHeading"), - _localizedTextService.Localize("blueprints", "createdBlueprintMessage", new[] { content.Name }) - ); - - return notificationModel; - } - - private bool EnsureUniqueName(string? name, IContent? content, string modelName) - { - if (content is null) - { - return false; - } - - var existing = _contentService.GetBlueprintsForContentTypes(content.ContentTypeId); - if (existing?.Any(x => x.Name == name && x.Id != content.Id) ?? false) - { - ModelState.AddModelError(modelName, _localizedTextService.Localize("blueprints", "duplicateBlueprintMessage")); - return false; - } - - return true; - } - - /// - /// Saves content - /// - [FileUploadCleanupFilter] - [ContentSaveValidation] - public async Task?>?> PostSaveBlueprint([ModelBinder(typeof(BlueprintItemBinder))] ContentItemSave contentItem) - { - var contentItemDisplay = await PostSaveInternal( - contentItem, - (content, _) => - { - if (!EnsureUniqueName(content?.Name, content, "Name") || contentItem.PersistedContent is null) - { - return OperationResult.Cancel(new EventMessages()); - } - - _contentService.SaveBlueprint(contentItem.PersistedContent, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - // we need to reuse the underlying logic so return the result that it wants - return OperationResult.Succeed(new EventMessages()); - }, - content => - { - var display = MapToDisplay(content); - if (display is not null) - { - SetupBlueprint(display, content); - } - - return display; - }); - - return contentItemDisplay; - } - - /// - /// Saves content - /// - [FileUploadCleanupFilter] - [ContentSaveValidation] - [OutgoingEditorModelEvent] - public async Task?>> PostSave([ModelBinder(typeof(ContentItemBinder))] ContentItemSave contentItem) - { - var contentItemDisplay = await PostSaveInternal( - contentItem, - (content, contentSchedule) => _contentService.Save(content, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id, contentSchedule), - MapToDisplayWithSchedule); - - return contentItemDisplay; - } - - private async Task?>> PostSaveInternal(ContentItemSave contentItem, Func? saveMethod, Func?> mapToDisplay) - where TVariant : ContentVariantDisplay - { - // Recent versions of IE/Edge may send in the full client side file path instead of just the file name. - // To ensure similar behavior across all browsers no matter what they do - we strip the FileName property of all - // uploaded files to being *only* the actual file name (as it should be). - if (contentItem.UploadedFiles != null && contentItem.UploadedFiles.Any()) - { - foreach (var file in contentItem.UploadedFiles) - { - file.FileName = Path.GetFileName(file.FileName); - } - } - - // If we've reached here it means: - // * Our model has been bound - // * and validated - // * any file attachments have been saved to their temporary location for us to use - // * we have a reference to the DTO object and the persisted object - // * Permissions are valid - MapValuesForPersistence(contentItem); - - var passesCriticalValidationRules = ValidateCriticalData(contentItem, out var variantCount); - - // we will continue to save if model state is invalid, however we cannot save if critical data is missing. - if (!ModelState.IsValid) - { - // check for critical data validation issues, we can't continue saving if this data is invalid - if (!passesCriticalValidationRules) - { - // ok, so the absolute mandatory data is invalid and it's new, we cannot actually continue! - // add the model state to the outgoing object and throw a validation message - var forDisplay = mapToDisplay(contentItem.PersistedContent); - return ValidationProblem(forDisplay, ModelState); - } - - // if there's only one variant and the model state is not valid we cannot publish so change it to save - if (variantCount == 1) - { - switch (contentItem.Action) - { - case ContentSaveAction.Publish: - case ContentSaveAction.PublishWithDescendants: - case ContentSaveAction.PublishWithDescendantsForce: - case ContentSaveAction.SendPublish: - case ContentSaveAction.Schedule: - contentItem.Action = ContentSaveAction.Save; - break; - case ContentSaveAction.PublishNew: - case ContentSaveAction.PublishWithDescendantsNew: - case ContentSaveAction.PublishWithDescendantsForceNew: - case ContentSaveAction.SendPublishNew: - case ContentSaveAction.ScheduleNew: - contentItem.Action = ContentSaveAction.SaveNew; - break; - } - } - } - - bool wasCancelled; - - //used to track successful notifications - var globalNotifications = new SimpleNotificationModel(); - var notifications = new Dictionary - { - //global (non variant specific) notifications - [string.Empty] = globalNotifications - }; - - //The default validation language will be either: The default languauge, else if the content is brand new and the default culture is - // not marked to be saved, it will be the first culture in the list marked for saving. - var defaultCulture = _allLangs.Value.Values.FirstOrDefault(x => x.IsDefault)?.IsoCode; - var cultureForInvariantErrors = CultureImpact.GetCultureForInvariantErrors( - contentItem.PersistedContent, - contentItem.Variants.Where(x => x.Save).Select(x => x.Culture).ToArray(), - defaultCulture); - - //get the updated model - bool isBlueprint = contentItem.PersistedContent?.Blueprint ?? false; - - var contentSavedHeader = isBlueprint ? "editBlueprintSavedHeader" : "editContentSavedHeader"; - var contentSavedText = isBlueprint ? "editBlueprintSavedText" : "editContentSavedText"; - - switch (contentItem.Action) - { - case ContentSaveAction.Save: - case ContentSaveAction.SaveNew: - SaveAndNotify(contentItem, saveMethod, variantCount, notifications, globalNotifications, contentSavedHeader, contentSavedText, "editVariantSavedText", cultureForInvariantErrors, null, out wasCancelled); - break; - case ContentSaveAction.Schedule: - case ContentSaveAction.ScheduleNew: - ContentScheduleCollection contentSchedule = _contentService.GetContentScheduleByContentId(contentItem.Id); - if (!SaveSchedule(contentItem, contentSchedule, globalNotifications)) - { - wasCancelled = false; - break; - } - - SaveAndNotify(contentItem, saveMethod, variantCount, notifications, globalNotifications, "editContentSavedHeader", "editContentScheduledSavedText", "editVariantSavedText", cultureForInvariantErrors, contentSchedule, out wasCancelled); - break; - - case ContentSaveAction.SendPublish: - case ContentSaveAction.SendPublishNew: - var sendResult = _contentService.SendToPublication(contentItem.PersistedContent, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - wasCancelled = sendResult == false; - if (sendResult) - { - if (variantCount > 1) { - var variantErrors = ModelState.GetVariantsWithErrors(cultureForInvariantErrors); + context.SetIncludedProperties(includeProperties.Split( + new[] { ", ", "," }, + StringSplitOptions.RemoveEmptyEntries)); + } + })) + .WhereNotNull() + .ToList() // evaluate now + }; - if (variantErrors is not null) + return pagedResult; + } + + /// + /// Creates a blueprint from a content item + /// + /// The content id to copy + /// The name of the blueprint + /// + [HttpPost] + public ActionResult CreateBlueprintFromContent( + [FromQuery] int contentId, + [FromQuery] string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)); + } + + IContent? content = _contentService.GetById(contentId); + if (content == null) + { + return NotFound(); + } + + if (!EnsureUniqueName(name, content, nameof(name))) + { + return ValidationProblem(ModelState); + } + + IContent blueprint = _contentService.CreateContentFromBlueprint(content, name, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + _contentService.SaveBlueprint( + blueprint, + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + var notificationModel = new SimpleNotificationModel(); + notificationModel.AddSuccessNotification( + _localizedTextService.Localize("blueprints", "createdBlueprintHeading"), + _localizedTextService.Localize("blueprints", "createdBlueprintMessage", new[] { content.Name })); + + return notificationModel; + } + + private bool EnsureUniqueName(string? name, IContent? content, string modelName) + { + if (content is null) + { + return false; + } + + IEnumerable? existing = _contentService.GetBlueprintsForContentTypes(content.ContentTypeId); + if (existing?.Any(x => x.Name == name && x.Id != content.Id) ?? false) + { + ModelState.AddModelError(modelName, _localizedTextService.Localize("blueprints", "duplicateBlueprintMessage")); + return false; + } + + return true; + } + + /// + /// Saves content + /// + [FileUploadCleanupFilter] + [ContentSaveValidation] + public async Task?>?> PostSaveBlueprint( + [ModelBinder(typeof(BlueprintItemBinder))] ContentItemSave contentItem) + { + ActionResult?> contentItemDisplay = await PostSaveInternal( + contentItem, + (content, _) => + { + if (!EnsureUniqueName(content?.Name, content, "Name") || contentItem.PersistedContent is null) + { + return OperationResult.Cancel(new EventMessages()); + } + + _contentService.SaveBlueprint(contentItem.PersistedContent, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + // we need to reuse the underlying logic so return the result that it wants + return OperationResult.Succeed(new EventMessages()); + }, + content => + { + ContentItemDisplay? display = MapToDisplay(content); + if (display is not null) + { + SetupBlueprint(display, content); + } + + return display; + }); + + return contentItemDisplay; + } + + /// + /// Saves content + /// + [FileUploadCleanupFilter] + [ContentSaveValidation] + [OutgoingEditorModelEvent] + public async Task?>> PostSave( + [ModelBinder(typeof(ContentItemBinder))] ContentItemSave contentItem) + { + ActionResult?> contentItemDisplay = await PostSaveInternal( + contentItem, + (content, contentSchedule) => _contentService.Save(content, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id, contentSchedule), + MapToDisplayWithSchedule); + + return contentItemDisplay; + } + + private async Task?>> PostSaveInternal( + ContentItemSave contentItem, + Func? saveMethod, + Func?> mapToDisplay) + where TVariant : ContentVariantDisplay + { + // Recent versions of IE/Edge may send in the full client side file path instead of just the file name. + // To ensure similar behavior across all browsers no matter what they do - we strip the FileName property of all + // uploaded files to being *only* the actual file name (as it should be). + if (contentItem.UploadedFiles != null && contentItem.UploadedFiles.Any()) + { + foreach (ContentPropertyFile file in contentItem.UploadedFiles) + { + file.FileName = Path.GetFileName(file.FileName); + } + } + + // If we've reached here it means: + // * Our model has been bound + // * and validated + // * any file attachments have been saved to their temporary location for us to use + // * we have a reference to the DTO object and the persisted object + // * Permissions are valid + MapValuesForPersistence(contentItem); + + var passesCriticalValidationRules = ValidateCriticalData(contentItem, out var variantCount); + + // we will continue to save if model state is invalid, however we cannot save if critical data is missing. + if (!ModelState.IsValid) + { + // check for critical data validation issues, we can't continue saving if this data is invalid + if (!passesCriticalValidationRules) + { + // ok, so the absolute mandatory data is invalid and it's new, we cannot actually continue! + // add the model state to the outgoing object and throw a validation message + ContentItemDisplay? forDisplay = mapToDisplay(contentItem.PersistedContent); + return ValidationProblem(forDisplay, ModelState); + } + + // if there's only one variant and the model state is not valid we cannot publish so change it to save + if (variantCount == 1) + { + switch (contentItem.Action) + { + case ContentSaveAction.Publish: + case ContentSaveAction.PublishWithDescendants: + case ContentSaveAction.PublishWithDescendantsForce: + case ContentSaveAction.SendPublish: + case ContentSaveAction.Schedule: + contentItem.Action = ContentSaveAction.Save; + break; + case ContentSaveAction.PublishNew: + case ContentSaveAction.PublishWithDescendantsNew: + case ContentSaveAction.PublishWithDescendantsForceNew: + case ContentSaveAction.SendPublishNew: + case ContentSaveAction.ScheduleNew: + contentItem.Action = ContentSaveAction.SaveNew; + break; + } + } + } + + bool wasCancelled; + + //used to track successful notifications + var globalNotifications = new SimpleNotificationModel(); + var notifications = new Dictionary + { + //global (non variant specific) notifications + [string.Empty] = globalNotifications + }; + + //The default validation language will be either: The default languauge, else if the content is brand new and the default culture is + // not marked to be saved, it will be the first culture in the list marked for saving. + var defaultCulture = _allLangs.Value.Values.FirstOrDefault(x => x.IsDefault)?.IsoCode; + var cultureForInvariantErrors = CultureImpact.GetCultureForInvariantErrors( + contentItem.PersistedContent, + contentItem.Variants.Where(x => x.Save).Select(x => x.Culture).ToArray(), + defaultCulture); + + //get the updated model + var isBlueprint = contentItem.PersistedContent?.Blueprint ?? false; + + var contentSavedHeader = isBlueprint ? "editBlueprintSavedHeader" : "editContentSavedHeader"; + var contentSavedText = isBlueprint ? "editBlueprintSavedText" : "editContentSavedText"; + + switch (contentItem.Action) + { + case ContentSaveAction.Save: + case ContentSaveAction.SaveNew: + SaveAndNotify( + contentItem, + saveMethod, + variantCount, + notifications, + globalNotifications, + contentSavedHeader, + contentSavedText, + "editVariantSavedText", + cultureForInvariantErrors, + null, + out wasCancelled); + break; + case ContentSaveAction.Schedule: + case ContentSaveAction.ScheduleNew: + ContentScheduleCollection contentSchedule = + _contentService.GetContentScheduleByContentId(contentItem.Id); + if (!SaveSchedule(contentItem, contentSchedule, globalNotifications)) + { + wasCancelled = false; + break; + } + + SaveAndNotify( + contentItem, + saveMethod, + variantCount, + notifications, + globalNotifications, + "editContentSavedHeader", + "editContentScheduledSavedText", + "editVariantSavedText", + cultureForInvariantErrors, + contentSchedule, + out wasCancelled); + break; + + case ContentSaveAction.SendPublish: + case ContentSaveAction.SendPublishNew: + var sendResult = _contentService.SendToPublication(contentItem.PersistedContent, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + wasCancelled = sendResult == false; + if (sendResult) + { + if (variantCount > 1) + { + IReadOnlyList<(string? culture, string? segment)>? variantErrors = + ModelState.GetVariantsWithErrors(cultureForInvariantErrors); + + if (variantErrors is not null) + { + IEnumerable<(string? culture, string? segment)> validVariants = contentItem.Variants + .Where(x => x.Save && !variantErrors.Contains((x.Culture, x.Segment))) + .Select(x => (culture: x.Culture, segment: x.Segment)); + + foreach ((string? culture, string? segment) in validVariants) { - var validVariants = contentItem.Variants - .Where(x => x.Save && !variantErrors.Contains((x.Culture, x.Segment))) - .Select(x => (culture: x.Culture, segment: x.Segment)); + var variantName = GetVariantName(culture, segment); - foreach (var (culture, segment) in validVariants) - { - var variantName = GetVariantName(culture, segment); - - AddSuccessNotification(notifications, culture, segment, - _localizedTextService.Localize("speechBubbles", "editContentSendToPublish"), - _localizedTextService.Localize("speechBubbles", "editVariantSendToPublishText", new[] { variantName })); - } + AddSuccessNotification( + notifications, + culture, + segment, + _localizedTextService.Localize("speechBubbles", "editContentSendToPublish"), + _localizedTextService.Localize( + "speechBubbles", + "editVariantSendToPublishText", + new[] + { + variantName + })); } } - else if (ModelState.IsValid) - { - globalNotifications.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles", "editContentSendToPublish"), - _localizedTextService.Localize("speechBubbles", "editContentSendToPublishText")); - } } - break; - case ContentSaveAction.Publish: - case ContentSaveAction.PublishNew: + else if (ModelState.IsValid) { - PublishResult publishStatus = PublishInternal(contentItem, defaultCulture, cultureForInvariantErrors, out wasCancelled, out var successfulCultures); - // Add warnings if domains are not set up correctly - AddDomainWarnings(publishStatus.Content, successfulCultures, globalNotifications); - AddPublishStatusNotifications(new[] { publishStatus }, globalNotifications, notifications, successfulCultures); + globalNotifications.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "editContentSendToPublish"), + _localizedTextService.Localize("speechBubbles", "editContentSendToPublishText")); } - break; - case ContentSaveAction.PublishWithDescendants: - case ContentSaveAction.PublishWithDescendantsNew: - { - if (!await ValidatePublishBranchPermissionsAsync(contentItem)) - { - globalNotifications.AddErrorNotification( - _localizedTextService.Localize(null, "publish"), - _localizedTextService.Localize("publish", "invalidPublishBranchPermissions")); - wasCancelled = false; - break; - } - - var publishStatus = PublishBranchInternal(contentItem, false, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); - AddDomainWarnings(publishStatus, successfulCultures, globalNotifications); - AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures); - } - break; - case ContentSaveAction.PublishWithDescendantsForce: - case ContentSaveAction.PublishWithDescendantsForceNew: - { - if (!await ValidatePublishBranchPermissionsAsync(contentItem)) - { - globalNotifications.AddErrorNotification( - _localizedTextService.Localize(null, "publish"), - _localizedTextService.Localize("publish", "invalidPublishBranchPermissions")); - wasCancelled = false; - break; - } - - var publishStatus = PublishBranchInternal(contentItem, true, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); - AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures); - } - break; - default: - throw new ArgumentOutOfRangeException(); - } - - // We have to map do display after we've actually saved the content, otherwise we'll miss information that's set when saving content, such as ID - var display = mapToDisplay(contentItem.PersistedContent); - - //merge the tracked success messages with the outgoing model - display?.Notifications.AddRange(globalNotifications.Notifications); - if (display?.Variants is not null) - { - foreach (var v in display.Variants.Where(x => x.Language != null)) - { - if (v.Language?.IsoCode is not null && notifications.TryGetValue(v.Language.IsoCode, out var n)) - v.Notifications.AddRange(n.Notifications); } - } - //lastly, if it is not valid, add the model state to the outgoing object and throw a 400 - HandleInvalidModelState(display, cultureForInvariantErrors); - - if (!ModelState.IsValid) + break; + case ContentSaveAction.Publish: + case ContentSaveAction.PublishNew: { - return ValidationProblem(display, ModelState); + PublishResult publishStatus = PublishInternal(contentItem, defaultCulture, cultureForInvariantErrors, out wasCancelled, out var successfulCultures); + // Add warnings if domains are not set up correctly + AddDomainWarnings(publishStatus.Content, successfulCultures, globalNotifications); + AddPublishStatusNotifications(new[] { publishStatus }, globalNotifications, notifications, successfulCultures); } - - if (wasCancelled) + break; + case ContentSaveAction.PublishWithDescendants: + case ContentSaveAction.PublishWithDescendantsNew: { - AddCancelMessage(display); - if (IsCreatingAction(contentItem.Action)) + if (!await ValidatePublishBranchPermissionsAsync(contentItem)) { - //If the item is new and the operation was cancelled, we need to return a different - // status code so the UI can handle it since it won't be able to redirect since there - // is no Id to redirect to! - return ValidationProblem(display); + globalNotifications.AddErrorNotification( + _localizedTextService.Localize(null, "publish"), + _localizedTextService.Localize("publish", "invalidPublishBranchPermissions")); + wasCancelled = false; + break; } - } - if (display is not null) + var publishStatus = PublishBranchInternal(contentItem, false, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); + AddDomainWarnings(publishStatus, successfulCultures, globalNotifications); + AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures); + } + break; + case ContentSaveAction.PublishWithDescendantsForce: + case ContentSaveAction.PublishWithDescendantsForceNew: { - display.PersistedContent = contentItem.PersistedContent; - } + if (!await ValidatePublishBranchPermissionsAsync(contentItem)) + { + globalNotifications.AddErrorNotification( + _localizedTextService.Localize(null, "publish"), + _localizedTextService.Localize("publish", "invalidPublishBranchPermissions")); + wasCancelled = false; + break; + } - return display; + var publishStatus = PublishBranchInternal(contentItem, true, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); + AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures); + } + break; + default: + throw new ArgumentOutOfRangeException(); } - private void AddPublishStatusNotifications(IReadOnlyCollection publishStatus, SimpleNotificationModel globalNotifications, Dictionary variantNotifications, string[]? successfulCultures) + // We have to map do display after we've actually saved the content, otherwise we'll miss information that's set when saving content, such as ID + ContentItemDisplay? display = mapToDisplay(contentItem.PersistedContent); + + //merge the tracked success messages with the outgoing model + display?.Notifications.AddRange(globalNotifications.Notifications); + if (display?.Variants is not null) { - //global notifications - AddMessageForPublishStatus(publishStatus, globalNotifications, successfulCultures); - //variant specific notifications - foreach (var c in successfulCultures ?? Array.Empty()) - AddMessageForPublishStatus(publishStatus, variantNotifications.GetOrCreate(c), successfulCultures); - } - - /// - /// Validates critical data for persistence and updates the ModelState and result accordingly - /// - /// - /// Returns the total number of variants (will be one if it's an invariant content item) - /// - /// - /// For invariant, the variants collection count will be 1 and this will check if that invariant item has the critical values for persistence (i.e. Name) - /// - /// For variant, each variant will be checked for critical data for persistence and if it's not there then it's flags will be reset and it will not - /// be persisted. However, we also need to deal with the case where all variants don't pass this check and then there is nothing to save. This also deals - /// with removing the Name validation keys based on data annotations validation for items that haven't been marked to be saved. - /// - /// - /// returns false if persistence cannot take place, returns true if persistence can take place even if there are validation errors - /// - private bool ValidateCriticalData(ContentItemSave contentItem, out int variantCount) - { - var variants = contentItem.Variants.ToList(); - variantCount = variants.Count; - var savedCount = 0; - var variantCriticalValidationErrors = new List(); - for (var i = 0; i < variants.Count; i++) + foreach (TVariant v in display.Variants.Where(x => x.Language != null)) { - var variant = variants[i]; - if (variant.Save) + if (v.Language?.IsoCode is not null && + notifications.TryGetValue(v.Language.IsoCode, out SimpleNotificationModel? n)) { - //ensure the variant has all critical required data to be persisted - if (!RequiredForPersistenceAttribute.HasRequiredValuesForPersistence(variant)) - { - if (variant.Culture is not null) - { - variantCriticalValidationErrors.Add(variant.Culture); - } - - //if there's no Name, it cannot be persisted at all reset the flags, this cannot be saved or published - variant.Save = variant.Publish = false; - - //if there's more than 1 variant, then we need to add the culture specific error - //messages based on the variants in error so that the messages show in the publish/save dialog - if (variants.Count > 1) - AddVariantValidationError(variant.Culture, variant.Segment, "publish","contentPublishedFailedByMissingName"); - else - return false; //It's invariant and is missing critical data, it cannot be saved - } - - savedCount++; - } - else - { - var msKey = $"Variants[{i}].Name"; - if (ModelState.ContainsKey(msKey)) - { - //if it's not being saved, remove the validation key - if (!variant.Save) ModelState.Remove(msKey); - } - } - } - - if (savedCount == variantCriticalValidationErrors.Count) - { - //in this case there can be nothing saved since all variants marked to be saved haven't passed critical validation rules - return false; - } - - return true; - } - - - - /// - /// Helper method to perform the saving of the content and add the notifications to the result - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// Method is used for normal Saving and Scheduled Publishing - /// - private void SaveAndNotify(ContentItemSave contentItem, Func? saveMethod, int variantCount, - Dictionary notifications, SimpleNotificationModel globalNotifications, - string savedContentHeaderLocalizationAlias, string invariantSavedLocalizationAlias, string variantSavedLocalizationAlias, string? cultureForInvariantErrors, ContentScheduleCollection? contentSchedule, - out bool wasCancelled) - { - var saveResult = saveMethod?.Invoke(contentItem.PersistedContent, contentSchedule); - wasCancelled = saveResult?.Success == false && saveResult.Result == OperationResultType.FailedCancelledByEvent; - if (saveResult?.Success ?? false) - { - if (variantCount > 1) - { - var variantErrors = ModelState.GetVariantsWithErrors(cultureForInvariantErrors); - - var savedWithoutErrors = contentItem.Variants - .Where(x => x.Save && (!variantErrors?.Contains((x.Culture, x.Segment)) ?? false)) - .Select(x => (culture: x.Culture, segment: x.Segment)); - - foreach (var (culture, segment) in savedWithoutErrors) - { - var variantName = GetVariantName(culture, segment); - - AddSuccessNotification(notifications, culture, segment, - _localizedTextService.Localize("speechBubbles", savedContentHeaderLocalizationAlias), - _localizedTextService.Localize(null, variantSavedLocalizationAlias, new[] { variantName })); - } - } - else if (ModelState.IsValid) - { - globalNotifications.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles", savedContentHeaderLocalizationAlias), - _localizedTextService.Localize("speechBubbles", invariantSavedLocalizationAlias)); + v.Notifications.AddRange(n.Notifications); } } } - /// - /// Validates the incoming schedule and update the model - /// - /// - /// - private bool SaveSchedule(ContentItemSave contentItem, ContentScheduleCollection contentSchedule, SimpleNotificationModel globalNotifications) + //lastly, if it is not valid, add the model state to the outgoing object and throw a 400 + HandleInvalidModelState(display, cultureForInvariantErrors); + + if (!ModelState.IsValid) { - if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false) - return SaveScheduleInvariant(contentItem, contentSchedule, globalNotifications); + return ValidationProblem(display, ModelState); + } + + if (wasCancelled) + { + AddCancelMessage(display); + if (IsCreatingAction(contentItem.Action)) + { + //If the item is new and the operation was cancelled, we need to return a different + // status code so the UI can handle it since it won't be able to redirect since there + // is no Id to redirect to! + return ValidationProblem(display); + } + } + + if (display is not null) + { + display.PersistedContent = contentItem.PersistedContent; + } + + return display; + } + + private void AddPublishStatusNotifications( + IReadOnlyCollection publishStatus, + SimpleNotificationModel globalNotifications, + Dictionary variantNotifications, + string[]? successfulCultures) + { + //global notifications + AddMessageForPublishStatus(publishStatus, globalNotifications, successfulCultures); + //variant specific notifications + foreach (var c in successfulCultures ?? Array.Empty()) + { + AddMessageForPublishStatus(publishStatus, variantNotifications.GetOrCreate(c), successfulCultures); + } + } + + /// + /// Validates critical data for persistence and updates the ModelState and result accordingly + /// + /// + /// Returns the total number of variants (will be one if it's an invariant content item) + /// + /// + /// For invariant, the variants collection count will be 1 and this will check if that invariant item has the critical + /// values for persistence (i.e. Name) + /// For variant, each variant will be checked for critical data for persistence and if it's not there then it's flags + /// will be reset and it will not + /// be persisted. However, we also need to deal with the case where all variants don't pass this check and then there + /// is nothing to save. This also deals + /// with removing the Name validation keys based on data annotations validation for items that haven't been marked to + /// be saved. + /// + /// + /// returns false if persistence cannot take place, returns true if persistence can take place even if there are + /// validation errors + /// + private bool ValidateCriticalData(ContentItemSave contentItem, out int variantCount) + { + var variants = contentItem.Variants.ToList(); + variantCount = variants.Count; + var savedCount = 0; + var variantCriticalValidationErrors = new List(); + for (var i = 0; i < variants.Count; i++) + { + ContentVariantSave variant = variants[i]; + if (variant.Save) + { + //ensure the variant has all critical required data to be persisted + if (!RequiredForPersistenceAttribute.HasRequiredValuesForPersistence(variant)) + { + if (variant.Culture is not null) + { + variantCriticalValidationErrors.Add(variant.Culture); + } + + //if there's no Name, it cannot be persisted at all reset the flags, this cannot be saved or published + variant.Save = variant.Publish = false; + + //if there's more than 1 variant, then we need to add the culture specific error + //messages based on the variants in error so that the messages show in the publish/save dialog + if (variants.Count > 1) + { + AddVariantValidationError(variant.Culture, variant.Segment, "publish", "contentPublishedFailedByMissingName"); + } + else + { + return false; //It's invariant and is missing critical data, it cannot be saved + } + } + + savedCount++; + } else - return SaveScheduleVariant(contentItem, contentSchedule); + { + var msKey = $"Variants[{i}].Name"; + if (ModelState.ContainsKey(msKey)) + { + //if it's not being saved, remove the validation key + if (!variant.Save) + { + ModelState.Remove(msKey); + } + } + } } - private bool SaveScheduleInvariant(ContentItemSave contentItem, ContentScheduleCollection contentSchedule, SimpleNotificationModel globalNotifications) + if (savedCount == variantCriticalValidationErrors.Count) { - var variant = contentItem.Variants.First(); + //in this case there can be nothing saved since all variants marked to be saved haven't passed critical validation rules + return false; + } - var currRelease = contentSchedule.GetSchedule(ContentScheduleAction.Release).ToList(); - var currExpire = contentSchedule.GetSchedule(ContentScheduleAction.Expire).ToList(); + return true; + } - //Do all validation of data first + /// + /// Helper method to perform the saving of the content and add the notifications to the result + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// Method is used for normal Saving and Scheduled Publishing + /// + private void SaveAndNotify( + ContentItemSave contentItem, + Func? saveMethod, + int variantCount, + Dictionary notifications, + SimpleNotificationModel globalNotifications, + string savedContentHeaderLocalizationAlias, + string invariantSavedLocalizationAlias, + string variantSavedLocalizationAlias, + string? cultureForInvariantErrors, + ContentScheduleCollection? contentSchedule, + out bool wasCancelled) + { + OperationResult? saveResult = saveMethod?.Invoke(contentItem.PersistedContent, contentSchedule); + wasCancelled = saveResult?.Success == false && saveResult.Result == OperationResultType.FailedCancelledByEvent; + if (saveResult?.Success ?? false) + { + if (variantCount > 1) + { + IReadOnlyList<(string? culture, string? segment)>? variantErrors = + ModelState.GetVariantsWithErrors(cultureForInvariantErrors); + + IEnumerable<(string? culture, string? segment)> savedWithoutErrors = contentItem.Variants + .Where(x => x.Save && (!variantErrors?.Contains((x.Culture, x.Segment)) ?? false)) + .Select(x => (culture: x.Culture, segment: x.Segment)); + + foreach ((string? culture, string? segment) in savedWithoutErrors) + { + var variantName = GetVariantName(culture, segment); + + AddSuccessNotification( + notifications, + culture, + segment, + _localizedTextService.Localize( + "speechBubbles", + savedContentHeaderLocalizationAlias), + _localizedTextService.Localize(null, variantSavedLocalizationAlias, new[] + { + variantName + })); + } + } + else if (ModelState.IsValid) + { + globalNotifications.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", savedContentHeaderLocalizationAlias), + _localizedTextService.Localize("speechBubbles", invariantSavedLocalizationAlias)); + } + } + } + + /// + /// Validates the incoming schedule and update the model + /// + /// + /// + /// + private bool SaveSchedule(ContentItemSave contentItem, ContentScheduleCollection contentSchedule, SimpleNotificationModel globalNotifications) + { + if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false) + { + return SaveScheduleInvariant(contentItem, contentSchedule, globalNotifications); + } + + return SaveScheduleVariant(contentItem, contentSchedule); + } + + private bool SaveScheduleInvariant(ContentItemSave contentItem, ContentScheduleCollection contentSchedule, SimpleNotificationModel globalNotifications) + { + ContentVariantSave variant = contentItem.Variants.First(); + + var currRelease = contentSchedule.GetSchedule(ContentScheduleAction.Release).ToList(); + var currExpire = contentSchedule.GetSchedule(ContentScheduleAction.Expire).ToList(); + + //Do all validation of data first + + //1) release date cannot be less than now + if (variant.ReleaseDate.HasValue && variant.ReleaseDate < DateTime.Now) + { + globalNotifications.AddErrorNotification( + _localizedTextService.Localize("speechBubbles", "validationFailedHeader"), + _localizedTextService.Localize("speechBubbles", "scheduleErrReleaseDate1")); + return false; + } + + //2) expire date cannot be less than now + if (variant.ExpireDate.HasValue && variant.ExpireDate < DateTime.Now) + { + globalNotifications.AddErrorNotification( + _localizedTextService.Localize("speechBubbles", "validationFailedHeader"), + _localizedTextService.Localize("speechBubbles", "scheduleErrExpireDate1")); + return false; + } + + //3) expire date cannot be less than release date + if (variant.ExpireDate.HasValue && variant.ReleaseDate.HasValue && variant.ExpireDate <= variant.ReleaseDate) + { + globalNotifications.AddErrorNotification( + _localizedTextService.Localize("speechBubbles", "validationFailedHeader"), + _localizedTextService.Localize("speechBubbles", "scheduleErrExpireDate2")); + return false; + } + + + //Now we can do the data updates + + //remove any existing release dates so we can replace it + //if there is a release date in the request or if there was previously a release and the request value is null then we are clearing the schedule + if (variant.ReleaseDate.HasValue || currRelease.Count > 0) + { + contentSchedule.Clear(ContentScheduleAction.Release); + } + + //remove any existing expire dates so we can replace it + //if there is an expiry date in the request or if there was a previous expiry and the request value is null then we are clearing the schedule + if (variant.ExpireDate.HasValue || currExpire.Count > 0) + { + contentSchedule.Clear(ContentScheduleAction.Expire); + } + + //add the new schedule + contentSchedule.Add(variant.ReleaseDate, variant.ExpireDate); + return true; + } + + private bool SaveScheduleVariant(ContentItemSave contentItem, ContentScheduleCollection contentSchedule) + { + //All variants in this collection should have a culture if we get here but we'll double check and filter here) + var cultureVariants = contentItem.Variants.Where(x => !x.Culture.IsNullOrWhiteSpace()).ToList(); + var mandatoryCultures = _allLangs.Value.Values.Where(x => x.IsMandatory).Select(x => x.IsoCode).ToList(); + + foreach (ContentVariantSave variant in cultureVariants.Where(x => x.Save)) + { + var currRelease = contentSchedule.GetSchedule(variant.Culture, ContentScheduleAction.Release).ToList(); + var currExpire = contentSchedule.GetSchedule(variant.Culture, ContentScheduleAction.Expire).ToList(); + + //remove any existing release dates so we can replace it + //if there is a release date in the request or if there was previously a release and the request value is null then we are clearing the schedule + if (variant.ReleaseDate.HasValue || currRelease.Count > 0) + { + contentSchedule.Clear(variant.Culture, ContentScheduleAction.Release); + } + + //remove any existing expire dates so we can replace it + //if there is an expiry date in the request or if there was a previous expiry and the request value is null then we are clearing the schedule + if (variant.ExpireDate.HasValue || currExpire.Count > 0) + { + contentSchedule.Clear(variant.Culture, ContentScheduleAction.Expire); + } + + //add the new schedule + contentSchedule.Add(variant.Culture, variant.ReleaseDate, variant.ExpireDate); + } + + //now validate the new schedule to make sure it passes all of the rules + + var isValid = true; + + //create lists of mandatory/non-mandatory states + var mandatoryVariants = new List<(string culture, bool isPublished, List releaseDates)>(); + var nonMandatoryVariants = new List<(string culture, bool isPublished, List releaseDates)>(); + foreach (IGrouping groupedSched in + contentSchedule.FullSchedule.GroupBy(x => x.Culture)) + { + var isPublished = (contentItem.PersistedContent?.Published ?? false) && + contentItem.PersistedContent.IsCulturePublished(groupedSched.Key); + var releaseDates = groupedSched.Where(x => x.Action == ContentScheduleAction.Release).Select(x => x.Date) + .ToList(); + if (mandatoryCultures.Contains(groupedSched.Key, StringComparer.InvariantCultureIgnoreCase)) + { + mandatoryVariants.Add((groupedSched.Key, isPublished, releaseDates)); + } + else + { + nonMandatoryVariants.Add((groupedSched.Key, isPublished, releaseDates)); + } + } + + var nonMandatoryVariantReleaseDates = nonMandatoryVariants.SelectMany(x => x.releaseDates).ToList(); + + //validate that the mandatory languages have the right data + foreach ((var culture, var isPublished, List releaseDates) in mandatoryVariants) + { + if (!isPublished && releaseDates.Count == 0) + { + //can't continue, a mandatory variant is not published and not scheduled for publishing + // TODO: Add segment + AddVariantValidationError(culture, null, "speechBubbles", "scheduleErrReleaseDate2"); + isValid = false; + continue; + } + + if (!isPublished && releaseDates.Any(x => nonMandatoryVariantReleaseDates.Any(r => x.Date > r.Date))) + { + //can't continue, a mandatory variant is not published and it's scheduled for publishing after a non-mandatory + // TODO: Add segment + AddVariantValidationError(culture, null, "speechBubbles", "scheduleErrReleaseDate3"); + isValid = false; + } + } + + if (!isValid) + { + return false; + } + + //now we can validate the more basic rules for individual variants + foreach (ContentVariantSave variant in cultureVariants.Where(x => + x.ReleaseDate.HasValue || x.ExpireDate.HasValue)) + { //1) release date cannot be less than now if (variant.ReleaseDate.HasValue && variant.ReleaseDate < DateTime.Now) { - globalNotifications.AddErrorNotification( - _localizedTextService.Localize("speechBubbles", "validationFailedHeader"), - _localizedTextService.Localize("speechBubbles", "scheduleErrReleaseDate1")); - return false; + AddVariantValidationError(variant.Culture, variant.Segment, "speechBubbles", "scheduleErrReleaseDate1"); + isValid = false; + continue; } //2) expire date cannot be less than now if (variant.ExpireDate.HasValue && variant.ExpireDate < DateTime.Now) { - globalNotifications.AddErrorNotification( - _localizedTextService.Localize("speechBubbles", "validationFailedHeader"), - _localizedTextService.Localize("speechBubbles", "scheduleErrExpireDate1")); - return false; + AddVariantValidationError(variant.Culture, variant.Segment, "speechBubbles", "scheduleErrExpireDate1"); + isValid = false; + continue; } //3) expire date cannot be less than release date - if (variant.ExpireDate.HasValue && variant.ReleaseDate.HasValue && variant.ExpireDate <= variant.ReleaseDate) + if (variant.ExpireDate.HasValue && variant.ReleaseDate.HasValue && + variant.ExpireDate <= variant.ReleaseDate) { - globalNotifications.AddErrorNotification( - _localizedTextService.Localize("speechBubbles", "validationFailedHeader"), - _localizedTextService.Localize("speechBubbles", "scheduleErrExpireDate2")); - return false; - } - - - //Now we can do the data updates - - //remove any existing release dates so we can replace it - //if there is a release date in the request or if there was previously a release and the request value is null then we are clearing the schedule - if (variant.ReleaseDate.HasValue || currRelease.Count > 0) - contentSchedule.Clear(ContentScheduleAction.Release); - - //remove any existing expire dates so we can replace it - //if there is an expiry date in the request or if there was a previous expiry and the request value is null then we are clearing the schedule - if (variant.ExpireDate.HasValue || currExpire.Count > 0) - contentSchedule.Clear(ContentScheduleAction.Expire); - - //add the new schedule - contentSchedule.Add(variant.ReleaseDate, variant.ExpireDate); - return true; - } - - private bool SaveScheduleVariant(ContentItemSave contentItem, ContentScheduleCollection contentSchedule) - { - //All variants in this collection should have a culture if we get here but we'll double check and filter here) - var cultureVariants = contentItem.Variants.Where(x => !x.Culture.IsNullOrWhiteSpace()).ToList(); - var mandatoryCultures = _allLangs.Value.Values.Where(x => x.IsMandatory).Select(x => x.IsoCode).ToList(); - - foreach (var variant in cultureVariants.Where(x => x.Save)) - { - var currRelease = contentSchedule.GetSchedule(variant.Culture, ContentScheduleAction.Release).ToList(); - var currExpire = contentSchedule.GetSchedule(variant.Culture, ContentScheduleAction.Expire).ToList(); - - //remove any existing release dates so we can replace it - //if there is a release date in the request or if there was previously a release and the request value is null then we are clearing the schedule - if (variant.ReleaseDate.HasValue || currRelease.Count > 0) - contentSchedule.Clear(variant.Culture, ContentScheduleAction.Release); - - //remove any existing expire dates so we can replace it - //if there is an expiry date in the request or if there was a previous expiry and the request value is null then we are clearing the schedule - if (variant.ExpireDate.HasValue || currExpire.Count > 0) - contentSchedule.Clear(variant.Culture, ContentScheduleAction.Expire); - - //add the new schedule - contentSchedule.Add(variant.Culture, variant.ReleaseDate, variant.ExpireDate); - } - - //now validate the new schedule to make sure it passes all of the rules - - var isValid = true; - - //create lists of mandatory/non-mandatory states - var mandatoryVariants = new List<(string culture, bool isPublished, List releaseDates)>(); - var nonMandatoryVariants = new List<(string culture, bool isPublished, List releaseDates)>(); - foreach (var groupedSched in contentSchedule.FullSchedule.GroupBy(x => x.Culture)) - { - var isPublished = (contentItem.PersistedContent?.Published ?? false) && contentItem.PersistedContent.IsCulturePublished(groupedSched.Key); - var releaseDates = groupedSched.Where(x => x.Action == ContentScheduleAction.Release).Select(x => x.Date).ToList(); - if (mandatoryCultures.Contains(groupedSched.Key, StringComparer.InvariantCultureIgnoreCase)) - mandatoryVariants.Add((groupedSched.Key, isPublished, releaseDates)); - else - nonMandatoryVariants.Add((groupedSched.Key, isPublished, releaseDates)); - } - - var nonMandatoryVariantReleaseDates = nonMandatoryVariants.SelectMany(x => x.releaseDates).ToList(); - - //validate that the mandatory languages have the right data - foreach (var (culture, isPublished, releaseDates) in mandatoryVariants) - { - if (!isPublished && releaseDates.Count == 0) - { - //can't continue, a mandatory variant is not published and not scheduled for publishing - // TODO: Add segment - AddVariantValidationError(culture, null, "speechBubbles", "scheduleErrReleaseDate2"); - isValid = false; - continue; - } - if (!isPublished && releaseDates.Any(x => nonMandatoryVariantReleaseDates.Any(r => x.Date > r.Date))) - { - //can't continue, a mandatory variant is not published and it's scheduled for publishing after a non-mandatory - // TODO: Add segment - AddVariantValidationError(culture, null, "speechBubbles", "scheduleErrReleaseDate3"); - isValid = false; - continue; - } - } - - if (!isValid) - return false; - - //now we can validate the more basic rules for individual variants - foreach (var variant in cultureVariants.Where(x => x.ReleaseDate.HasValue || x.ExpireDate.HasValue)) - { - //1) release date cannot be less than now - if (variant.ReleaseDate.HasValue && variant.ReleaseDate < DateTime.Now) - { - AddVariantValidationError(variant.Culture, variant.Segment, "speechBubbles", "scheduleErrReleaseDate1"); - isValid = false; - continue; - } - - //2) expire date cannot be less than now - if (variant.ExpireDate.HasValue && variant.ExpireDate < DateTime.Now) - { - AddVariantValidationError(variant.Culture, variant.Segment, "speechBubbles", "scheduleErrExpireDate1"); - isValid = false; - continue; - } - - //3) expire date cannot be less than release date - if (variant.ExpireDate.HasValue && variant.ReleaseDate.HasValue && variant.ExpireDate <= variant.ReleaseDate) - { - AddVariantValidationError(variant.Culture, variant.Segment, "speechBubbles", "scheduleErrExpireDate2"); - isValid = false; - continue; - } - } - - if (!isValid) - return false; - - return true; - } - - /// - /// Used to add success notifications globally and for the culture - /// - /// - /// - /// - /// - /// - /// global notifications will be shown if all variant processing is successful and the save/publish dialog is closed, otherwise - /// variant specific notifications are used to show success messages in the save/publish dialog. - /// - private static void AddSuccessNotification(IDictionary notifications, string? culture, string? segment, string header, string msg) - { - //add the global notification (which will display globally if all variants are successfully processed) - notifications[string.Empty].AddSuccessNotification(header, msg); - //add the variant specific notification (which will display in the dialog if all variants are not successfully processed) - var key = culture + "_" + segment; - notifications.GetOrCreate(key).AddSuccessNotification(header, msg); - } - - /// - /// The user must have publish access to all descendant nodes of the content item in order to continue - /// - /// - /// - private async Task ValidatePublishBranchPermissionsAsync(ContentItemSave contentItem) - { - // Authorize... - var requirement = new ContentPermissionsPublishBranchRequirement(ActionPublish.ActionLetter); - var authorizationResult = await _authorizationService.AuthorizeAsync(User, contentItem.PersistedContent, requirement); - return authorizationResult.Succeeded; - } - - private IEnumerable PublishBranchInternal(ContentItemSave contentItem, bool force, string? cultureForInvariantErrors, - out bool wasCancelled, out string[]? successfulCultures) - { - if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false) - { - //its invariant, proceed normally - var publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, force, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - // TODO: Deal with multiple cancellations - wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent); - successfulCultures = null; //must be null! this implies invariant - return publishStatus; - } - - var mandatoryCultures = _allLangs.Value.Values.Where(x => x.IsMandatory).Select(x => x.IsoCode).ToList(); - - var variantErrors = ModelState.GetVariantsWithErrors(cultureForInvariantErrors); - - var variants = contentItem.Variants.ToList(); - - //validate if we can publish based on the mandatory language requirements - var canPublish = ValidatePublishingMandatoryLanguages( - variantErrors, - contentItem, variants, mandatoryCultures, - mandatoryVariant => mandatoryVariant.Publish); - - //Now check if there are validation errors on each variant. - //If validation errors are detected on a variant and it's state is set to 'publish', then we - //need to change it to 'save'. - //It is a requirement that this is performed AFTER ValidatePublishingMandatoryLanguages. - - foreach (var variant in contentItem.Variants) - { - if (variantErrors?.Contains((variant.Culture, variant.Segment)) ?? false) - variant.Publish = false; - } - - var culturesToPublish = variants.Where(x => x.Publish).Select(x => x.Culture).ToArray(); - - if (canPublish) - { - //proceed to publish if all validation still succeeds - var publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, force, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - // TODO: Deal with multiple cancellations - wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent); - successfulCultures = contentItem.Variants.Where(x => x.Publish).Select(x => x.Culture).WhereNotNull().ToArray(); - return publishStatus; - } - else - { - //can only save - var saveResult = _contentService.Save(contentItem.PersistedContent!, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - var publishStatus = new[] - { - new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, null, contentItem.PersistedContent) - }; - wasCancelled = saveResult.Result == OperationResultType.FailedCancelledByEvent; - successfulCultures = Array.Empty(); - return publishStatus; - } - - } - - /// - /// Performs the publishing operation for a content item - /// - /// - /// - /// - /// if the content is variant this will return an array of cultures that will be published (passed validation rules) - /// - /// - /// If this is a culture variant than we need to do some validation, if it's not we'll publish as normal - /// - private PublishResult PublishInternal(ContentItemSave contentItem, string? defaultCulture, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures) - { - if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false) - { - //its invariant, proceed normally - var publishStatus = _contentService.SaveAndPublish(contentItem.PersistedContent!, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - wasCancelled = publishStatus.Result == PublishResultType.FailedPublishCancelledByEvent; - successfulCultures = null; //must be null! this implies invariant - return publishStatus; - } - - var mandatoryCultures = _allLangs.Value.Values.Where(x => x.IsMandatory).Select(x => x.IsoCode).ToList(); - - var variantErrors = ModelState.GetVariantsWithErrors(cultureForInvariantErrors); - - var variants = contentItem.Variants.ToList(); - - //validate if we can publish based on the mandatory languages selected - var canPublish = ValidatePublishingMandatoryLanguages( - variantErrors, - contentItem, variants, mandatoryCultures, - mandatoryVariant => mandatoryVariant.Publish); - - //if none are published and there are validation errors for mandatory cultures, then we can't publish anything - - - //Now check if there are validation errors on each variant. - //If validation errors are detected on a variant and it's state is set to 'publish', then we - //need to change it to 'save'. - //It is a requirement that this is performed AFTER ValidatePublishingMandatoryLanguages. - foreach (var variant in contentItem.Variants) - { - if (variantErrors?.Contains((variant.Culture, variant.Segment)) ?? false) - variant.Publish = false; - } - - //At this stage all variants might have failed validation which means there are no cultures flagged for publishing! - var culturesToPublish = variants.Where(x => x.Publish).Select(x => x.Culture).WhereNotNull().ToArray(); - canPublish = canPublish && culturesToPublish.Length > 0; - - if (canPublish) - { - //try to publish all the values on the model - this will generally only fail if someone is tampering with the request - //since there's no reason variant rules would be violated in normal cases. - canPublish = PublishCulture(contentItem.PersistedContent!, variants, defaultCulture); - } - - if (canPublish) - { - //proceed to publish if all validation still succeeds - var publishStatus = _contentService.SaveAndPublish(contentItem.PersistedContent!, culturesToPublish, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - wasCancelled = publishStatus.Result == PublishResultType.FailedPublishCancelledByEvent; - successfulCultures = culturesToPublish; - - return publishStatus; - } - else - { - //can only save - var saveResult = _contentService.Save(contentItem.PersistedContent!, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - var publishStatus = new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, null, contentItem.PersistedContent); - wasCancelled = saveResult.Result == OperationResultType.FailedCancelledByEvent; - successfulCultures = Array.Empty(); - return publishStatus; + AddVariantValidationError(variant.Culture, variant.Segment, "speechBubbles", "scheduleErrExpireDate2"); + isValid = false; } } - private void AddDomainWarnings(IEnumerable publishResults, string[]? culturesPublished, - SimpleNotificationModel globalNotifications) + if (!isValid) { - foreach (PublishResult publishResult in publishResults) + return false; + } + + return true; + } + + /// + /// Used to add success notifications globally and for the culture + /// + /// + /// + /// + /// + /// + /// + /// global notifications will be shown if all variant processing is successful and the save/publish dialog is closed, + /// otherwise + /// variant specific notifications are used to show success messages in the save/publish dialog. + /// + private static void AddSuccessNotification( + IDictionary notifications, + string? culture, + string? segment, + string header, + string msg) + { + //add the global notification (which will display globally if all variants are successfully processed) + notifications[string.Empty].AddSuccessNotification(header, msg); + //add the variant specific notification (which will display in the dialog if all variants are not successfully processed) + var key = culture + "_" + segment; + notifications.GetOrCreate(key).AddSuccessNotification(header, msg); + } + + /// + /// The user must have publish access to all descendant nodes of the content item in order to continue + /// + /// + /// + private async Task ValidatePublishBranchPermissionsAsync(ContentItemSave contentItem) + { + // Authorize... + var requirement = new ContentPermissionsPublishBranchRequirement(ActionPublish.ActionLetter); + AuthorizationResult authorizationResult = + await _authorizationService.AuthorizeAsync(User, contentItem.PersistedContent, requirement); + return authorizationResult.Succeeded; + } + + private IEnumerable PublishBranchInternal(ContentItemSave contentItem, bool force, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures) + { + if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false) + { + //its invariant, proceed normally + IEnumerable publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, force, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + // TODO: Deal with multiple cancellations + wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent); + successfulCultures = null; //must be null! this implies invariant + return publishStatus; + } + + var mandatoryCultures = _allLangs.Value.Values.Where(x => x.IsMandatory).Select(x => x.IsoCode).ToList(); + + IReadOnlyList<(string? culture, string? segment)>? variantErrors = + ModelState.GetVariantsWithErrors(cultureForInvariantErrors); + + var variants = contentItem.Variants.ToList(); + + //validate if we can publish based on the mandatory language requirements + var canPublish = ValidatePublishingMandatoryLanguages(variantErrors, contentItem, variants, mandatoryCultures, mandatoryVariant => mandatoryVariant.Publish); + + //Now check if there are validation errors on each variant. + //If validation errors are detected on a variant and it's state is set to 'publish', then we + //need to change it to 'save'. + //It is a requirement that this is performed AFTER ValidatePublishingMandatoryLanguages. + + foreach (ContentVariantSave variant in contentItem.Variants) + { + if (variantErrors?.Contains((variant.Culture, variant.Segment)) ?? false) { - AddDomainWarnings(publishResult.Content, culturesPublished, globalNotifications); + variant.Publish = false; } } - /// - /// Verifies that there's an appropriate domain setup for the published cultures - /// - /// - /// Adds a warning and logs a message if a node varies by culture, there's at least 1 culture already published, - /// and there's no domain added for the published cultures - /// - /// - /// - /// - internal void AddDomainWarnings(IContent? persistedContent, string[]? culturesPublished, SimpleNotificationModel globalNotifications) + var culturesToPublish = variants.Where(x => x.Publish).Select(x => x.Culture).ToArray(); + + if (canPublish) { - // Don't try to verify if no cultures were published - if (culturesPublished is null) - { - return; - } - - var publishedCultures = GetPublishedCulturesFromAncestors(persistedContent).ToList(); - // If only a single culture is published we shouldn't have any routing issues - if (publishedCultures.Count < 2) - { - return; - } - - // If more than a single culture is published we need to verify that there's a domain registered for each published culture - var assignedDomains = persistedContent is null ? null : _domainService.GetAssignedDomains(persistedContent.Id, true)?.ToHashSet(); - - var ancestorIds = persistedContent?.GetAncestorIds(); - if (ancestorIds is not null && assignedDomains is not null) - { - // We also have to check all of the ancestors, if any of those has the appropriate culture assigned we don't need to warn - foreach (var ancestorID in ancestorIds) - { - assignedDomains.UnionWith(_domainService.GetAssignedDomains(ancestorID, true) ?? Enumerable.Empty()); - } - } - - // No domains at all, add a warning, to add domains. - if (assignedDomains is null || assignedDomains.Count == 0) - { - globalNotifications.AddWarningNotification( - _localizedTextService.Localize("auditTrails", "publish"), - _localizedTextService.Localize("speechBubbles", "publishWithNoDomains")); - - _logger.LogWarning("The root node {RootNodeName} was published with multiple cultures, but no domains are configured, this will cause routing and caching issues, please register domains for: {Cultures}", - persistedContent?.Name, string.Join(", ", publishedCultures)); - return; - } - - // If there is some domains, verify that there's a domain for each of the published cultures - foreach (var culture in culturesPublished - .Where(culture => assignedDomains.Any(x => x.LanguageIsoCode?.Equals(culture, StringComparison.OrdinalIgnoreCase) ?? false) is false)) - { - globalNotifications.AddWarningNotification( - _localizedTextService.Localize("auditTrails", "publish"), - _localizedTextService.Localize("speechBubbles", "publishWithMissingDomain", new []{culture})); - - _logger.LogWarning("The root node {RootNodeName} was published in culture {Culture}, but there's no domain configured for it, this will cause routing and caching issues, please register a domain for it", - persistedContent?.Name, culture); - } + //proceed to publish if all validation still succeeds + IEnumerable publishStatus = _contentService.SaveAndPublishBranch( + contentItem.PersistedContent!, force, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + // TODO: Deal with multiple cancellations + wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent); + successfulCultures = contentItem.Variants.Where(x => x.Publish).Select(x => x.Culture).WhereNotNull() + .ToArray(); + return publishStatus; } - - /// - /// Validate if publishing is possible based on the mandatory language requirements - /// - /// - /// - /// - /// - /// - /// - private bool ValidatePublishingMandatoryLanguages( - IReadOnlyCollection<(string? culture, string? segment)>? variantsWithValidationErrors, - ContentItemSave contentItem, - IReadOnlyCollection variants, - IReadOnlyList mandatoryCultures, - Func publishingCheck) + else { - var canPublish = true; - var result = new List<(ContentVariantSave model, bool publishing, bool isValid)>(); - - foreach (var culture in mandatoryCultures) + //can only save + OperationResult saveResult = _contentService.Save(contentItem.PersistedContent!, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + PublishResult[] publishStatus = { - //Check if a mandatory language is missing from being published - - var mandatoryVariant = variants.First(x => x.Culture.InvariantEquals(culture)); - - var isPublished = (contentItem.PersistedContent?.Published ?? false) && contentItem.PersistedContent.IsCulturePublished(culture); - var isPublishing = isPublished || publishingCheck(mandatoryVariant); - var isValid = !variantsWithValidationErrors?.Select(v => v.culture!).InvariantContains(culture) ?? false; - - result.Add((mandatoryVariant, isPublished || isPublishing, isValid)); - } - - //iterate over the results by invalid first - string? firstInvalidMandatoryCulture = null; - foreach (var r in result.OrderBy(x => x.isValid)) - { - if (!r.isValid) - firstInvalidMandatoryCulture = r.model.Culture; - - if (r.publishing && !r.isValid) - { - //flagged for publishing but the mandatory culture is invalid - AddVariantValidationError(r.model.Culture, r.model.Segment, "publish", "contentPublishedFailedReqCultureValidationError"); - canPublish = false; - } - else if (r.publishing && r.isValid && firstInvalidMandatoryCulture != null) - { - //in this case this culture also cannot be published because another mandatory culture is invalid - AddVariantValidationError(r.model.Culture, r.model.Segment, "publish", "contentPublishedFailedReqCultureValidationError", firstInvalidMandatoryCulture); - canPublish = false; - } - else if (!r.publishing) - { - //cannot continue publishing since a required culture that is not currently being published isn't published - AddVariantValidationError(r.model.Culture, r.model.Segment, "speechBubbles", "contentReqCulturePublishError"); - canPublish = false; - } - } - - return canPublish; - } - - /// - /// Call PublishCulture on the content item for each culture to get a validation result for each culture - /// - /// - /// - /// - /// - /// This would generally never fail unless someone is tampering with the request - /// - private bool PublishCulture(IContent persistentContent, IEnumerable cultureVariants, string? defaultCulture) - { - foreach (var variant in cultureVariants.Where(x => x.Publish)) - { - // publishing any culture, implies the invariant culture - var valid = persistentContent.PublishCulture(CultureImpact.Explicit(variant.Culture, defaultCulture.InvariantEquals(variant.Culture))); - if (!valid) - { - AddVariantValidationError(variant.Culture, variant.Segment, "speechBubbles", "contentCultureValidationError"); - return false; - } - } - - return true; - } - - private IEnumerable GetPublishedCulturesFromAncestors(IContent? content) - { - if (content?.ParentId == -1) - { - return content.PublishedCultures; - } - - HashSet publishedCultures = new (); - publishedCultures.UnionWith(content?.PublishedCultures ?? Enumerable.Empty()); - - IEnumerable? ancestorIds = content?.GetAncestorIds(); - - if (ancestorIds is not null) - { - foreach (var id in ancestorIds) - { - IEnumerable? cultures = _contentService.GetById(id)?.PublishedCultures; - publishedCultures.UnionWith(cultures ?? Enumerable.Empty()); - } - } - - return publishedCultures; - } - /// - /// Adds a generic culture error for use in displaying the culture validation error in the save/publish/etc... dialogs - /// - /// Culture to assign the error to - /// Segment to assign the error to - /// - /// - /// The culture used in the localization message, null by default which means will be used. - /// - private void AddVariantValidationError(string? culture, string? segment, string localizationArea,string localizationAlias, string? cultureToken = null) - { - var cultureToUse = cultureToken ?? culture; - var variantName = GetVariantName(cultureToUse, segment); - - var errMsg = _localizedTextService.Localize(localizationArea, localizationAlias, new[] { variantName }); - - ModelState.AddVariantValidationError(culture, segment, errMsg); - } - - /// - /// Creates the human readable variant name based on culture and segment - /// - /// Culture - /// Segment - /// - private string GetVariantName(string? culture, string? segment) - { - if (culture.IsNullOrWhiteSpace() && segment.IsNullOrWhiteSpace()) - { - // TODO: Get name for default variant from somewhere? - return "Default"; - } - - var cultureName = culture == null ? null : _allLangs.Value[culture].CultureName; - var variantName = string.Join(" — ", new[] { segment, cultureName }.Where(x => !x.IsNullOrWhiteSpace())); - - // Format: [—] - return variantName; - } - - /// - /// Publishes a document with a given ID - /// - /// - /// - /// - /// The EnsureUserPermissionForContent attribute will deny access to this method if the current user - /// does not have Publish access to this node. - /// - [Authorize(Policy = AuthorizationPolicies.ContentPermissionPublishById)] - public IActionResult PostPublishById(int id) - { - var foundContent = GetObjectFromRequest(() => _contentService.GetById(id)); - - if (foundContent == null) - { - return HandleContentNotFound(id); - } - - var publishResult = _contentService.SaveAndPublish(foundContent, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0); - if (publishResult.Success == false) - { - var notificationModel = new SimpleNotificationModel(); - AddMessageForPublishStatus(new[] { publishResult }, notificationModel); - return ValidationProblem(notificationModel); - } - - return Ok(); - - } - - [HttpDelete] - [HttpPost] - public IActionResult DeleteBlueprint(int id) - { - var found = _contentService.GetBlueprintById(id); - - if (found == null) - { - return HandleContentNotFound(id); - } - - _contentService.DeleteBlueprint(found); - - return Ok(); - } - - /// - /// Moves an item to the recycle bin, if it is already there then it will permanently delete it - /// - /// - /// - /// - /// The CanAccessContentAuthorize attribute will deny access to this method if the current user - /// does not have Delete access to this node. - /// - [Authorize(Policy = AuthorizationPolicies.ContentPermissionDeleteById)] - [HttpDelete] - [HttpPost] - public IActionResult DeleteById(int id) - { - var foundContent = GetObjectFromRequest(() => _contentService.GetById(id)); - - if (foundContent == null) - { - return HandleContentNotFound(id); - } - - //if the current item is in the recycle bin - if (foundContent.Trashed == false) - { - var moveResult = _contentService.MoveToRecycleBin(foundContent, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - if (moveResult.Success == false) - { - return ValidationProblem(); - } - } - else - { - var deleteResult = _contentService.Delete(foundContent, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - if (deleteResult.Success == false) - { - return ValidationProblem(); - } - } - - return Ok(); - } - - /// - /// Empties the recycle bin - /// - /// - /// - /// attributed with EnsureUserPermissionForContent to verify the user has access to the recycle bin - /// - [HttpDelete] - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.ContentPermissionEmptyRecycleBin)] - public IActionResult EmptyRecycleBin() - { - _contentService.EmptyRecycleBin(_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - return Ok(_localizedTextService.Localize("defaultdialogs", "recycleBinIsEmpty")); - } - - /// - /// Change the sort order for content - /// - /// - /// - public async Task PostSort(ContentSortOrder sorted) - { - if (sorted == null) - { - return NotFound(); - } - - //if there's nothing to sort just return ok - if (sorted.IdSortOrder?.Length == 0) - { - return Ok(); - } - - // Authorize... - var resource = new ContentPermissionsResource(_contentService.GetById(sorted.ParentId), ActionSort.ActionLetter); - var authorizationResult = await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); - if (!authorizationResult.Succeeded) - { - return Forbid(); - } - - try - { - // Save content with new sort order and update content xml in db accordingly - var sortResult = _contentService.Sort(sorted.IdSortOrder, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - if (!sortResult.Success) - { - _logger.LogWarning("Content sorting failed, this was probably caused by an event being cancelled"); - // TODO: Now you can cancel sorting, does the event messages bubble up automatically? - return ValidationProblem("Content sorting failed, this was probably caused by an event being cancelled"); - } - - return Ok(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not update content sort order"); - throw; - } - } - - /// - /// Change the sort order for media - /// - /// - /// - public async Task PostMove(MoveOrCopy move) - { - // Authorize... - var resource = new ContentPermissionsResource(_contentService.GetById(move.ParentId), ActionMove.ActionLetter); - var authorizationResult = await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); - if (!authorizationResult.Succeeded) - { - return Forbid(); - } - - var toMoveResult = ValidateMoveOrCopy(move); - if (!(toMoveResult.Result is null)) - { - return toMoveResult.Result; - } - var toMove = toMoveResult.Value; - - if (toMove is null) - { - return null; - } - _contentService.Move(toMove, move.ParentId, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - return Content(toMove.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); - } - - /// - /// Copies a content item and places the copy as a child of a given parent Id - /// - /// - /// - public async Task?> PostCopy(MoveOrCopy copy) - { - // Authorize... - var resource = new ContentPermissionsResource(_contentService.GetById(copy.ParentId), ActionCopy.ActionLetter); - var authorizationResult = await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); - if (!authorizationResult.Succeeded) - { - return Forbid(); - } - - var toCopyResult = ValidateMoveOrCopy(copy); - if (!(toCopyResult.Result is null)) - { - return toCopyResult.Result; - } - var toCopy = toCopyResult.Value; - if (toCopy is null) - { - return null; - } - - var c = _contentService.Copy(toCopy, copy.ParentId, copy.RelateToOriginal, copy.Recursive, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - if (c is null) - { - return null; - } - - return Content(c.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); - } - - /// - /// Unpublishes a node with a given Id and returns the unpublished entity - /// - /// The content and variants to unpublish - /// - [OutgoingEditorModelEvent] - public async Task> PostUnpublish(UnpublishContent model) - { - var foundContent = _contentService.GetById(model.Id); - - if (foundContent == null) - { - return HandleContentNotFound(model.Id); - } - - // Authorize... - var resource = new ContentPermissionsResource(foundContent, ActionUnpublish.ActionLetter); - var authorizationResult = await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); - if (!authorizationResult.Succeeded) - { - return Forbid(); - } - - var languageCount = _allLangs.Value.Count(); - if (model.Cultures?.Length == 0 || model.Cultures?.Length == languageCount) - { - //this means that the entire content item will be unpublished - var unpublishResult = _contentService.Unpublish(foundContent, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - var content = MapToDisplayWithSchedule(foundContent); - - if (!unpublishResult.Success) - { - AddCancelMessage(content); - return ValidationProblem(content); - } - else - { - content?.AddSuccessNotification( - _localizedTextService.Localize("content", "unpublish"), - _localizedTextService.Localize("speechBubbles", "contentUnpublished")); - return content; - } - } - else - { - //we only want to unpublish some of the variants - var results = new Dictionary(); - if (model.Cultures is not null) - { - foreach (var c in model.Cultures) - { - var result = _contentService.Unpublish(foundContent, culture: c, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - results[c] = result; - if (result.Result == PublishResultType.SuccessUnpublishMandatoryCulture) - { - //if this happens, it means they are all unpublished, we don't need to continue - break; - } - } - } - - var content = MapToDisplayWithSchedule(foundContent); - - //check for this status and return the correct message - if (results.Any(x => x.Value.Result == PublishResultType.SuccessUnpublishMandatoryCulture)) - { - content?.AddSuccessNotification( - _localizedTextService.Localize("content", "unpublish"), - _localizedTextService.Localize("speechBubbles", "contentMandatoryCultureUnpublished")); - return content; - } - - //otherwise add a message for each one unpublished - foreach (var r in results) - { - content?.AddSuccessNotification( - _localizedTextService.Localize("conten", "unpublish"), - _localizedTextService.Localize("speechBubbles", "contentCultureUnpublished", new[] { _allLangs.Value[r.Key].CultureName })); - } - return content; - - } - - } - - public ContentDomainsAndCulture GetCultureAndDomains(int id) - { - var nodeDomains = _domainService.GetAssignedDomains(id, true)?.ToArray(); - var wildcard = nodeDomains?.FirstOrDefault(d => d.IsWildcard); - var domains = nodeDomains?.Where(d => !d.IsWildcard).Select(d => new DomainDisplay(d.DomainName, d.LanguageId.GetValueOrDefault(0))); - return new ContentDomainsAndCulture - { - Domains = domains, - Language = wildcard == null || !wildcard.LanguageId.HasValue ? "undefined" : wildcard.LanguageId.ToString() + new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, null, contentItem.PersistedContent) }; + wasCancelled = saveResult.Result == OperationResultType.FailedCancelledByEvent; + successfulCultures = Array.Empty(); + return publishStatus; + } + } + + /// + /// Performs the publishing operation for a content item + /// + /// + /// + /// + /// + /// + /// if the content is variant this will return an array of cultures that will be published (passed validation rules) + /// + /// + /// If this is a culture variant than we need to do some validation, if it's not we'll publish as normal + /// + private PublishResult PublishInternal(ContentItemSave contentItem, string? defaultCulture, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures) + { + if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false) + { + //its invariant, proceed normally + PublishResult publishStatus = _contentService.SaveAndPublish(contentItem.PersistedContent!, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + wasCancelled = publishStatus.Result == PublishResultType.FailedPublishCancelledByEvent; + successfulCultures = null; //must be null! this implies invariant + return publishStatus; } - [HttpPost] - public ActionResult PostSaveLanguageAndDomains(DomainSave model) + var mandatoryCultures = _allLangs.Value.Values.Where(x => x.IsMandatory).Select(x => x.IsoCode).ToList(); + + IReadOnlyList<(string? culture, string? segment)>? variantErrors = + ModelState.GetVariantsWithErrors(cultureForInvariantErrors); + + var variants = contentItem.Variants.ToList(); + + //validate if we can publish based on the mandatory languages selected + var canPublish = ValidatePublishingMandatoryLanguages( + variantErrors, + contentItem, + variants, + mandatoryCultures, + mandatoryVariant => mandatoryVariant.Publish); + + //if none are published and there are validation errors for mandatory cultures, then we can't publish anything + + + //Now check if there are validation errors on each variant. + //If validation errors are detected on a variant and it's state is set to 'publish', then we + //need to change it to 'save'. + //It is a requirement that this is performed AFTER ValidatePublishingMandatoryLanguages. + foreach (ContentVariantSave variant in contentItem.Variants) { - if (model.Domains is not null) + if (variantErrors?.Contains((variant.Culture, variant.Segment)) ?? false) { - foreach (var domain in model.Domains) + variant.Publish = false; + } + } + + //At this stage all variants might have failed validation which means there are no cultures flagged for publishing! + var culturesToPublish = variants.Where(x => x.Publish).Select(x => x.Culture).WhereNotNull().ToArray(); + canPublish = canPublish && culturesToPublish.Length > 0; + + if (canPublish) + { + //try to publish all the values on the model - this will generally only fail if someone is tampering with the request + //since there's no reason variant rules would be violated in normal cases. + canPublish = PublishCulture(contentItem.PersistedContent!, variants, defaultCulture); + } + + if (canPublish) + { + //proceed to publish if all validation still succeeds + PublishResult publishStatus = _contentService.SaveAndPublish( + contentItem.PersistedContent!, + culturesToPublish, + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + wasCancelled = publishStatus.Result == PublishResultType.FailedPublishCancelledByEvent; + successfulCultures = culturesToPublish; + + return publishStatus; + } + else + { + //can only save + OperationResult saveResult = _contentService.Save( + contentItem.PersistedContent!, + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + var publishStatus = new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, null, contentItem.PersistedContent); + wasCancelled = saveResult.Result == OperationResultType.FailedCancelledByEvent; + successfulCultures = Array.Empty(); + return publishStatus; + } + } + + private void AddDomainWarnings(IEnumerable publishResults, string[]? culturesPublished, SimpleNotificationModel globalNotifications) + { + foreach (PublishResult publishResult in publishResults) + { + AddDomainWarnings(publishResult.Content, culturesPublished, globalNotifications); + } + } + + /// + /// Verifies that there's an appropriate domain setup for the published cultures + /// + /// + /// Adds a warning and logs a message if a node varies by culture, there's at least 1 culture already published, + /// and there's no domain added for the published cultures + /// + /// + /// + /// + internal void AddDomainWarnings(IContent? persistedContent, string[]? culturesPublished, SimpleNotificationModel globalNotifications) + { + // Don't try to verify if no cultures were published + if (culturesPublished is null) + { + return; + } + + var publishedCultures = GetPublishedCulturesFromAncestors(persistedContent).ToList(); + // If only a single culture is published we shouldn't have any routing issues + if (publishedCultures.Count < 2) + { + return; + } + + // If more than a single culture is published we need to verify that there's a domain registered for each published culture + HashSet? assignedDomains = persistedContent is null + ? null + : _domainService.GetAssignedDomains(persistedContent.Id, true)?.ToHashSet(); + + IEnumerable? ancestorIds = persistedContent?.GetAncestorIds(); + if (ancestorIds is not null && assignedDomains is not null) + { + // We also have to check all of the ancestors, if any of those has the appropriate culture assigned we don't need to warn + foreach (var ancestorID in ancestorIds) + { + assignedDomains.UnionWith(_domainService.GetAssignedDomains(ancestorID, true) ?? + Enumerable.Empty()); + } + } + + // No domains at all, add a warning, to add domains. + if (assignedDomains is null || assignedDomains.Count == 0) + { + globalNotifications.AddWarningNotification( + _localizedTextService.Localize("auditTrails", "publish"), + _localizedTextService.Localize("speechBubbles", "publishWithNoDomains")); + + _logger.LogWarning( + "The root node {RootNodeName} was published with multiple cultures, but no domains are configured, this will cause routing and caching issues, please register domains for: {Cultures}", + persistedContent?.Name, + string.Join(", ", publishedCultures)); + return; + } + + // If there is some domains, verify that there's a domain for each of the published cultures + foreach (var culture in culturesPublished + .Where(culture => assignedDomains.Any(x => + x.LanguageIsoCode?.Equals(culture, StringComparison.OrdinalIgnoreCase) ?? false) is false)) + { + globalNotifications.AddWarningNotification( + _localizedTextService.Localize("auditTrails", "publish"), + _localizedTextService.Localize("speechBubbles", "publishWithMissingDomain", new[] { culture })); + + _logger.LogWarning( + "The root node {RootNodeName} was published in culture {Culture}, but there's no domain configured for it, this will cause routing and caching issues, please register a domain for it", + persistedContent?.Name, + culture); + } + } + + /// + /// Validate if publishing is possible based on the mandatory language requirements + /// + /// + /// + /// + /// + /// + /// + private bool ValidatePublishingMandatoryLanguages( + IReadOnlyCollection<(string? culture, string? segment)>? variantsWithValidationErrors, + ContentItemSave contentItem, + IReadOnlyCollection variants, + IReadOnlyList mandatoryCultures, + Func publishingCheck) + { + var canPublish = true; + var result = new List<(ContentVariantSave model, bool publishing, bool isValid)>(); + + foreach (var culture in mandatoryCultures) + { + //Check if a mandatory language is missing from being published + + ContentVariantSave mandatoryVariant = variants.First(x => x.Culture.InvariantEquals(culture)); + + var isPublished = (contentItem.PersistedContent?.Published ?? false) && + contentItem.PersistedContent.IsCulturePublished(culture); + var isPublishing = isPublished || publishingCheck(mandatoryVariant); + var isValid = !variantsWithValidationErrors?.Select(v => v.culture!).InvariantContains(culture) ?? false; + + result.Add((mandatoryVariant, isPublished || isPublishing, isValid)); + } + + //iterate over the results by invalid first + string? firstInvalidMandatoryCulture = null; + foreach ((ContentVariantSave model, bool publishing, bool isValid) in result.OrderBy(x => x.isValid)) + { + if (!isValid) + { + firstInvalidMandatoryCulture = model.Culture; + } + + if (publishing && !isValid) + { + //flagged for publishing but the mandatory culture is invalid + AddVariantValidationError(model.Culture, model.Segment, "publish", "contentPublishedFailedReqCultureValidationError"); + canPublish = false; + } + else if (publishing && isValid && firstInvalidMandatoryCulture != null) + { + //in this case this culture also cannot be published because another mandatory culture is invalid + AddVariantValidationError(model.Culture, model.Segment, "publish", "contentPublishedFailedReqCultureValidationError", firstInvalidMandatoryCulture); + canPublish = false; + } + else if (!publishing) + { + //cannot continue publishing since a required culture that is not currently being published isn't published + AddVariantValidationError(model.Culture, model.Segment, "speechBubbles", "contentReqCulturePublishError"); + canPublish = false; + } + } + + return canPublish; + } + + /// + /// Call PublishCulture on the content item for each culture to get a validation result for each culture + /// + /// + /// + /// + /// + /// + /// This would generally never fail unless someone is tampering with the request + /// + private bool PublishCulture(IContent persistentContent, IEnumerable cultureVariants, string? defaultCulture) + { + foreach (ContentVariantSave variant in cultureVariants.Where(x => x.Publish)) + { + // publishing any culture, implies the invariant culture + var valid = persistentContent.PublishCulture(CultureImpact.Explicit(variant.Culture, defaultCulture.InvariantEquals(variant.Culture))); + if (!valid) + { + AddVariantValidationError(variant.Culture, variant.Segment, "speechBubbles", "contentCultureValidationError"); + return false; + } + } + + return true; + } + + private IEnumerable GetPublishedCulturesFromAncestors(IContent? content) + { + if (content?.ParentId == -1) + { + return content.PublishedCultures; + } + + HashSet publishedCultures = new(); + publishedCultures.UnionWith(content?.PublishedCultures ?? Enumerable.Empty()); + + IEnumerable? ancestorIds = content?.GetAncestorIds(); + + if (ancestorIds is not null) + { + foreach (var id in ancestorIds) + { + IEnumerable? cultures = _contentService.GetById(id)?.PublishedCultures; + publishedCultures.UnionWith(cultures ?? Enumerable.Empty()); + } + } + + return publishedCultures; + } + + /// + /// Adds a generic culture error for use in displaying the culture validation error in the save/publish/etc... dialogs + /// + /// Culture to assign the error to + /// Segment to assign the error to + /// + /// + /// + /// The culture used in the localization message, null by default which means will be used. + /// + private void AddVariantValidationError(string? culture, string? segment, string localizationArea, string localizationAlias, string? cultureToken = null) + { + var cultureToUse = cultureToken ?? culture; + var variantName = GetVariantName(cultureToUse, segment); + + var errMsg = _localizedTextService.Localize(localizationArea, localizationAlias, new[] { variantName }); + + ModelState.AddVariantValidationError(culture, segment, errMsg); + } + + /// + /// Creates the human readable variant name based on culture and segment + /// + /// Culture + /// Segment + /// + private string GetVariantName(string? culture, string? segment) + { + if (culture.IsNullOrWhiteSpace() && segment.IsNullOrWhiteSpace()) + { + // TODO: Get name for default variant from somewhere? + return "Default"; + } + + var cultureName = culture == null ? null : _allLangs.Value[culture].CultureName; + var variantName = string.Join(" — ", new[] { segment, cultureName }.Where(x => !x.IsNullOrWhiteSpace())); + + // Format: [—] + return variantName; + } + + /// + /// Publishes a document with a given ID + /// + /// + /// + /// + /// The EnsureUserPermissionForContent attribute will deny access to this method if the current user + /// does not have Publish access to this node. + /// + [Authorize(Policy = AuthorizationPolicies.ContentPermissionPublishById)] + public IActionResult PostPublishById(int id) + { + IContent? foundContent = GetObjectFromRequest(() => _contentService.GetById(id)); + + if (foundContent == null) + { + return HandleContentNotFound(id); + } + + PublishResult publishResult = _contentService.SaveAndPublish(foundContent, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0); + if (publishResult.Success == false) + { + var notificationModel = new SimpleNotificationModel(); + AddMessageForPublishStatus(new[] { publishResult }, notificationModel); + return ValidationProblem(notificationModel); + } + + return Ok(); + } + + [HttpDelete] + [HttpPost] + public IActionResult DeleteBlueprint(int id) + { + IContent? found = _contentService.GetBlueprintById(id); + + if (found == null) + { + return HandleContentNotFound(id); + } + + _contentService.DeleteBlueprint(found); + + return Ok(); + } + + /// + /// Moves an item to the recycle bin, if it is already there then it will permanently delete it + /// + /// + /// + /// + /// The CanAccessContentAuthorize attribute will deny access to this method if the current user + /// does not have Delete access to this node. + /// + [Authorize(Policy = AuthorizationPolicies.ContentPermissionDeleteById)] + [HttpDelete] + [HttpPost] + public IActionResult DeleteById(int id) + { + IContent? foundContent = GetObjectFromRequest(() => _contentService.GetById(id)); + + if (foundContent == null) + { + return HandleContentNotFound(id); + } + + //if the current item is in the recycle bin + if (foundContent.Trashed == false) + { + OperationResult moveResult = _contentService.MoveToRecycleBin(foundContent, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + if (moveResult.Success == false) + { + return ValidationProblem(); + } + } + else + { + OperationResult deleteResult = _contentService.Delete( + foundContent, + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + if (deleteResult.Success == false) + { + return ValidationProblem(); + } + } + + return Ok(); + } + + /// + /// Empties the recycle bin + /// + /// + /// + /// attributed with EnsureUserPermissionForContent to verify the user has access to the recycle bin + /// + [HttpDelete] + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.ContentPermissionEmptyRecycleBin)] + public IActionResult EmptyRecycleBin() + { + _contentService.EmptyRecycleBin(_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + return Ok(_localizedTextService.Localize("defaultdialogs", "recycleBinIsEmpty")); + } + + /// + /// Change the sort order for content + /// + /// + /// + public async Task PostSort(ContentSortOrder sorted) + { + if (sorted == null) + { + return NotFound(); + } + + //if there's nothing to sort just return ok + if (sorted.IdSortOrder?.Length == 0) + { + return Ok(); + } + + // Authorize... + var resource = + new ContentPermissionsResource(_contentService.GetById(sorted.ParentId), ActionSort.ActionLetter); + AuthorizationResult authorizationResult = + await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); + if (!authorizationResult.Succeeded) + { + return Forbid(); + } + + try + { + // Save content with new sort order and update content xml in db accordingly + OperationResult sortResult = _contentService.Sort( + sorted.IdSortOrder, + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + if (!sortResult.Success) + { + _logger.LogWarning("Content sorting failed, this was probably caused by an event being cancelled"); + // TODO: Now you can cancel sorting, does the event messages bubble up automatically? + return ValidationProblem( + "Content sorting failed, this was probably caused by an event being cancelled"); + } + + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not update content sort order"); + throw; + } + } + + /// + /// Change the sort order for media + /// + /// + /// + public async Task PostMove(MoveOrCopy move) + { + // Authorize... + var resource = new ContentPermissionsResource(_contentService.GetById(move.ParentId), ActionMove.ActionLetter); + AuthorizationResult authorizationResult = + await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); + if (!authorizationResult.Succeeded) + { + return Forbid(); + } + + ActionResult toMoveResult = ValidateMoveOrCopy(move); + if (!(toMoveResult.Result is null)) + { + return toMoveResult.Result; + } + + IContent? toMove = toMoveResult.Value; + + if (toMove is null) + { + return null; + } + + _contentService.Move(toMove, move.ParentId, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + return Content(toMove.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); + } + + /// + /// Copies a content item and places the copy as a child of a given parent Id + /// + /// + /// + public async Task?> PostCopy(MoveOrCopy copy) + { + // Authorize... + var resource = new ContentPermissionsResource(_contentService.GetById(copy.ParentId), ActionCopy.ActionLetter); + AuthorizationResult authorizationResult = + await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); + if (!authorizationResult.Succeeded) + { + return Forbid(); + } + + ActionResult toCopyResult = ValidateMoveOrCopy(copy); + if (!(toCopyResult.Result is null)) + { + return toCopyResult.Result; + } + + IContent? toCopy = toCopyResult.Value; + if (toCopy is null) + { + return null; + } + + IContent? c = _contentService.Copy(toCopy, copy.ParentId, copy.RelateToOriginal, copy.Recursive, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + if (c is null) + { + return null; + } + + return Content(c.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); + } + + /// + /// Unpublishes a node with a given Id and returns the unpublished entity + /// + /// The content and variants to unpublish + /// + [OutgoingEditorModelEvent] + public async Task> PostUnpublish(UnpublishContent model) + { + IContent? foundContent = _contentService.GetById(model.Id); + + if (foundContent == null) + { + return HandleContentNotFound(model.Id); + } + + // Authorize... + var resource = new ContentPermissionsResource(foundContent, ActionUnpublish.ActionLetter); + AuthorizationResult authorizationResult = + await _authorizationService.AuthorizeAsync(User, resource, AuthorizationPolicies.ContentPermissionByResource); + if (!authorizationResult.Succeeded) + { + return Forbid(); + } + + var languageCount = _allLangs.Value.Count(); + if (model.Cultures?.Length == 0 || model.Cultures?.Length == languageCount) + { + //this means that the entire content item will be unpublished + PublishResult unpublishResult = _contentService.Unpublish(foundContent, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + ContentItemDisplayWithSchedule? content = MapToDisplayWithSchedule(foundContent); + + if (!unpublishResult.Success) + { + AddCancelMessage(content); + return ValidationProblem(content); + } + + content?.AddSuccessNotification( + _localizedTextService.Localize("content", "unpublish"), + _localizedTextService.Localize("speechBubbles", "contentUnpublished")); + return content; + } + else + { + //we only want to unpublish some of the variants + var results = new Dictionary(); + if (model.Cultures is not null) + { + foreach (var c in model.Cultures) { - try + PublishResult result = _contentService.Unpublish(foundContent, c, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + results[c] = result; + if (result.Result == PublishResultType.SuccessUnpublishMandatoryCulture) { - var uri = DomainUtilities.ParseUriFromDomainName(domain.Name, new Uri(Request.GetEncodedUrl())); - } - catch (UriFormatException) - { - return ValidationProblem(_localizedTextService.Localize("assignDomain", "invalidDomain")); + //if this happens, it means they are all unpublished, we don't need to continue + break; } } } - var node = _contentService.GetById(model.NodeId); + ContentItemDisplayWithSchedule? content = MapToDisplayWithSchedule(foundContent); - if (node == null) + //check for this status and return the correct message + if (results.Any(x => x.Value.Result == PublishResultType.SuccessUnpublishMandatoryCulture)) { - HttpContext.SetReasonPhrase("Node Not Found."); - return NotFound("There is no content node with id {model.NodeId}."); + content?.AddSuccessNotification( + _localizedTextService.Localize("content", "unpublish"), + _localizedTextService.Localize("speechBubbles", "contentMandatoryCultureUnpublished")); + return content; } - var permission = _userService.GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, node.Path); - - - if (permission?.AssignedPermissions.Contains(ActionAssignDomain.ActionLetter.ToString(), StringComparer.Ordinal) == false) + //otherwise add a message for each one unpublished + foreach (KeyValuePair r in results) { - HttpContext.SetReasonPhrase("Permission Denied."); - return BadRequest("You do not have permission to assign domains on that node."); + content?.AddSuccessNotification( + _localizedTextService.Localize("conten", "unpublish"), + _localizedTextService.Localize("speechBubbles", "contentCultureUnpublished", new[] { _allLangs.Value[r.Key].CultureName })); } - model.Valid = true; - var domains = _domainService.GetAssignedDomains(model.NodeId, true)?.ToArray(); - var languages = _localizationService.GetAllLanguages().ToArray(); - var language = model.Language > 0 ? languages.FirstOrDefault(l => l.Id == model.Language) : null; + return content; + } + } - // process wildcard - if (language != null) + public ContentDomainsAndCulture GetCultureAndDomains(int id) + { + IDomain[]? nodeDomains = _domainService.GetAssignedDomains(id, true)?.ToArray(); + IDomain? wildcard = nodeDomains?.FirstOrDefault(d => d.IsWildcard); + IEnumerable? domains = nodeDomains?.Where(d => !d.IsWildcard) + .Select(d => new DomainDisplay(d.DomainName, d.LanguageId.GetValueOrDefault(0))); + return new ContentDomainsAndCulture + { + Domains = domains, + Language = wildcard == null || !wildcard.LanguageId.HasValue + ? "undefined" + : wildcard.LanguageId.ToString() + }; + } + + [HttpPost] + public ActionResult PostSaveLanguageAndDomains(DomainSave model) + { + if (model.Domains is not null) + { + foreach (DomainDisplay domain in model.Domains) + { + try + { + Uri uri = DomainUtilities.ParseUriFromDomainName(domain.Name, new Uri(Request.GetEncodedUrl())); + } + catch (UriFormatException) + { + return ValidationProblem(_localizedTextService.Localize("assignDomain", "invalidDomain")); + } + } + } + + IContent? node = _contentService.GetById(model.NodeId); + + if (node == null) + { + HttpContext.SetReasonPhrase("Node Not Found."); + return NotFound("There is no content node with id {model.NodeId}."); + } + + EntityPermission? permission = + _userService.GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, node.Path); + + + if (permission?.AssignedPermissions.Contains(ActionAssignDomain.ActionLetter.ToString(), StringComparer.Ordinal) == false) + { + HttpContext.SetReasonPhrase("Permission Denied."); + return BadRequest("You do not have permission to assign domains on that node."); + } + + model.Valid = true; + IDomain[]? domains = _domainService.GetAssignedDomains(model.NodeId, true)?.ToArray(); + ILanguage[] languages = _localizationService.GetAllLanguages().ToArray(); + ILanguage? language = model.Language > 0 ? languages.FirstOrDefault(l => l.Id == model.Language) : null; + + // process wildcard + if (language != null) + { + // yet there is a race condition here... + IDomain? wildcard = domains?.FirstOrDefault(d => d.IsWildcard); + if (wildcard != null) + { + wildcard.LanguageId = language.Id; + } + else + { + wildcard = new UmbracoDomain("*" + model.NodeId) + { + LanguageId = model.Language, + RootContentId = model.NodeId + }; + } + + Attempt saveAttempt = _domainService.Save(wildcard); + if (saveAttempt == false) + { + HttpContext.SetReasonPhrase(saveAttempt.Result?.Result.ToString()); + return BadRequest("Saving domain failed"); + } + } + else + { + IDomain? wildcard = domains?.FirstOrDefault(d => d.IsWildcard); + if (wildcard != null) + { + _domainService.Delete(wildcard); + } + } + + // process domains + // delete every (non-wildcard) domain, that exists in the DB yet is not in the model + foreach (IDomain domain in domains?.Where(d => + d.IsWildcard == false && + (model.Domains?.All(m => m.Name.InvariantEquals(d.DomainName) == false) ?? + false)) ?? + Array.Empty()) + { + _domainService.Delete(domain); + } + + var names = new List(); + + // create or update domains in the model + foreach (DomainDisplay domainModel in model.Domains?.Where(m => string.IsNullOrWhiteSpace(m.Name) == false) ?? + Array.Empty()) + { + language = languages.FirstOrDefault(l => l.Id == domainModel.Lang); + if (language == null) + { + continue; + } + + var name = domainModel.Name.ToLowerInvariant(); + if (names.Contains(name)) + { + domainModel.Duplicate = true; + continue; + } + + names.Add(name); + IDomain? domain = domains?.FirstOrDefault(d => d.DomainName.InvariantEquals(domainModel.Name)); + if (domain != null) + { + domain.LanguageId = language.Id; + _domainService.Save(domain); + } + else if (_domainService.Exists(domainModel.Name)) + { + domainModel.Duplicate = true; + IDomain? xdomain = _domainService.GetByName(domainModel.Name); + var xrcid = xdomain?.RootContentId; + if (xrcid.HasValue) + { + IContent? xcontent = _contentService.GetById(xrcid.Value); + var xnames = new List(); + while (xcontent != null) + { + if (xcontent.Name is not null) + { + xnames.Add(xcontent.Name); + } + + if (xcontent.ParentId < -1) + { + xnames.Add("Recycle Bin"); + } + + xcontent = _contentService.GetParent(xcontent); + } + + xnames.Reverse(); + domainModel.Other = "/" + string.Join("/", xnames); + } + } + else { // yet there is a race condition here... - var wildcard = domains?.FirstOrDefault(d => d.IsWildcard); - if (wildcard != null) - { - wildcard.LanguageId = language.Id; - } - else - { - wildcard = new UmbracoDomain("*" + model.NodeId) - { - LanguageId = model.Language, - RootContentId = model.NodeId - }; - } - - var saveAttempt = _domainService.Save(wildcard); + var newDomain = new UmbracoDomain(name) { LanguageId = domainModel.Lang, RootContentId = model.NodeId }; + Attempt saveAttempt = _domainService.Save(newDomain); if (saveAttempt == false) { HttpContext.SetReasonPhrase(saveAttempt.Result?.Result.ToString()); - return BadRequest("Saving domain failed"); + return BadRequest("Saving new domain failed"); } } - else + } + + model.Valid = model.Domains?.All(m => m.Duplicate == false) ?? false; + + return model; + } + + /// + /// Ensure there is culture specific errors in the result if any errors are for culture properties + /// and we're dealing with variant content, then call the base class HandleInvalidModelState + /// + /// + /// + /// + /// This is required to wire up the validation in the save/publish dialog + /// + private void HandleInvalidModelState( + ContentItemDisplay? display, + string? cultureForInvariantErrors) + where TVariant : ContentVariantDisplay + { + if (!ModelState.IsValid && display?.Variants?.Count() > 1) + { + //Add any culture specific errors here + IReadOnlyList<(string? culture, string? segment)>? variantErrors = + ModelState.GetVariantsWithErrors(cultureForInvariantErrors); + + if (variantErrors is not null) { - var wildcard = domains?.FirstOrDefault(d => d.IsWildcard); - if (wildcard != null) + foreach ((string? culture, string? segment) in variantErrors) { - _domainService.Delete(wildcard); + AddVariantValidationError(culture, segment, "speechBubbles", "contentCultureValidationError"); } } + } + } - // process domains - // delete every (non-wildcard) domain, that exists in the DB yet is not in the model - foreach (var domain in domains?.Where(d => d.IsWildcard == false && (model.Domains?.All(m => m.Name.InvariantEquals(d.DomainName) == false) ?? false)) ?? Array.Empty()) + /// + /// Maps the dto property values and names to the persisted model + /// + /// + private void MapValuesForPersistence(ContentItemSave contentSave) + { + // inline method to determine the culture and segment to persist the property + static (string? culture, string? segment) PropertyCultureAndSegment(IProperty? property, ContentVariantSave variant) + { + var culture = property?.PropertyType.VariesByCulture() ?? false ? variant.Culture : null; + var segment = property?.PropertyType.VariesBySegment() ?? false ? variant.Segment : null; + return (culture, segment); + } + + var variantIndex = 0; + var defaultCulture = _allLangs.Value.Values.FirstOrDefault(x => x.IsDefault)?.IsoCode; + + // loop through each variant, set the correct name and property values + foreach (ContentVariantSave variant in contentSave.Variants) + { + // Don't update anything for this variant if Save is not true + if (!variant.Save) { - _domainService.Delete(domain); + continue; } - var names = new List(); - - // create or update domains in the model - foreach (var domainModel in model.Domains?.Where(m => string.IsNullOrWhiteSpace(m.Name) == false) ?? Array.Empty()) + // Don't update the name if it is empty + if (!variant.Name.IsNullOrWhiteSpace()) { - language = languages.FirstOrDefault(l => l.Id == domainModel.Lang); - if (language == null) + if (contentSave.PersistedContent?.ContentType.VariesByCulture() ?? false) { - continue; - } - - var name = domainModel.Name.ToLowerInvariant(); - if (names.Contains(name)) - { - domainModel.Duplicate = true; - continue; - } - names.Add(name); - var domain = domains?.FirstOrDefault(d => d.DomainName.InvariantEquals(domainModel.Name)); - if (domain != null) - { - domain.LanguageId = language.Id; - _domainService.Save(domain); - } - else if (_domainService.Exists(domainModel.Name)) - { - domainModel.Duplicate = true; - var xdomain = _domainService.GetByName(domainModel.Name); - var xrcid = xdomain?.RootContentId; - if (xrcid.HasValue) + if (variant.Culture.IsNullOrWhiteSpace()) { - var xcontent = _contentService.GetById(xrcid.Value); - var xnames = new List(); - while (xcontent != null) - { - if (xcontent.Name is not null) - { - xnames.Add(xcontent.Name); - } - if (xcontent.ParentId < -1) - xnames.Add("Recycle Bin"); - xcontent = _contentService.GetParent(xcontent); - } - xnames.Reverse(); - domainModel.Other = "/" + string.Join("/", xnames); + throw new InvalidOperationException("Cannot set culture name without a culture."); + } + + contentSave.PersistedContent.SetCultureName(variant.Name, variant.Culture); + + // If the variant culture is the default culture we also want to update the name on the Content itself. + if (variant.Culture?.Equals(defaultCulture, StringComparison.InvariantCultureIgnoreCase) ?? false) + { + contentSave.PersistedContent.Name = variant.Name; } } else { - // yet there is a race condition here... - var newDomain = new UmbracoDomain(name) + if (contentSave.PersistedContent is not null) { - LanguageId = domainModel.Lang, - RootContentId = model.NodeId - }; - var saveAttempt = _domainService.Save(newDomain); - if (saveAttempt == false) - { - HttpContext.SetReasonPhrase(saveAttempt.Result?.Result.ToString()); - return BadRequest("Saving new domain failed"); + contentSave.PersistedContent.Name = variant.Name; } } } - model.Valid = model.Domains?.All(m => m.Duplicate == false) ?? false; - - return model; - } - - /// - /// Ensure there is culture specific errors in the result if any errors are for culture properties - /// and we're dealing with variant content, then call the base class HandleInvalidModelState - /// - /// - /// - /// This is required to wire up the validation in the save/publish dialog - /// - private void HandleInvalidModelState(ContentItemDisplay? display, string? cultureForInvariantErrors) - where TVariant : ContentVariantDisplay - { - if (!ModelState.IsValid && display?.Variants?.Count() > 1) - { - //Add any culture specific errors here - var variantErrors = ModelState.GetVariantsWithErrors(cultureForInvariantErrors); - - if (variantErrors is not null) + // This is important! We only want to process invariant properties with the first variant, for any other variant + // we need to exclude invariant properties from being processed, otherwise they will be double processed for the + // same value which can cause some problems with things such as file uploads. + ContentPropertyCollectionDto? propertyCollection = variantIndex == 0 + ? variant.PropertyCollectionDto + : new ContentPropertyCollectionDto { - foreach (var (culture, segment) in variantErrors) - { - AddVariantValidationError(culture, segment, "speechBubbles", "contentCultureValidationError"); - } - } - } - } - - /// - /// Maps the dto property values and names to the persisted model - /// - /// - private void MapValuesForPersistence(ContentItemSave contentSave) - { - // inline method to determine the culture and segment to persist the property - (string? culture, string? segment) PropertyCultureAndSegment(IProperty? property, ContentVariantSave variant) - { - var culture = property?.PropertyType.VariesByCulture() ?? false ? variant.Culture : null; - var segment = property?.PropertyType.VariesBySegment() ?? false ? variant.Segment : null; - return (culture, segment); - } - - var variantIndex = 0; - var defaultCulture = _allLangs.Value.Values.FirstOrDefault(x => x.IsDefault)?.IsoCode; - - // loop through each variant, set the correct name and property values - foreach (var variant in contentSave.Variants) - { - // Don't update anything for this variant if Save is not true - if (!variant.Save) - { - continue; - } - - // Don't update the name if it is empty - if (!variant.Name.IsNullOrWhiteSpace()) - { - if (contentSave.PersistedContent?.ContentType.VariesByCulture() ?? false) - { - if (variant.Culture.IsNullOrWhiteSpace()) - { - throw new InvalidOperationException($"Cannot set culture name without a culture."); - } - - contentSave.PersistedContent.SetCultureName(variant.Name, variant.Culture); - - // If the variant culture is the default culture we also want to update the name on the Content itself. - if (variant.Culture?.Equals(defaultCulture, StringComparison.InvariantCultureIgnoreCase) ?? false) - { - contentSave.PersistedContent.Name = variant.Name; - } - } - else - { - if (contentSave.PersistedContent is not null) - { - contentSave.PersistedContent.Name = variant.Name; - } - } - } - - // This is important! We only want to process invariant properties with the first variant, for any other variant - // we need to exclude invariant properties from being processed, otherwise they will be double processed for the - // same value which can cause some problems with things such as file uploads. - var propertyCollection = variantIndex == 0 - ? variant.PropertyCollectionDto - : new ContentPropertyCollectionDto - { - Properties = variant.PropertyCollectionDto?.Properties.Where( - x => !x.Culture.IsNullOrWhiteSpace() || !x.Segment.IsNullOrWhiteSpace()) ?? Enumerable.Empty(), - }; - - // for each variant, map the property values - MapPropertyValuesForPersistence( - contentSave, - propertyCollection, - (save, property) => - { - // Get property value - (var culture, var segment) = PropertyCultureAndSegment(property, variant); - return property?.GetValue(culture, segment); - }, - (save, property, v) => - { - // Set property value - (var culture, var segment) = PropertyCultureAndSegment(property, variant); - property?.SetValue(v, culture, segment); - }, - variant.Culture); - - variantIndex++; - } - - // Map IsDirty cultures to edited cultures, to make it easier to verify changes on specific variants on Saving and Saved events. - IEnumerable? editedCultures = contentSave.PersistedContent?.CultureInfos?.Values - .Where(x => x.IsDirty()) - .Select(x => x.Culture); - contentSave.PersistedContent?.SetCultureEdited(editedCultures); - - // handle template - if (string.IsNullOrWhiteSpace(contentSave.TemplateAlias)) // cleared: clear if not already null - { - if (contentSave.PersistedContent?.TemplateId != null) - { - contentSave.PersistedContent.TemplateId = null; - } - } - else // set: update if different - { - ITemplate? template = _fileService.GetTemplate(contentSave.TemplateAlias); - if (template is null) - { - // ModelState.AddModelError("Template", "No template exists with the specified alias: " + contentItem.TemplateAlias); - _logger.LogWarning("No template exists with the specified alias: {TemplateAlias}", contentSave.TemplateAlias); - } - else if (contentSave.PersistedContent is not null && template.Id != contentSave.PersistedContent.TemplateId) - { - contentSave.PersistedContent.TemplateId = template.Id; - } - } - } - - /// - /// Ensures the item can be moved/copied to the new location - /// - /// - /// - private ActionResult ValidateMoveOrCopy(MoveOrCopy model) - { - if (model == null) - { - return NotFound(); - } - - var contentService = _contentService; - var toMove = contentService.GetById(model.Id); - if (toMove == null) - { - return NotFound(); - } - if (model.ParentId < 0) - { - //cannot move if the content item is not allowed at the root - if (toMove.ContentType.AllowedAsRoot == false) - { - return ValidationProblem( - _localizedTextService.Localize("moveOrCopy", "notAllowedAtRoot")); - } - } - else - { - var parent = contentService.GetById(model.ParentId); - if (parent == null) - { - return NotFound(); - } - - var parentContentType = _contentTypeService.Get(parent.ContentTypeId); - //check if the item is allowed under this one - if (parentContentType?.AllowedContentTypes?.Select(x => x.Id).ToArray() - .Any(x => x.Value == toMove.ContentType.Id) == false) - { - return ValidationProblem( - _localizedTextService.Localize("moveOrCopy", "notAllowedByContentType")); - } - - // Check on paths - if ($",{parent.Path},".IndexOf($",{toMove.Id},", StringComparison.Ordinal) > -1) - { - return ValidationProblem( - _localizedTextService.Localize("moveOrCopy", "notAllowedByPath")); - } - } - - return new ActionResult(toMove); - } - - /// - /// Adds notification messages to the outbound display model for a given published status - /// - /// - /// - /// - /// This is null when dealing with invariant content, else it's the cultures that were successfully published - /// - private void AddMessageForPublishStatus(IReadOnlyCollection statuses, INotificationModel display, string[]? successfulCultures = null) - { - var totalStatusCount = statuses.Count(); - - //Put the statuses into groups, each group results in a different message - var statusGroup = statuses.GroupBy(x => - { - switch (x.Result) - { - case PublishResultType.SuccessPublish: - case PublishResultType.SuccessPublishCulture: - //these 2 belong to a single group - return PublishResultType.SuccessPublish; - case PublishResultType.FailedPublishAwaitingRelease: - case PublishResultType.FailedPublishCultureAwaitingRelease: - //these 2 belong to a single group - return PublishResultType.FailedPublishAwaitingRelease; - case PublishResultType.FailedPublishHasExpired: - case PublishResultType.FailedPublishCultureHasExpired: - //these 2 belong to a single group - return PublishResultType.FailedPublishHasExpired; - case PublishResultType.SuccessPublishAlready: - case PublishResultType.FailedPublishPathNotPublished: - case PublishResultType.FailedPublishCancelledByEvent: - case PublishResultType.FailedPublishIsTrashed: - case PublishResultType.FailedPublishContentInvalid: - case PublishResultType.FailedPublishMandatoryCultureMissing: - //the rest that we are looking for each belong in their own group - return x.Result; - default: - throw new IndexOutOfRangeException($"{x.Result}\" was not expected."); - } - }); - - foreach (var status in statusGroup) - { - switch (status.Key) - { - case PublishResultType.SuccessPublishAlready: - { - // TODO: Here we should have messaging for when there are release dates specified like https://github.com/umbraco/Umbraco-CMS/pull/3507 - // but this will take a bit of effort because we need to deal with variants, different messaging, etc... A quick attempt was made here: - // http://github.com/umbraco/Umbraco-CMS/commit/9b3de7b655e07c612c824699b48a533c0448131a - - //special case, we will only show messages for this if: - // * it's not a bulk publish operation - // * it's a bulk publish operation and all successful statuses are this one - var itemCount = status.Count(); - if (totalStatusCount == 1 || totalStatusCount == itemCount) - { - if (successfulCultures == null || totalStatusCount == itemCount) - { - //either invariant single publish, or bulk publish where all statuses are already published - display.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles", "editContentPublishedHeader"), - _localizedTextService.Localize("speechBubbles", "editContentPublishedText")); - } - else - { - foreach (var c in successfulCultures) - { - display.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles", "editContentPublishedHeader"), - _localizedTextService.Localize("speechBubbles", "editVariantPublishedText", new[] { _allLangs.Value[c].CultureName })); - } - } - } - } - break; - case PublishResultType.SuccessPublish: - { - // TODO: Here we should have messaging for when there are release dates specified like https://github.com/umbraco/Umbraco-CMS/pull/3507 - // but this will take a bit of effort because we need to deal with variants, different messaging, etc... A quick attempt was made here: - // http://github.com/umbraco/Umbraco-CMS/commit/9b3de7b655e07c612c824699b48a533c0448131a - - var itemCount = status.Count(); - if (successfulCultures == null) - { - display.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles", "editContentPublishedHeader"), - totalStatusCount > 1 - ? _localizedTextService.Localize("speechBubbles", "editMultiContentPublishedText", new[] { itemCount.ToInvariantString() }) - : _localizedTextService.Localize("speechBubbles", "editContentPublishedText")); - } - else - { - foreach (var c in successfulCultures) - { - display.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles", "editContentPublishedHeader"), - totalStatusCount > 1 - ? _localizedTextService.Localize("speechBubbles", "editMultiVariantPublishedText", new[] { itemCount.ToInvariantString(), _allLangs.Value[c].CultureName }) - : _localizedTextService.Localize("speechBubbles", "editVariantPublishedText", new[] { _allLangs.Value[c].CultureName })); - } - } - } - break; - case PublishResultType.FailedPublishPathNotPublished: - { - //TODO: This doesn't take into account variations with the successfulCultures param - var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); - display.AddWarningNotification( - _localizedTextService.Localize(null,"publish"), - _localizedTextService.Localize("publish", "contentPublishedFailedByParent", - new[] { names }).Trim()); - } - break; - case PublishResultType.FailedPublishCancelledByEvent: - { - //TODO: This doesn't take into account variations with the successfulCultures param - var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); - AddCancelMessage(display, "publish","contentPublishedFailedByEvent", messageParams: new[] { names }); - } - break; - case PublishResultType.FailedPublishAwaitingRelease: - { - //TODO: This doesn't take into account variations with the successfulCultures param - var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); - display.AddWarningNotification( - _localizedTextService.Localize(null,"publish"), - _localizedTextService.Localize("publish", "contentPublishedFailedAwaitingRelease", - new[] { names }).Trim()); - } - break; - case PublishResultType.FailedPublishHasExpired: - { - //TODO: This doesn't take into account variations with the successfulCultures param - var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); - display.AddWarningNotification( - _localizedTextService.Localize(null,"publish"), - _localizedTextService.Localize("publish", "contentPublishedFailedExpired", - new[] { names }).Trim()); - } - break; - case PublishResultType.FailedPublishIsTrashed: - { - //TODO: This doesn't take into account variations with the successfulCultures param - var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); - display.AddWarningNotification( - _localizedTextService.Localize(null,"publish"), - _localizedTextService.Localize("publish", "contentPublishedFailedIsTrashed", - new[] { names }).Trim()); - } - break; - case PublishResultType.FailedPublishContentInvalid: - { - if (successfulCultures == null) - { - var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); - display.AddWarningNotification( - _localizedTextService.Localize(null,"publish"), - _localizedTextService.Localize("publish", "contentPublishedFailedInvalid", - new[] { names }).Trim()); - } - else - { - foreach (var c in successfulCultures) - { - var names = string.Join(", ", status.Select(x => $"'{(x.Content?.ContentType.VariesByCulture() ?? false ? x.Content.GetCultureName(c) : x.Content?.Name)}'")); - display.AddWarningNotification( - _localizedTextService.Localize(null,"publish"), - _localizedTextService.Localize("publish", "contentPublishedFailedInvalid", - new[] { names }).Trim()); - } - } - } - break; - case PublishResultType.FailedPublishMandatoryCultureMissing: - display.AddWarningNotification( - _localizedTextService.Localize(null,"publish"), - "publish/contentPublishedFailedByCulture"); - break; - default: - throw new IndexOutOfRangeException($"PublishedResultType \"{status.Key}\" was not expected."); - } - } - } - - /// - /// Used to map an instance to a and ensuring a language is present if required - /// - /// - /// - private ContentItemDisplay? MapToDisplay(IContent? content) => - MapToDisplay(content, context => - { - context.Items["CurrentUser"] = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - }); - - private ContentItemDisplayWithSchedule? MapToDisplayWithSchedule(IContent? content) - { - if (content is null) - { - return null; - } - - ContentItemDisplayWithSchedule? display = _umbracoMapper.Map(content, context => - { - context.Items["CurrentUser"] = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - context.Items["Schedule"] = _contentService.GetContentScheduleByContentId(content.Id); - }); - - if (display is not null) - { - display.AllowPreview = display.AllowPreview && content?.Trashed == false && content.ContentType.IsElement == false; - } - - return display; - } - - /// - /// Used to map an instance to a and ensuring AllowPreview is set correctly. - /// Also allows you to pass in an action for the mapper context where you can pass additional information on to the mapper. - /// - /// - /// - /// - private ContentItemDisplay? MapToDisplay(IContent? content, Action contextOptions) - { - ContentItemDisplay? display = _umbracoMapper.Map(content, contextOptions); - if (display is not null) - { - display.AllowPreview = display.AllowPreview && content?.Trashed == false && content.ContentType.IsElement == false; - } - - return display; - } - - [Authorize(Policy = AuthorizationPolicies.ContentPermissionBrowseById)] - public ActionResult> GetNotificationOptions(int contentId) - { - var notifications = new List(); - if (contentId <= 0) return NotFound(); - - var content = _contentService.GetById(contentId); - if (content == null) return NotFound(); - - var userNotifications = _notificationService.GetUserNotifications(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, content.Path)?.ToList(); - - foreach (var a in _actionCollection.Where(x => x.ShowInNotifier)) - { - var n = new NotifySetting - { - Name = _localizedTextService.Localize("actions", a.Alias), - Checked = userNotifications?.FirstOrDefault(x => x.Action == a.Letter.ToString()) != null, - NotifyCode = a.Letter.ToString() - }; - notifications.Add(n); - } - - return notifications; - } - - public IActionResult PostNotificationOptions(int contentId, [FromQuery(Name = "notifyOptions[]")] string[] notifyOptions) - { - if (contentId <= 0) return NotFound(); - var content = _contentService.GetById(contentId); - if (content == null) return NotFound(); - - _notificationService.SetNotifications(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, content, notifyOptions); - - return NoContent(); - } - - [HttpGet] - [JsonCamelCaseFormatter] - public IActionResult GetPagedContentVersions( - int contentId, - int pageNumber = 1, - int pageSize = 10, - string? culture = null) - { - if (!string.IsNullOrEmpty(culture)) - { - if (!_allLangs.Value.TryGetValue(culture, out _)) - { - return NotFound(); - } - } - - IEnumerable? results = _contentVersionService.GetPagedContentVersions( - contentId, - pageNumber - 1, - pageSize, - out var totalRecords, - culture); - - var model = new PagedResult(totalRecords, pageNumber, pageSize) - { - Items = results - }; - - return Ok(model); - } - - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.ContentPermissionAdministrationById)] - public IActionResult PostSetContentVersionPreventCleanup(int contentId, int versionId, bool preventCleanup) - { - IContent? content = _contentService.GetVersion(versionId); - - if (content == null || content.Id != contentId) - { - return NotFound(); - } - - _contentVersionService.SetPreventCleanup(versionId, preventCleanup, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - return NoContent(); - } - - [HttpGet] - public IEnumerable GetRollbackVersions(int contentId, string? culture = null) - { - var rollbackVersions = new List(); - var writerIds = new HashSet(); - - var versions = _contentService.GetVersionsSlim(contentId, 0, 50); - - //Not all nodes are variants & thus culture can be null - if (culture != null) - { - //Get cultures that were published with the version = their update date is equal to the version's - versions = versions.Where(x => x.UpdateDate == x.GetUpdateDate(culture)); - } - - //First item is our current item/state (cant rollback to ourselves) - versions = versions.Skip(1); - - foreach (var version in versions) - { - var rollbackVersion = new RollbackVersion - { - VersionId = version.VersionId, - VersionDate = version.UpdateDate, - VersionAuthorId = version.WriterId + Properties = variant.PropertyCollectionDto?.Properties.Where( + x => !x.Culture.IsNullOrWhiteSpace() || !x.Segment.IsNullOrWhiteSpace()) ?? + Enumerable.Empty() }; - rollbackVersions.Add(rollbackVersion); + // for each variant, map the property values + MapPropertyValuesForPersistence( + contentSave, + propertyCollection, + (save, property) => + { + // Get property value + (string? culture, string? segment) = PropertyCultureAndSegment(property, variant); + return property?.GetValue(culture, segment); + }, + (save, property, v) => + { + // Set property value + (string? culture, string? segment) = PropertyCultureAndSegment(property, variant); + property?.SetValue(v, culture, segment); + }, + variant.Culture); - writerIds.Add(version.WriterId); - } - - var users = _userService - .GetUsersById(writerIds.ToArray()) - .ToDictionary(x => x.Id, x => x.Name); - - foreach (var rollbackVersion in rollbackVersions) - { - if (users.TryGetValue(rollbackVersion.VersionAuthorId, out var userName)) - rollbackVersion.VersionAuthorName = userName; - } - - return rollbackVersions; + variantIndex++; } - [HttpGet] - public ContentVariantDisplay? GetRollbackVersion(int versionId, string? culture = null) + // Map IsDirty cultures to edited cultures, to make it easier to verify changes on specific variants on Saving and Saved events. + IEnumerable? editedCultures = contentSave.PersistedContent?.CultureInfos?.Values + .Where(x => x.IsDirty()) + .Select(x => x.Culture); + contentSave.PersistedContent?.SetCultureEdited(editedCultures); + + // handle template + if (string.IsNullOrWhiteSpace(contentSave.TemplateAlias)) // cleared: clear if not already null { - var version = _contentService.GetVersion(versionId); - var content = MapToDisplay(version); - - return culture == null - ? content?.Variants?.FirstOrDefault() //No culture set - so this is an invariant node - so just list me the first item in here - : content?.Variants?.FirstOrDefault(x => x.Language?.IsoCode == culture); - } - - [Authorize(Policy = AuthorizationPolicies.ContentPermissionRollbackById)] - [HttpPost] - public IActionResult PostRollbackContent(int contentId, int versionId, string culture = "*") - { - var rollbackResult = _contentService.Rollback(contentId, versionId, culture, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - if (rollbackResult.Success) - return Ok(); - - switch (rollbackResult.Result) + if (contentSave.PersistedContent?.TemplateId != null) { - case OperationResultType.Failed: - case OperationResultType.FailedCannot: - case OperationResultType.FailedExceptionThrown: - case OperationResultType.NoOperation: - default: - return ValidationProblem(_localizedTextService.Localize("speechBubbles", "operationFailedHeader")); - case OperationResultType.FailedCancelledByEvent: - return ValidationProblem( - _localizedTextService.Localize("speechBubbles", "operationCancelledHeader"), - _localizedTextService.Localize("speechBubbles", "operationCancelledText")); + contentSave.PersistedContent.TemplateId = null; + } + } + else // set: update if different + { + ITemplate? template = _fileService.GetTemplate(contentSave.TemplateAlias); + if (template is null) + { + // ModelState.AddModelError("Template", "No template exists with the specified alias: " + contentItem.TemplateAlias); + _logger.LogWarning("No template exists with the specified alias: {TemplateAlias}", contentSave.TemplateAlias); + } + else if (contentSave.PersistedContent is not null && template.Id != contentSave.PersistedContent.TemplateId) + { + contentSave.PersistedContent.TemplateId = template.Id; } } } + + /// + /// Ensures the item can be moved/copied to the new location + /// + /// + /// + private ActionResult ValidateMoveOrCopy(MoveOrCopy model) + { + if (model == null) + { + return NotFound(); + } + + IContentService contentService = _contentService; + IContent? toMove = contentService.GetById(model.Id); + if (toMove == null) + { + return NotFound(); + } + + if (model.ParentId < 0) + { + //cannot move if the content item is not allowed at the root + if (toMove.ContentType.AllowedAsRoot == false) + { + return ValidationProblem( + _localizedTextService.Localize("moveOrCopy", "notAllowedAtRoot")); + } + } + else + { + IContent? parent = contentService.GetById(model.ParentId); + if (parent == null) + { + return NotFound(); + } + + IContentType? parentContentType = _contentTypeService.Get(parent.ContentTypeId); + //check if the item is allowed under this one + if (parentContentType?.AllowedContentTypes?.Select(x => x.Id).ToArray() + .Any(x => x.Value == toMove.ContentType.Id) == false) + { + return ValidationProblem( + _localizedTextService.Localize("moveOrCopy", "notAllowedByContentType")); + } + + // Check on paths + if ($",{parent.Path},".IndexOf($",{toMove.Id},", StringComparison.Ordinal) > -1) + { + return ValidationProblem( + _localizedTextService.Localize("moveOrCopy", "notAllowedByPath")); + } + } + + return new ActionResult(toMove); + } + + /// + /// Adds notification messages to the outbound display model for a given published status + /// + /// + /// + /// + /// This is null when dealing with invariant content, else it's the cultures that were successfully published + /// + private void AddMessageForPublishStatus(IReadOnlyCollection statuses, INotificationModel display, string[]? successfulCultures = null) + { + var totalStatusCount = statuses.Count(); + + //Put the statuses into groups, each group results in a different message + IEnumerable> statusGroup = statuses.GroupBy(x => + { + switch (x.Result) + { + case PublishResultType.SuccessPublish: + case PublishResultType.SuccessPublishCulture: + //these 2 belong to a single group + return PublishResultType.SuccessPublish; + case PublishResultType.FailedPublishAwaitingRelease: + case PublishResultType.FailedPublishCultureAwaitingRelease: + //these 2 belong to a single group + return PublishResultType.FailedPublishAwaitingRelease; + case PublishResultType.FailedPublishHasExpired: + case PublishResultType.FailedPublishCultureHasExpired: + //these 2 belong to a single group + return PublishResultType.FailedPublishHasExpired; + case PublishResultType.SuccessPublishAlready: + case PublishResultType.FailedPublishPathNotPublished: + case PublishResultType.FailedPublishCancelledByEvent: + case PublishResultType.FailedPublishIsTrashed: + case PublishResultType.FailedPublishContentInvalid: + case PublishResultType.FailedPublishMandatoryCultureMissing: + //the rest that we are looking for each belong in their own group + return x.Result; + default: + throw new IndexOutOfRangeException($"{x.Result}\" was not expected."); + } + }); + + foreach (IGrouping status in statusGroup) + { + switch (status.Key) + { + case PublishResultType.SuccessPublishAlready: + { + // TODO: Here we should have messaging for when there are release dates specified like https://github.com/umbraco/Umbraco-CMS/pull/3507 + // but this will take a bit of effort because we need to deal with variants, different messaging, etc... A quick attempt was made here: + // http://github.com/umbraco/Umbraco-CMS/commit/9b3de7b655e07c612c824699b48a533c0448131a + + //special case, we will only show messages for this if: + // * it's not a bulk publish operation + // * it's a bulk publish operation and all successful statuses are this one + var itemCount = status.Count(); + if (totalStatusCount == 1 || totalStatusCount == itemCount) + { + if (successfulCultures == null || totalStatusCount == itemCount) + { + //either invariant single publish, or bulk publish where all statuses are already published + display.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "editContentPublishedHeader"), + _localizedTextService.Localize("speechBubbles", "editContentPublishedText")); + } + else + { + foreach (var c in successfulCultures) + { + display.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "editContentPublishedHeader"), + _localizedTextService.Localize("speechBubbles", "editVariantPublishedText", new[] { _allLangs.Value[c].CultureName })); + } + } + } + } + break; + case PublishResultType.SuccessPublish: + { + // TODO: Here we should have messaging for when there are release dates specified like https://github.com/umbraco/Umbraco-CMS/pull/3507 + // but this will take a bit of effort because we need to deal with variants, different messaging, etc... A quick attempt was made here: + // http://github.com/umbraco/Umbraco-CMS/commit/9b3de7b655e07c612c824699b48a533c0448131a + + var itemCount = status.Count(); + if (successfulCultures == null) + { + display.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "editContentPublishedHeader"), + totalStatusCount > 1 + ? _localizedTextService.Localize("speechBubbles", "editMultiContentPublishedText", new[] { itemCount.ToInvariantString() }) + : _localizedTextService.Localize("speechBubbles", "editContentPublishedText")); + } + else + { + foreach (var c in successfulCultures) + { + display.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "editContentPublishedHeader"), + totalStatusCount > 1 + ? _localizedTextService.Localize("speechBubbles", "editMultiVariantPublishedText", new[] { itemCount.ToInvariantString(), _allLangs.Value[c].CultureName }) + : _localizedTextService.Localize("speechBubbles", "editVariantPublishedText", new[] { _allLangs.Value[c].CultureName })); + } + } + } + break; + case PublishResultType.FailedPublishPathNotPublished: + { + //TODO: This doesn't take into account variations with the successfulCultures param + var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); + display.AddWarningNotification( + _localizedTextService.Localize(null, "publish"), + _localizedTextService.Localize("publish", "contentPublishedFailedByParent", new[] { names }).Trim()); + } + break; + case PublishResultType.FailedPublishCancelledByEvent: + { + //TODO: This doesn't take into account variations with the successfulCultures param + var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); + AddCancelMessage(display, "publish", "contentPublishedFailedByEvent", new[] { names }); + } + break; + case PublishResultType.FailedPublishAwaitingRelease: + { + //TODO: This doesn't take into account variations with the successfulCultures param + var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); + display.AddWarningNotification( + _localizedTextService.Localize(null, "publish"), + _localizedTextService.Localize("publish", "contentPublishedFailedAwaitingRelease", new[] { names }).Trim()); + } + break; + case PublishResultType.FailedPublishHasExpired: + { + //TODO: This doesn't take into account variations with the successfulCultures param + var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); + display.AddWarningNotification( + _localizedTextService.Localize(null, "publish"), + _localizedTextService.Localize("publish", "contentPublishedFailedExpired", new[] { names }).Trim()); + } + break; + case PublishResultType.FailedPublishIsTrashed: + { + //TODO: This doesn't take into account variations with the successfulCultures param + var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); + display.AddWarningNotification( + _localizedTextService.Localize(null, "publish"), + _localizedTextService.Localize("publish", "contentPublishedFailedIsTrashed", new[] { names }).Trim()); + } + break; + case PublishResultType.FailedPublishContentInvalid: + { + if (successfulCultures == null) + { + var names = string.Join(", ", status.Select(x => $"'{x.Content?.Name}'")); + display.AddWarningNotification( + _localizedTextService.Localize(null, "publish"), + _localizedTextService.Localize("publish", "contentPublishedFailedInvalid", new[] { names }).Trim()); + } + else + { + foreach (var c in successfulCultures) + { + var names = string.Join( + ", ", + status.Select(x => + $"'{(x.Content?.ContentType.VariesByCulture() ?? false ? x.Content.GetCultureName(c) : x.Content?.Name)}'")); + display.AddWarningNotification( + _localizedTextService.Localize(null, "publish"), + _localizedTextService.Localize("publish", "contentPublishedFailedInvalid", new[] { names }).Trim()); + } + } + } + break; + case PublishResultType.FailedPublishMandatoryCultureMissing: + display.AddWarningNotification( + _localizedTextService.Localize(null, "publish"), + "publish/contentPublishedFailedByCulture"); + break; + default: + throw new IndexOutOfRangeException($"PublishedResultType \"{status.Key}\" was not expected."); + } + } + } + + /// + /// Used to map an instance to a and ensuring a language is + /// present if required + /// + /// + /// + private ContentItemDisplay? MapToDisplay(IContent? content) => + MapToDisplay(content, context => + { + context.Items["CurrentUser"] = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + }); + + private ContentItemDisplayWithSchedule? MapToDisplayWithSchedule(IContent? content) + { + if (content is null) + { + return null; + } + + ContentItemDisplayWithSchedule? display = _umbracoMapper.Map(content, context => + { + context.Items["CurrentUser"] = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + context.Items["Schedule"] = _contentService.GetContentScheduleByContentId(content.Id); + }); + + if (display is not null) + { + display.AllowPreview = display.AllowPreview && content?.Trashed == false && + content.ContentType.IsElement == false; + } + + return display; + } + + /// + /// Used to map an instance to a and ensuring AllowPreview is + /// set correctly. + /// Also allows you to pass in an action for the mapper context where you can pass additional information on to the + /// mapper. + /// + /// + /// + /// + private ContentItemDisplay? MapToDisplay(IContent? content, Action contextOptions) + { + ContentItemDisplay? display = _umbracoMapper.Map(content, contextOptions); + if (display is not null) + { + display.AllowPreview = display.AllowPreview && content?.Trashed == false && + content.ContentType.IsElement == false; + } + + return display; + } + + [Authorize(Policy = AuthorizationPolicies.ContentPermissionBrowseById)] + public ActionResult> GetNotificationOptions(int contentId) + { + var notifications = new List(); + if (contentId <= 0) + { + return NotFound(); + } + + IContent? content = _contentService.GetById(contentId); + if (content == null) + { + return NotFound(); + } + + var userNotifications = _notificationService + .GetUserNotifications(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, content.Path)?.ToList(); + + foreach (IAction a in _actionCollection.Where(x => x.ShowInNotifier)) + { + var n = new NotifySetting + { + Name = _localizedTextService.Localize("actions", a.Alias), + Checked = userNotifications?.FirstOrDefault(x => x.Action == a.Letter.ToString()) != null, + NotifyCode = a.Letter.ToString() + }; + notifications.Add(n); + } + + return notifications; + } + + public IActionResult PostNotificationOptions( + int contentId, + [FromQuery(Name = "notifyOptions[]")] string[] notifyOptions) + { + if (contentId <= 0) + { + return NotFound(); + } + + IContent? content = _contentService.GetById(contentId); + if (content == null) + { + return NotFound(); + } + + _notificationService.SetNotifications(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, content, notifyOptions); + + return NoContent(); + } + + [HttpGet] + [JsonCamelCaseFormatter] + public IActionResult GetPagedContentVersions( + int contentId, + int pageNumber = 1, + int pageSize = 10, + string? culture = null) + { + if (!string.IsNullOrEmpty(culture)) + { + if (!_allLangs.Value.TryGetValue(culture, out _)) + { + return NotFound(); + } + } + + IEnumerable? results = _contentVersionService.GetPagedContentVersions( + contentId, + pageNumber - 1, + pageSize, + out var totalRecords, + culture); + + var model = new PagedResult(totalRecords, pageNumber, pageSize) { Items = results }; + + return Ok(model); + } + + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.ContentPermissionAdministrationById)] + public IActionResult PostSetContentVersionPreventCleanup(int contentId, int versionId, bool preventCleanup) + { + IContent? content = _contentService.GetVersion(versionId); + + if (content == null || content.Id != contentId) + { + return NotFound(); + } + + _contentVersionService.SetPreventCleanup(versionId, preventCleanup, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + return NoContent(); + } + + [HttpGet] + public IEnumerable GetRollbackVersions(int contentId, string? culture = null) + { + var rollbackVersions = new List(); + var writerIds = new HashSet(); + + IEnumerable versions = _contentService.GetVersionsSlim(contentId, 0, 50); + + //Not all nodes are variants & thus culture can be null + if (culture != null) + { + //Get cultures that were published with the version = their update date is equal to the version's + versions = versions.Where(x => x.UpdateDate == x.GetUpdateDate(culture)); + } + + //First item is our current item/state (cant rollback to ourselves) + versions = versions.Skip(1); + + foreach (IContent version in versions) + { + var rollbackVersion = new RollbackVersion + { + VersionId = version.VersionId, + VersionDate = version.UpdateDate, + VersionAuthorId = version.WriterId + }; + + rollbackVersions.Add(rollbackVersion); + + writerIds.Add(version.WriterId); + } + + var users = _userService + .GetUsersById(writerIds.ToArray()) + .ToDictionary(x => x.Id, x => x.Name); + + foreach (RollbackVersion rollbackVersion in rollbackVersions) + { + if (users.TryGetValue(rollbackVersion.VersionAuthorId, out var userName)) + { + rollbackVersion.VersionAuthorName = userName; + } + } + + return rollbackVersions; + } + + [HttpGet] + public ContentVariantDisplay? GetRollbackVersion(int versionId, string? culture = null) + { + IContent? version = _contentService.GetVersion(versionId); + ContentItemDisplay? content = MapToDisplay(version); + + return culture == null + ? content?.Variants + ?.FirstOrDefault() //No culture set - so this is an invariant node - so just list me the first item in here + : content?.Variants?.FirstOrDefault(x => x.Language?.IsoCode == culture); + } + + [Authorize(Policy = AuthorizationPolicies.ContentPermissionRollbackById)] + [HttpPost] + public IActionResult PostRollbackContent(int contentId, int versionId, string culture = "*") + { + OperationResult rollbackResult = _contentService.Rollback(contentId, versionId, culture, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + if (rollbackResult.Success) + { + return Ok(); + } + + switch (rollbackResult.Result) + { + case OperationResultType.Failed: + case OperationResultType.FailedCannot: + case OperationResultType.FailedExceptionThrown: + case OperationResultType.NoOperation: + default: + return ValidationProblem(_localizedTextService.Localize("speechBubbles", "operationFailedHeader")); + case OperationResultType.FailedCancelledByEvent: + return ValidationProblem( + _localizedTextService.Localize("speechBubbles", "operationCancelledHeader"), + _localizedTextService.Localize("speechBubbles", "operationCancelledText")); + } + } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs index 8678972c2c..795257800a 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Dictionary; @@ -14,228 +12,229 @@ using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// An abstract base controller used for media/content/members to try to reduce code replication. +/// +[JsonDateTimeFormat] +public abstract class ContentControllerBase : BackOfficeNotificationsController { + private readonly ILogger _logger; + private readonly IJsonSerializer _serializer; + /// - /// An abstract base controller used for media/content/members to try to reduce code replication. + /// Initializes a new instance of the class. /// - [JsonDateTimeFormat] - public abstract class ContentControllerBase : BackOfficeNotificationsController + protected ContentControllerBase( + ICultureDictionary cultureDictionary, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper, + IEventMessagesFactory eventMessages, + ILocalizedTextService localizedTextService, + IJsonSerializer serializer) { - private readonly ILogger _logger; - private readonly IJsonSerializer _serializer; + CultureDictionary = cultureDictionary; + LoggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + ShortStringHelper = shortStringHelper; + EventMessages = eventMessages; + LocalizedTextService = localizedTextService; + _serializer = serializer; + } - /// - /// Initializes a new instance of the class. - /// - protected ContentControllerBase( - ICultureDictionary cultureDictionary, - ILoggerFactory loggerFactory, - IShortStringHelper shortStringHelper, - IEventMessagesFactory eventMessages, - ILocalizedTextService localizedTextService, - IJsonSerializer serializer) + /// + /// Gets the + /// + protected ICultureDictionary CultureDictionary { get; } + + /// + /// Gets the + /// + protected ILoggerFactory LoggerFactory { get; } + + /// + /// Gets the + /// + protected IShortStringHelper ShortStringHelper { get; } + + /// + /// Gets the + /// + protected IEventMessagesFactory EventMessages { get; } + + /// + /// Gets the + /// + protected ILocalizedTextService LocalizedTextService { get; } + + /// + /// Handles if the content for the specified ID isn't found + /// + /// The content ID to find + /// Whether to throw an exception + /// The error response + protected NotFoundObjectResult HandleContentNotFound(object id) + { + ModelState.AddModelError("id", $"content with id: {id} was not found"); + NotFoundObjectResult errorResponse = NotFound(ModelState); + + + return errorResponse; + } + + /// + /// Maps the dto property values to the persisted model + /// + internal void MapPropertyValuesForPersistence( + TSaved contentItem, + ContentPropertyCollectionDto? dto, + Func getPropertyValue, + Action savePropertyValue, + string? culture) + where TPersisted : IContentBase + where TSaved : IContentSave + { + if (dto is null) { - CultureDictionary = cultureDictionary; - LoggerFactory = loggerFactory; - _logger = loggerFactory.CreateLogger(); - ShortStringHelper = shortStringHelper; - EventMessages = eventMessages; - LocalizedTextService = localizedTextService; - _serializer = serializer; + return; } - /// - /// Gets the - /// - protected ICultureDictionary CultureDictionary { get; } - - /// - /// Gets the - /// - protected ILoggerFactory LoggerFactory { get; } - - /// - /// Gets the - /// - protected IShortStringHelper ShortStringHelper { get; } - - /// - /// Gets the - /// - protected IEventMessagesFactory EventMessages { get; } - - /// - /// Gets the - /// - protected ILocalizedTextService LocalizedTextService { get; } - - /// - /// Handles if the content for the specified ID isn't found - /// - /// The content ID to find - /// Whether to throw an exception - /// The error response - protected NotFoundObjectResult HandleContentNotFound(object id) + // map the property values + foreach (ContentPropertyDto propertyDto in dto.Properties) { - ModelState.AddModelError("id", $"content with id: {id} was not found"); - NotFoundObjectResult errorResponse = NotFound(ModelState); - - - return errorResponse; - } - - /// - /// Maps the dto property values to the persisted model - /// - internal void MapPropertyValuesForPersistence( - TSaved contentItem, - ContentPropertyCollectionDto? dto, - Func getPropertyValue, - Action savePropertyValue, - string? culture) - where TPersisted : IContentBase - where TSaved : IContentSave - { - if (dto is null) + // get the property editor + if (propertyDto.PropertyEditor == null) { - return; + _logger.LogWarning("No property editor found for property {PropertyAlias}", propertyDto.Alias); + continue; } - // map the property values - foreach (ContentPropertyDto propertyDto in dto.Properties) + // get the value editor + // nothing to save/map if it is readonly + IDataValueEditor valueEditor = propertyDto.PropertyEditor.GetValueEditor(); + if (valueEditor.IsReadOnly) { - // get the property editor - if (propertyDto.PropertyEditor == null) - { - _logger.LogWarning("No property editor found for property {PropertyAlias}", propertyDto.Alias); - continue; - } - - // get the value editor - // nothing to save/map if it is readonly - IDataValueEditor valueEditor = propertyDto.PropertyEditor.GetValueEditor(); - if (valueEditor.IsReadOnly) - { - continue; - } - - // get the property - IProperty property = contentItem.PersistedContent.Properties[propertyDto.Alias]!; - - // prepare files, if any matching property and culture - ContentPropertyFile[] files = contentItem.UploadedFiles - .Where(x => x.PropertyAlias == propertyDto.Alias && x.Culture == propertyDto.Culture && x.Segment == propertyDto.Segment) - .ToArray(); - - foreach (ContentPropertyFile file in files) - { - file.FileName = file.FileName?.ToSafeFileName(ShortStringHelper); - } - - - // create the property data for the property editor - var data = new ContentPropertyData(propertyDto.Value, propertyDto.DataType?.Configuration) - { - ContentKey = contentItem.PersistedContent!.Key, - PropertyTypeKey = property.PropertyType.Key, - Files = files - }; - - // let the editor convert the value that was received, deal with files, etc - object? value = valueEditor.FromEditor(data, getPropertyValue(contentItem, property)); - - // set the value - tags are special - TagsPropertyEditorAttribute? tagAttribute = propertyDto.PropertyEditor.GetTagAttribute(); - if (tagAttribute != null) - { - TagConfiguration? tagConfiguration = ConfigurationEditor.ConfigurationAs(propertyDto.DataType?.Configuration); - if (tagConfiguration is not null && tagConfiguration.Delimiter == default) - { - tagConfiguration.Delimiter = tagAttribute.Delimiter; - } - - var tagCulture = property?.PropertyType.VariesByCulture() ?? false ? culture : null; - property?.SetTagsValue(_serializer, value, tagConfiguration, tagCulture); - } - else - { - savePropertyValue(contentItem, property, value); - } - } - } - - /// - /// A helper method to attempt to get the instance from the request storage if it can be found there, - /// otherwise gets it from the callback specified - /// - /// - /// - /// - /// - /// This is useful for when filters have already looked up a persisted entity and we don't want to have - /// to look it up again. - /// - protected TPersisted? GetObjectFromRequest(Func getFromService) - { - // checks if the request contains the key and the item is not null, if that is the case, return it from the request, otherwise return - // it from the callback - return HttpContext.Items.ContainsKey(typeof(TPersisted).ToString()) && HttpContext.Items[typeof(TPersisted).ToString()] != null - ? (TPersisted?)HttpContext.Items[typeof(TPersisted).ToString()] - : getFromService(); - } - - /// - /// Returns true if the action passed in means we need to create something new - /// - /// The content action - /// Returns true if this is a creating action - internal static bool IsCreatingAction(ContentSaveAction action) => action.ToString().EndsWith("New"); - - /// - /// Adds a cancelled message to the display - /// - /// - /// - /// - /// - protected void AddCancelMessage( - INotificationModel? display, - string messageArea = "speechBubbles", - string messageAlias ="operationCancelledText", - string[]? messageParams = null) - { - // if there's already a default event message, don't add our default one - IEventMessagesFactory messages = EventMessages; - if (messages != null && (messages.GetOrDefault()?.GetAll().Any(x => x.IsDefaultEventMessage) ?? false)) - { - return; + continue; } - display?.AddWarningNotification( - LocalizedTextService.Localize("speechBubbles", "operationCancelledHeader"), - LocalizedTextService.Localize(messageArea, messageAlias, messageParams)); - } + // get the property + IProperty property = contentItem.PersistedContent.Properties[propertyDto.Alias]!; - /// - /// Adds a cancelled message to the display - /// - /// - /// - /// - /// - /// - /// - protected void AddCancelMessage(INotificationModel display, string message) - { - // if there's already a default event message, don't add our default one - IEventMessagesFactory messages = EventMessages; - if (messages?.GetOrDefault()?.GetAll().Any(x => x.IsDefaultEventMessage) == true) + // prepare files, if any matching property and culture + ContentPropertyFile[] files = contentItem.UploadedFiles + .Where(x => x.PropertyAlias == propertyDto.Alias && x.Culture == propertyDto.Culture && + x.Segment == propertyDto.Segment) + .ToArray(); + + foreach (ContentPropertyFile file in files) { - return; + file.FileName = file.FileName?.ToSafeFileName(ShortStringHelper); } - display.AddWarningNotification(LocalizedTextService.Localize("speechBubbles", "operationCancelledHeader"), message); + + // create the property data for the property editor + var data = new ContentPropertyData(propertyDto.Value, propertyDto.DataType?.Configuration) + { + ContentKey = contentItem.PersistedContent!.Key, + PropertyTypeKey = property.PropertyType.Key, + Files = files + }; + + // let the editor convert the value that was received, deal with files, etc + var value = valueEditor.FromEditor(data, getPropertyValue(contentItem, property)); + + // set the value - tags are special + TagsPropertyEditorAttribute? tagAttribute = propertyDto.PropertyEditor.GetTagAttribute(); + if (tagAttribute != null) + { + TagConfiguration? tagConfiguration = + ConfigurationEditor.ConfigurationAs(propertyDto.DataType?.Configuration); + if (tagConfiguration is not null && tagConfiguration.Delimiter == default) + { + tagConfiguration.Delimiter = tagAttribute.Delimiter; + } + + var tagCulture = property?.PropertyType.VariesByCulture() ?? false ? culture : null; + property?.SetTagsValue(_serializer, value, tagConfiguration, tagCulture); + } + else + { + savePropertyValue(contentItem, property, value); + } } } + + /// + /// A helper method to attempt to get the instance from the request storage if it can be found there, + /// otherwise gets it from the callback specified + /// + /// + /// + /// + /// + /// This is useful for when filters have already looked up a persisted entity and we don't want to have + /// to look it up again. + /// + protected TPersisted? GetObjectFromRequest(Func getFromService) => + // checks if the request contains the key and the item is not null, if that is the case, return it from the request, otherwise return + // it from the callback + HttpContext.Items.ContainsKey(typeof(TPersisted).ToString()) && + HttpContext.Items[typeof(TPersisted).ToString()] != null + ? (TPersisted?)HttpContext.Items[typeof(TPersisted).ToString()] + : getFromService(); + + /// + /// Returns true if the action passed in means we need to create something new + /// + /// The content action + /// Returns true if this is a creating action + internal static bool IsCreatingAction(ContentSaveAction action) => action.ToString().EndsWith("New"); + + /// + /// Adds a cancelled message to the display + /// + /// + /// + /// + /// + protected void AddCancelMessage( + INotificationModel? display, + string messageArea = "speechBubbles", + string messageAlias = "operationCancelledText", + string[]? messageParams = null) + { + // if there's already a default event message, don't add our default one + IEventMessagesFactory messages = EventMessages; + if (messages != null && (messages.GetOrDefault()?.GetAll().Any(x => x.IsDefaultEventMessage) ?? false)) + { + return; + } + + display?.AddWarningNotification( + LocalizedTextService.Localize("speechBubbles", "operationCancelledHeader"), + LocalizedTextService.Localize(messageArea, messageAlias, messageParams)); + } + + /// + /// Adds a cancelled message to the display + /// + /// + /// + /// + /// + /// + /// + protected void AddCancelMessage(INotificationModel display, string message) + { + // if there's already a default event message, don't add our default one + IEventMessagesFactory messages = EventMessages; + if (messages?.GetOrDefault()?.GetAll().Any(x => x.IsDefaultEventMessage) == true) + { + return; + } + + display.AddWarningNotification(LocalizedTextService.Localize("speechBubbles", "operationCancelledHeader"), + message); + } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentTypeController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentTypeController.cs index 1dbe63fed5..6dc0824efc 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentTypeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentTypeController.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net.Mime; using System.Text; using System.Xml; @@ -23,625 +19,635 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Infrastructure.Packaging; using Umbraco.Cms.Web.BackOffice.Filters; -using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; using ContentType = Umbraco.Cms.Core.Models.ContentType; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// An API controller used for dealing with content types +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +public class ContentTypeController : ContentTypeControllerBase { - /// - /// An API controller used for dealing with content types - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - public class ContentTypeController : ContentTypeControllerBase + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IContentService _contentService; + private readonly IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider; + private readonly IContentTypeService _contentTypeService; + private readonly IDataTypeService _dataTypeService; + private readonly IFileService _fileService; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly ILocalizedTextService _localizedTextService; + private readonly ILogger _logger; + private readonly PackageDataInstallation _packageDataInstallation; + + private readonly PropertyEditorCollection _propertyEditors; + // TODO: Split this controller apart so that authz is consistent, currently we need to authz each action individually. + // It would be possible to have something like a ContentTypeInfoController for the GetAllPropertyTypeAliases/GetCount/GetAllowedChildren/etc... actions + + private readonly IEntityXmlSerializer _serializer; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; + + public ContentTypeController( + ICultureDictionary cultureDictionary, + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService, + IUmbracoMapper umbracoMapper, + ILocalizedTextService localizedTextService, + IEntityXmlSerializer serializer, + PropertyEditorCollection propertyEditors, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IDataTypeService dataTypeService, + IShortStringHelper shortStringHelper, + IFileService fileService, + ILogger logger, + IContentService contentService, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + IHostingEnvironment hostingEnvironment, + EditorValidatorCollection editorValidatorCollection, + PackageDataInstallation packageDataInstallation) + : base( + cultureDictionary, + editorValidatorCollection, + contentTypeService, + mediaTypeService, + memberTypeService, + umbracoMapper, + localizedTextService) { - // TODO: Split this controller apart so that authz is consistent, currently we need to authz each action individually. - // It would be possible to have something like a ContentTypeInfoController for the GetAllPropertyTypeAliases/GetCount/GetAllowedChildren/etc... actions + _serializer = serializer; + _propertyEditors = propertyEditors; + _contentTypeService = contentTypeService; + _umbracoMapper = umbracoMapper; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _dataTypeService = dataTypeService; + _shortStringHelper = shortStringHelper; + _localizedTextService = localizedTextService; + _fileService = fileService; + _logger = logger; + _contentService = contentService; + _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider; + _hostingEnvironment = hostingEnvironment; + _packageDataInstallation = packageDataInstallation; + } - private readonly IEntityXmlSerializer _serializer; - private readonly PropertyEditorCollection _propertyEditors; - private readonly IContentTypeService _contentTypeService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IDataTypeService _dataTypeService; - private readonly IShortStringHelper _shortStringHelper; - private readonly ILocalizedTextService _localizedTextService; - private readonly IFileService _fileService; - private readonly ILogger _logger; - private readonly IContentService _contentService; - private readonly IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly PackageDataInstallation _packageDataInstallation; + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public int GetCount() => _contentTypeService.Count(); - public ContentTypeController( - ICultureDictionary cultureDictionary, - IContentTypeService contentTypeService, - IMediaTypeService mediaTypeService, - IMemberTypeService memberTypeService, - IUmbracoMapper umbracoMapper, - ILocalizedTextService localizedTextService, - IEntityXmlSerializer serializer, - PropertyEditorCollection propertyEditors, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IDataTypeService dataTypeService, - IShortStringHelper shortStringHelper, - IFileService fileService, - ILogger logger, - IContentService contentService, - IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, - IHostingEnvironment hostingEnvironment, - EditorValidatorCollection editorValidatorCollection, - PackageDataInstallation packageDataInstallation) - : base(cultureDictionary, - editorValidatorCollection, - contentTypeService, - mediaTypeService, - memberTypeService, - umbracoMapper, - localizedTextService) + [HttpGet] + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public bool HasContentNodes(int id) => _contentTypeService.HasContentNodes(id); + + /// + /// Gets the document type a given id + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public ActionResult GetById(int id) + { + IContentType? ct = _contentTypeService.Get(id); + if (ct == null) { - _serializer = serializer; - _propertyEditors = propertyEditors; - _contentTypeService = contentTypeService; - _umbracoMapper = umbracoMapper; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _dataTypeService = dataTypeService; - _shortStringHelper = shortStringHelper; - _localizedTextService = localizedTextService; - _fileService = fileService; - _logger = logger; - _contentService = contentService; - _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider; - _hostingEnvironment = hostingEnvironment; - _packageDataInstallation = packageDataInstallation; + return NotFound(); } - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public int GetCount() + DocumentTypeDisplay? dto = _umbracoMapper.Map(ct); + return dto; + } + + /// + /// Gets the document type a given guid + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public ActionResult GetById(Guid id) + { + IContentType? contentType = _contentTypeService.Get(id); + if (contentType == null) { - return _contentTypeService.Count(); + return NotFound(); } - [HttpGet] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public bool HasContentNodes(int id) + DocumentTypeDisplay? dto = _umbracoMapper.Map(contentType); + return dto; + } + + /// + /// Gets the document type a given udi + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) { - return _contentTypeService.HasContentNodes(id); + return NotFound(); } - /// - /// Gets the document type a given id - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public ActionResult GetById(int id) + IContentType? contentType = _contentTypeService.Get(guidUdi.Guid); + if (contentType == null) { - var ct = _contentTypeService.Get(id); - if (ct == null) + return NotFound(); + } + + DocumentTypeDisplay? dto = _umbracoMapper.Map(contentType); + return dto; + } + + /// + /// Deletes a document type with a given ID + /// + [HttpDelete] + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public IActionResult DeleteById(int id) + { + IContentType? foundType = _contentTypeService.Get(id); + if (foundType == null) + { + return NotFound(); + } + + _contentTypeService.Delete(foundType, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + return Ok(); + } + + /// + /// Gets all user defined properties. + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessAnyContentOrTypes)] + public IEnumerable GetAllPropertyTypeAliases() => _contentTypeService.GetAllPropertyTypeAliases(); + + /// + /// Gets all the standard fields. + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessAnyContentOrTypes)] + public IEnumerable GetAllStandardFields() + { + string[] preValuesSource = + { + "createDate", "creatorName", "level", "nodeType", "nodeTypeAlias", "pageID", "pageName", "parentID", + "path", "template", "updateDate", "writerID", "writerName" + }; + return preValuesSource; + } + + /// + /// Returns the available compositions for this content type + /// This has been wrapped in a dto instead of simple parameters to support having multiple parameters in post request + /// body + /// + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public ActionResult GetAvailableCompositeContentTypes(GetAvailableCompositionsFilter filter) + { + ActionResult>> actionResult = PerformGetAvailableCompositeContentTypes( + filter.ContentTypeId, + UmbracoObjectTypes.DocumentType, + filter.FilterContentTypes, + filter.FilterPropertyTypes, + filter.IsElement); + + if (!(actionResult.Result is null)) + { + return actionResult.Result; + } + + var result = actionResult.Value? + .Select(x => new { contentType = x.Item1, allowed = x.Item2 }); + return Ok(result); + } + + /// + /// Returns true if any content types have culture variation enabled + /// + [HttpGet] + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] + public bool AllowsCultureVariation() + { + IEnumerable contentTypes = _contentTypeService.GetAll(); + return contentTypes.Any(contentType => contentType.VariesByCulture()); + } + + /// + /// Returns where a particular composition has been used + /// This has been wrapped in a dto instead of simple parameters to support having multiple parameters in post request + /// body + /// + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public IActionResult GetWhereCompositionIsUsedInContentTypes(GetAvailableCompositionsFilter filter) + { + var result = + PerformGetWhereCompositionIsUsedInContentTypes(filter.ContentTypeId, UmbracoObjectTypes.DocumentType).Value? + .Select(x => new { contentType = x }); + return Ok(result); + } + + [Authorize(Policy = AuthorizationPolicies.TreeAccessAnyContentOrTypes)] + public ActionResult GetPropertyTypeScaffold(int id) + { + IDataType? dataTypeDiff = _dataTypeService.GetDataType(id); + + if (dataTypeDiff == null) + { + return NotFound(); + } + + var configuration = _dataTypeService.GetDataType(id)?.Configuration; + IDataEditor? editor = _propertyEditors[dataTypeDiff.EditorAlias]; + + return new ContentPropertyDisplay + { + Editor = dataTypeDiff.EditorAlias, + Validation = new PropertyTypeValidation(), + View = editor?.GetValueEditor().View, + Config = editor?.GetConfigurationEditor().ToConfigurationEditor(configuration) + }; + } + + /// + /// Deletes a document type container with a given ID + /// + [HttpDelete] + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public IActionResult DeleteContainer(int id) + { + _contentTypeService.DeleteContainer(id, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + return Ok(); + } + + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public IActionResult PostCreateContainer(int parentId, string name) + { + Attempt?> result = + _contentTypeService.CreateContainer(parentId, Guid.NewGuid(), name, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + if (result.Success) + { + return Ok(result.Result); //return the id + } + + return ValidationProblem(result.Exception?.Message); + } + + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public IActionResult PostRenameContainer(int id, string name) + { + Attempt?> result = + _contentTypeService.RenameContainer(id, name, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + if (result.Success) + { + return Ok(result.Result); //return the id + } + + return ValidationProblem(result.Exception?.Message); + } + + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public ActionResult PostSave(DocumentTypeSave contentTypeSave) + { + //Before we send this model into this saving/mapping pipeline, we need to do some cleanup on variations. + //If the doc type does not allow content variations, we need to update all of it's property types to not allow this either + //else we may end up with ysods. I'm unsure if the service level handles this but we'll make sure it is updated here + if (!contentTypeSave.AllowCultureVariant) + { + foreach (PropertyTypeBasic prop in contentTypeSave.Groups.SelectMany(x => x.Properties)) { - return NotFound(); + prop.AllowCultureVariant = false; } - - var dto = _umbracoMapper.Map(ct); - return dto; } - /// - /// Gets the document type a given guid - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public ActionResult GetById(Guid id) - { - var contentType = _contentTypeService.Get(id); - if (contentType == null) + ActionResult savedCt = PerformPostSave( + contentTypeSave, + i => _contentTypeService.Get(i), + type => _contentTypeService.Save(type), + ctSave => { - return NotFound(); - } - - var dto = _umbracoMapper.Map(contentType); - return dto; - } - - /// - /// Gets the document type a given udi - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public ActionResult GetById(Udi id) - { - var guidUdi = id as GuidUdi; - if (guidUdi == null) - return NotFound(); - - var contentType = _contentTypeService.Get(guidUdi.Guid); - if (contentType == null) - { - return NotFound(); - } - - var dto = _umbracoMapper.Map(contentType); - return dto; - } - - /// - /// Deletes a document type with a given ID - /// - [HttpDelete] - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public IActionResult DeleteById(int id) - { - var foundType = _contentTypeService.Get(id); - if (foundType == null) - { - return NotFound(); - } - - _contentTypeService.Delete(foundType, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - return Ok(); - } - - /// - /// Gets all user defined properties. - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessAnyContentOrTypes)] - public IEnumerable GetAllPropertyTypeAliases() - { - return _contentTypeService.GetAllPropertyTypeAliases(); - } - - /// - /// Gets all the standard fields. - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessAnyContentOrTypes)] - public IEnumerable GetAllStandardFields() - { - string[] preValuesSource = { "createDate", "creatorName", "level", "nodeType", "nodeTypeAlias", "pageID", "pageName", "parentID", "path", "template", "updateDate", "writerID", "writerName" }; - return preValuesSource; - } - - /// - /// Returns the available compositions for this content type - /// This has been wrapped in a dto instead of simple parameters to support having multiple parameters in post request body - /// - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public ActionResult GetAvailableCompositeContentTypes(GetAvailableCompositionsFilter filter) - { - var actionResult = PerformGetAvailableCompositeContentTypes(filter.ContentTypeId, - UmbracoObjectTypes.DocumentType, filter.FilterContentTypes, filter.FilterPropertyTypes, - filter.IsElement); - - if (!(actionResult.Result is null)) - { - return actionResult.Result; - } - - var result = actionResult.Value? - .Select(x => new + //create a default template if it doesn't exist -but only if default template is == to the content type + if (ctSave.DefaultTemplate.IsNullOrWhiteSpace() == false && ctSave.DefaultTemplate == ctSave.Alias) { - contentType = x.Item1, - allowed = x.Item2 - }); - return Ok(result); - } + ITemplate? template = CreateTemplateForContentType(ctSave.Alias, ctSave.Name); - /// - /// Returns true if any content types have culture variation enabled - /// - [HttpGet] - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - public bool AllowsCultureVariation() - { - IEnumerable contentTypes = _contentTypeService.GetAll(); - return contentTypes.Any(contentType => contentType.VariesByCulture()); - } - - /// - /// Returns where a particular composition has been used - /// This has been wrapped in a dto instead of simple parameters to support having multiple parameters in post request body - /// - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public IActionResult GetWhereCompositionIsUsedInContentTypes(GetAvailableCompositionsFilter filter) - { - var result = PerformGetWhereCompositionIsUsedInContentTypes(filter.ContentTypeId, UmbracoObjectTypes.DocumentType).Value? - .Select(x => new - { - contentType = x - }); - return Ok(result); - } - - [Authorize(Policy = AuthorizationPolicies.TreeAccessAnyContentOrTypes)] - public ActionResult GetPropertyTypeScaffold(int id) - { - var dataTypeDiff = _dataTypeService.GetDataType(id); - - if (dataTypeDiff == null) - { - return NotFound(); - } - - var configuration = _dataTypeService.GetDataType(id)?.Configuration; - var editor = _propertyEditors[dataTypeDiff.EditorAlias]; - - return new ContentPropertyDisplay() - { - Editor = dataTypeDiff.EditorAlias, - Validation = new PropertyTypeValidation(), - View = editor?.GetValueEditor().View, - Config = editor?.GetConfigurationEditor().ToConfigurationEditor(configuration) - }; - } - - /// - /// Deletes a document type container with a given ID - /// - [HttpDelete] - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public IActionResult DeleteContainer(int id) - { - _contentTypeService.DeleteContainer(id, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - return Ok(); - } - - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public IActionResult PostCreateContainer(int parentId, string name) - { - var result = _contentTypeService.CreateContainer(parentId, Guid.NewGuid(), name, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - if (result.Success) - return Ok(result.Result); //return the id - else - return ValidationProblem(result.Exception?.Message); - } - - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public IActionResult PostRenameContainer(int id, string name) - { - var result = _contentTypeService.RenameContainer(id, name, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - if (result.Success) - return Ok(result.Result); //return the id - else - return ValidationProblem(result.Exception?.Message); - } - - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public ActionResult PostSave(DocumentTypeSave contentTypeSave) - { - //Before we send this model into this saving/mapping pipeline, we need to do some cleanup on variations. - //If the doc type does not allow content variations, we need to update all of it's property types to not allow this either - //else we may end up with ysods. I'm unsure if the service level handles this but we'll make sure it is updated here - if (!contentTypeSave.AllowCultureVariant) - { - foreach(var prop in contentTypeSave.Groups.SelectMany(x => x.Properties)) - { - prop.AllowCultureVariant = false; - } - } - - var savedCt = PerformPostSave( - contentTypeSave: contentTypeSave, - getContentType: i => _contentTypeService.Get(i), - saveContentType: type => _contentTypeService.Save(type), - beforeCreateNew: ctSave => - { - //create a default template if it doesn't exist -but only if default template is == to the content type - if (ctSave.DefaultTemplate.IsNullOrWhiteSpace() == false && ctSave.DefaultTemplate == ctSave.Alias) + if (template is not null) { - var template = CreateTemplateForContentType(ctSave.Alias, ctSave.Name); - - if (template is not null) + // If the alias has been manually updated before the first save, + // make sure to also update the first allowed template, as the + // name will come back as a SafeAlias of the document type name, + // not as the actual document type alias. + // For more info: http://issues.umbraco.org/issue/U4-11059 + if (ctSave.DefaultTemplate != template.Alias) { - // If the alias has been manually updated before the first save, - // make sure to also update the first allowed template, as the - // name will come back as a SafeAlias of the document type name, - // not as the actual document type alias. - // For more info: http://issues.umbraco.org/issue/U4-11059 - if (ctSave.DefaultTemplate != template.Alias) + var allowedTemplates = ctSave.AllowedTemplates?.ToArray(); + if (allowedTemplates?.Any() ?? false) { - var allowedTemplates = ctSave.AllowedTemplates?.ToArray(); - if (allowedTemplates?.Any() ?? false) - allowedTemplates[0] = template.Alias; - ctSave.AllowedTemplates = allowedTemplates; + allowedTemplates[0] = template.Alias; } - //make sure the template alias is set on the default and allowed template so we can map it back - ctSave.DefaultTemplate = template.Alias; + ctSave.AllowedTemplates = allowedTemplates; } + + //make sure the template alias is set on the default and allowed template so we can map it back + ctSave.DefaultTemplate = template.Alias; } - }); - - if (!(savedCt.Result is null)) - { - return savedCt.Result; - } - - var display = _umbracoMapper.Map(savedCt.Value); - - - display?.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles","contentTypeSavedHeader"), - string.Empty); - - return display; - } - - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public ActionResult PostCreateDefaultTemplate(int id) - { - var contentType = _contentTypeService.Get(id); - if (contentType == null) - { - return NotFound("No content type found with id " + id); - } - - var template = CreateTemplateForContentType(contentType.Alias, contentType.Name); - if (template == null) - { - throw new InvalidOperationException("Could not create default template for content type with id " + id); - } - - return _umbracoMapper.Map(template); - } - - private ITemplate? CreateTemplateForContentType(string contentTypeAlias, string? contentTypeName) - { - var template = _fileService.GetTemplate(contentTypeAlias); - if (template == null) - { - var tryCreateTemplate = _fileService.CreateTemplateForContentType(contentTypeAlias, contentTypeName); - if (tryCreateTemplate == false) - { - _logger.LogWarning("Could not create a template for Content Type: \"{ContentTypeAlias}\", status: {Status}", - contentTypeAlias, tryCreateTemplate.Result?.Result); } - - template = tryCreateTemplate.Result?.Entity; - } - - return template; - } - - /// - /// Returns an empty content type for use as a scaffold when creating a new type - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public DocumentTypeDisplay? GetEmpty(int parentId) - { - IContentType ct; - if (parentId != Constants.System.Root) - { - var parent = _contentTypeService.Get(parentId); - ct = parent != null ? new ContentType(_shortStringHelper, parent, string.Empty) : new ContentType(_shortStringHelper, parentId); - } - else - ct = new ContentType(_shortStringHelper, parentId); - - ct.Icon = Constants.Icons.Content; - - var dto = _umbracoMapper.Map(ct); - return dto; - } - - - /// - /// Returns all content type objects - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes)] - public IEnumerable GetAll() - { - var types = _contentTypeService.GetAll(); - var basics = types.Select(_umbracoMapper.Map).WhereNotNull(); - - return basics.Select(basic => - { - basic.Name = TranslateItem(basic.Name); - basic.Description = TranslateItem(basic.Description); - return basic; }); + + if (!(savedCt.Result is null)) + { + return savedCt.Result; } - /// - /// Returns the allowed child content type objects for the content item id passed in - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes)] - [OutgoingEditorModelEvent] - public IEnumerable GetAllowedChildren(int contentId) + DocumentTypeDisplay? display = _umbracoMapper.Map(savedCt.Value); + + + display?.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "contentTypeSavedHeader"), + string.Empty); + + return display; + } + + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public ActionResult PostCreateDefaultTemplate(int id) + { + IContentType? contentType = _contentTypeService.Get(id); + if (contentType == null) { - if (contentId == Constants.System.RecycleBinContent) + return NotFound("No content type found with id " + id); + } + + ITemplate? template = CreateTemplateForContentType(contentType.Alias, contentType.Name); + if (template == null) + { + throw new InvalidOperationException("Could not create default template for content type with id " + id); + } + + return _umbracoMapper.Map(template); + } + + private ITemplate? CreateTemplateForContentType(string contentTypeAlias, string? contentTypeName) + { + ITemplate? template = _fileService.GetTemplate(contentTypeAlias); + if (template == null) + { + Attempt?> tryCreateTemplate = + _fileService.CreateTemplateForContentType(contentTypeAlias, contentTypeName); + if (tryCreateTemplate == false) + { + _logger.LogWarning( + "Could not create a template for Content Type: \"{ContentTypeAlias}\", status: {Status}", + contentTypeAlias, + tryCreateTemplate.Result?.Result); + } + + template = tryCreateTemplate.Result?.Entity; + } + + return template; + } + + /// + /// Returns an empty content type for use as a scaffold when creating a new type + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public DocumentTypeDisplay? GetEmpty(int parentId) + { + IContentType ct; + if (parentId != Constants.System.Root) + { + IContentType? parent = _contentTypeService.Get(parentId); + ct = parent != null + ? new ContentType(_shortStringHelper, parent, string.Empty) + : new ContentType(_shortStringHelper, parentId); + } + else + { + ct = new ContentType(_shortStringHelper, parentId); + } + + ct.Icon = Constants.Icons.Content; + + DocumentTypeDisplay? dto = _umbracoMapper.Map(ct); + return dto; + } + + + /// + /// Returns all content type objects + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes)] + public IEnumerable GetAll() + { + IEnumerable types = _contentTypeService.GetAll(); + IEnumerable basics = types.Select(_umbracoMapper.Map) + .WhereNotNull(); + + return basics.Select(basic => + { + basic.Name = TranslateItem(basic.Name); + basic.Description = TranslateItem(basic.Description); + return basic; + }); + } + + /// + /// Returns the allowed child content type objects for the content item id passed in + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes)] + [OutgoingEditorModelEvent] + public IEnumerable GetAllowedChildren(int contentId) + { + if (contentId == Constants.System.RecycleBinContent) + { + return Enumerable.Empty(); + } + + IEnumerable types; + if (contentId == Constants.System.Root) + { + types = _contentTypeService.GetAll().Where(x => x.AllowedAsRoot).ToList(); + } + else + { + IContent? contentItem = _contentService.GetById(contentId); + if (contentItem == null) + { return Enumerable.Empty(); - - IEnumerable types; - if (contentId == Constants.System.Root) - { - types = _contentTypeService.GetAll().Where(x => x.AllowedAsRoot).ToList(); } - else + + IContentTypeComposition? contentType = _contentTypeBaseServiceProvider.GetContentTypeOf(contentItem); + var ids = contentType?.AllowedContentTypes?.OrderBy(c => c.SortOrder).Select(x => x.Id.Value).ToArray(); + + if (ids is null || ids.Any() == false) { - var contentItem = _contentService.GetById(contentId); - if (contentItem == null) + return Enumerable.Empty(); + } + + types = _contentTypeService.GetAll(ids).OrderBy(c => ids.IndexOf(c.Id)).ToList(); + } + + var basics = types.Where(type => type.IsElement == false) + .Select(_umbracoMapper.Map).WhereNotNull().ToList(); + + ILocalizedTextService localizedTextService = _localizedTextService; + foreach (ContentTypeBasic basic in basics) + { + basic.Name = localizedTextService.UmbracoDictionaryTranslate(CultureDictionary, basic.Name); + basic.Description = localizedTextService.UmbracoDictionaryTranslate(CultureDictionary, basic.Description); + } + + //map the blueprints + IContent[]? blueprints = + _contentService.GetBlueprintsForContentTypes(types.Select(x => x.Id).ToArray())?.ToArray(); + foreach (ContentTypeBasic basic in basics) + { + IContent[]? docTypeBluePrints = blueprints?.Where(x => x.ContentTypeId == (int?)basic.Id).ToArray(); + if (docTypeBluePrints is not null) + { + foreach (IContent blueprint in docTypeBluePrints) { - return Enumerable.Empty(); - } - - var contentType = _contentTypeBaseServiceProvider.GetContentTypeOf(contentItem); - var ids = contentType?.AllowedContentTypes?.OrderBy(c => c.SortOrder).Select(x => x.Id.Value).ToArray(); - - if (ids is null || ids.Any() == false) return Enumerable.Empty(); - - types = _contentTypeService.GetAll(ids).OrderBy(c => ids.IndexOf(c.Id)).ToList(); - } - - var basics = types.Where(type => type.IsElement == false).Select(_umbracoMapper.Map).WhereNotNull().ToList(); - - var localizedTextService = _localizedTextService; - foreach (var basic in basics) - { - basic.Name = localizedTextService.UmbracoDictionaryTranslate(CultureDictionary, basic.Name); - basic.Description = localizedTextService.UmbracoDictionaryTranslate(CultureDictionary, basic.Description); - } - - //map the blueprints - var blueprints = _contentService.GetBlueprintsForContentTypes(types.Select(x => x.Id).ToArray())?.ToArray(); - foreach (var basic in basics) - { - var docTypeBluePrints = blueprints?.Where(x => x.ContentTypeId == (int?) basic.Id).ToArray(); - if (docTypeBluePrints is not null) - { - foreach (var blueprint in docTypeBluePrints) - { - basic.Blueprints[blueprint.Id] = blueprint.Name ?? string.Empty; - } + basic.Blueprints[blueprint.Id] = blueprint.Name ?? string.Empty; } } - - return basics.OrderBy(c => contentId == Constants.System.Root ? c.Name : string.Empty); } - /// - /// Move the content type - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public IActionResult PostMove(MoveOrCopy move) + return basics.OrderBy(c => contentId == Constants.System.Root ? c.Name : string.Empty); + } + + /// + /// Move the content type + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public IActionResult PostMove(MoveOrCopy move) => + PerformMove( + move, + i => _contentTypeService.Get(i), + (type, i) => _contentTypeService.Move(type, i)); + + /// + /// Copy the content type + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public IActionResult PostCopy(MoveOrCopy copy) => + PerformCopy( + copy, + i => _contentTypeService.Get(i), + (type, i) => _contentTypeService.Copy(type, i)); + + [HttpGet] + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public IActionResult Export(int id) + { + IContentType? contentType = _contentTypeService.Get(id); + if (contentType == null) { - return PerformMove( - move, - getContentType: i => _contentTypeService.Get(i), - doMove: (type, i) => _contentTypeService.Move(type, i)); + throw new NullReferenceException("No content type found with id " + id); } - /// - /// Copy the content type - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public IActionResult PostCopy(MoveOrCopy copy) + XElement xml = _serializer.Serialize(contentType); + + var fileName = $"{contentType.Alias}.udt"; + // Set custom header so umbRequestHelper.downloadFile can save the correct filename + HttpContext.Response.Headers.Add("x-filename", fileName); + + return File(Encoding.UTF8.GetBytes(xml.ToDataString()), MediaTypeNames.Application.Octet, fileName); + } + + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public IActionResult Import(string file) + { + var filePath = Path.Combine(_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.Data), file); + if (string.IsNullOrEmpty(file) || !System.IO.File.Exists(filePath)) { - return PerformCopy( - copy, - getContentType: i => _contentTypeService.Get(i), - doCopy: (type, i) => _contentTypeService.Copy(type, i)); + return NotFound(); } - [HttpGet] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public IActionResult Export(int id) + + var xd = new XmlDocument { XmlResolver = null }; + xd.Load(filePath); + + var userId = _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().ResultOr(0); + var element = XElement.Parse(xd.InnerXml); + if (userId is not null) { - var contentType = _contentTypeService.Get(id); - if (contentType == null) throw new NullReferenceException("No content type found with id " + id); - - var xml = _serializer.Serialize(contentType); - - var fileName = $"{contentType.Alias}.udt"; - // Set custom header so umbRequestHelper.downloadFile can save the correct filename - HttpContext.Response.Headers.Add("x-filename", fileName); - - return File( Encoding.UTF8.GetBytes(xml.ToDataString()), MediaTypeNames.Application.Octet, fileName); - + _packageDataInstallation.ImportDocumentType(element, userId.Value); } - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public IActionResult Import(string file) + // Try to clean up the temporary file. + try { - var filePath = Path.Combine(_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.Data), file); - if (string.IsNullOrEmpty(file) || !System.IO.File.Exists(filePath)) - { - return NotFound(); - } - - - var xd = new XmlDocument {XmlResolver = null}; - xd.Load(filePath); - - var userId = _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().ResultOr(0); - var element = XElement.Parse(xd.InnerXml); - if (userId is not null) - { - _packageDataInstallation.ImportDocumentType(element, userId.Value); - } - - // Try to clean up the temporary file. - try - { - System.IO.File.Delete(filePath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error cleaning up temporary udt file in {File}", filePath); - } - - return Ok(); + System.IO.File.Delete(filePath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cleaning up temporary udt file in {File}", filePath); } - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - public ActionResult Upload(List file) + return Ok(); + } + + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] + public ActionResult Upload(List file) + { + var model = new ContentTypeImportModel(); + + foreach (IFormFile formFile in file) { - var model = new ContentTypeImportModel(); + var fileName = formFile.FileName.Trim(Constants.CharArrays.DoubleQuote); + var ext = fileName[(fileName.LastIndexOf('.') + 1)..].ToLower(); - foreach (var formFile in file) + var root = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); + var tempPath = Path.Combine(root, fileName); + if (Path.GetFullPath(tempPath).StartsWith(Path.GetFullPath(root))) { - var fileName = formFile.FileName.Trim(Constants.CharArrays.DoubleQuote); - var ext = fileName.Substring(fileName.LastIndexOf('.') + 1).ToLower(); - - var root = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); - var tempPath = Path.Combine(root,fileName); - if (Path.GetFullPath(tempPath).StartsWith(Path.GetFullPath(root))) + using (FileStream stream = System.IO.File.Create(tempPath)) { - using (var stream = System.IO.File.Create(tempPath)) - { - formFile.CopyToAsync(stream).GetAwaiter().GetResult(); - } + formFile.CopyToAsync(stream).GetAwaiter().GetResult(); + } - if (ext.InvariantEquals("udt")) - { - model.TempFileName = Path.Combine(root, fileName); + if (ext.InvariantEquals("udt")) + { + model.TempFileName = Path.Combine(root, fileName); - var xd = new XmlDocument - { - XmlResolver = null - }; - xd.Load(model.TempFileName); + var xd = new XmlDocument { XmlResolver = null }; + xd.Load(model.TempFileName); - model.Alias = xd.DocumentElement?.SelectSingleNode("//DocumentType/Info/Alias")?.FirstChild?.Value; - model.Name = xd.DocumentElement?.SelectSingleNode("//DocumentType/Info/Name")?.FirstChild?.Value; - } - else - { - model.Notifications.Add(new BackOfficeNotification( - _localizedTextService.Localize("speechBubbles","operationFailedHeader"), - _localizedTextService.Localize("media","disallowedFileType"), - NotificationStyle.Warning)); - } + model.Alias = xd.DocumentElement?.SelectSingleNode("//DocumentType/Info/Alias")?.FirstChild?.Value; + model.Name = xd.DocumentElement?.SelectSingleNode("//DocumentType/Info/Name")?.FirstChild?.Value; } else { model.Notifications.Add(new BackOfficeNotification( _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), - _localizedTextService.Localize("media", "invalidFileName"), + _localizedTextService.Localize("media", "disallowedFileType"), NotificationStyle.Warning)); } - } - - return model; - + else + { + model.Notifications.Add(new BackOfficeNotification( + _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), + _localizedTextService.Localize("media", "invalidFileName"), + NotificationStyle.Warning)); + } } - + return model; } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentTypeControllerBase.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentTypeControllerBase.cs index 4a09276442..a50d2c7bb1 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentTypeControllerBase.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentTypeControllerBase.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; using System.Net.Mime; using System.Text; using Microsoft.AspNetCore.Mvc; @@ -18,700 +15,695 @@ using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// Am abstract API controller providing functionality used for dealing with content and media types +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[PrefixlessBodyModelValidator] +public abstract class ContentTypeControllerBase : BackOfficeNotificationsController + where TContentType : class, IContentTypeComposition { - /// - /// Am abstract API controller providing functionality used for dealing with content and media types - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [PrefixlessBodyModelValidator] - public abstract class ContentTypeControllerBase : BackOfficeNotificationsController - where TContentType : class, IContentTypeComposition + private readonly EditorValidatorCollection _editorValidatorCollection; + + protected ContentTypeControllerBase( + ICultureDictionary cultureDictionary, + EditorValidatorCollection editorValidatorCollection, + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService, + IUmbracoMapper umbracoMapper, + ILocalizedTextService localizedTextService) { - private readonly EditorValidatorCollection _editorValidatorCollection; + _editorValidatorCollection = editorValidatorCollection ?? + throw new ArgumentNullException(nameof(editorValidatorCollection)); + CultureDictionary = cultureDictionary ?? throw new ArgumentNullException(nameof(cultureDictionary)); + ContentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); + MediaTypeService = mediaTypeService ?? throw new ArgumentNullException(nameof(mediaTypeService)); + MemberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); + UmbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + LocalizedTextService = + localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + } - protected ContentTypeControllerBase( - ICultureDictionary cultureDictionary, - EditorValidatorCollection editorValidatorCollection, - IContentTypeService contentTypeService, - IMediaTypeService mediaTypeService, - IMemberTypeService memberTypeService, - IUmbracoMapper umbracoMapper, - ILocalizedTextService localizedTextService) + protected ICultureDictionary CultureDictionary { get; } + public IContentTypeService ContentTypeService { get; } + public IMediaTypeService MediaTypeService { get; } + public IMemberTypeService MemberTypeService { get; } + public IUmbracoMapper UmbracoMapper { get; } + public ILocalizedTextService LocalizedTextService { get; } + + /// + /// Returns the available composite content types for a given content type + /// + /// + /// + /// This is normally an empty list but if additional content type aliases are passed in, any content types containing + /// those aliases will be filtered out + /// along with any content types that have matching property types that are included in the filtered content types + /// + /// + /// This is normally an empty list but if additional property type aliases are passed in, any content types that have + /// these aliases will be filtered out. + /// This is required because in the case of creating/modifying a content type because new property types being added to + /// it are not yet persisted so cannot + /// be looked up via the db, they need to be passed in. + /// + /// + /// Whether the composite content types should be applicable for an element type + /// + protected ActionResult>> PerformGetAvailableCompositeContentTypes( + int contentTypeId, + UmbracoObjectTypes type, + string[]? filterContentTypes, + string[]? filterPropertyTypes, + bool isElement) + { + IContentTypeComposition? source = null; + + //below is all ported from the old doc type editor and comes with the same weaknesses /insanity / magic + + IContentTypeComposition[] allContentTypes; + + switch (type) { - _editorValidatorCollection = editorValidatorCollection ?? - throw new ArgumentNullException(nameof(editorValidatorCollection)); - CultureDictionary = cultureDictionary ?? throw new ArgumentNullException(nameof(cultureDictionary)); - ContentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); - MediaTypeService = mediaTypeService ?? throw new ArgumentNullException(nameof(mediaTypeService)); - MemberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); - UmbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - LocalizedTextService = - localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); - } - - protected ICultureDictionary CultureDictionary { get; } - public IContentTypeService ContentTypeService { get; } - public IMediaTypeService MediaTypeService { get; } - public IMemberTypeService MemberTypeService { get; } - public IUmbracoMapper UmbracoMapper { get; } - public ILocalizedTextService LocalizedTextService { get; } - - /// - /// Returns the available composite content types for a given content type - /// - /// - /// - /// This is normally an empty list but if additional content type aliases are passed in, any content types containing - /// those aliases will be filtered out - /// along with any content types that have matching property types that are included in the filtered content types - /// - /// - /// This is normally an empty list but if additional property type aliases are passed in, any content types that have - /// these aliases will be filtered out. - /// This is required because in the case of creating/modifying a content type because new property types being added to - /// it are not yet persisted so cannot - /// be looked up via the db, they need to be passed in. - /// - /// - /// Whether the composite content types should be applicable for an element type - /// - protected ActionResult>> PerformGetAvailableCompositeContentTypes( - int contentTypeId, - UmbracoObjectTypes type, - string[]? filterContentTypes, - string[]? filterPropertyTypes, - bool isElement) - { - IContentTypeComposition? source = null; - - //below is all ported from the old doc type editor and comes with the same weaknesses /insanity / magic - - IContentTypeComposition[] allContentTypes; - - switch (type) - { - case UmbracoObjectTypes.DocumentType: - if (contentTypeId > 0) - { - source = ContentTypeService.Get(contentTypeId); - if (source == null) - { - return NotFound(); - } - } - - allContentTypes = ContentTypeService.GetAll().Cast().ToArray(); - break; - - case UmbracoObjectTypes.MediaType: - if (contentTypeId > 0) - { - source = MediaTypeService.Get(contentTypeId); - if (source == null) - { - return NotFound(); - } - } - - allContentTypes = MediaTypeService.GetAll().Cast().ToArray(); - break; - - case UmbracoObjectTypes.MemberType: - if (contentTypeId > 0) - { - source = MemberTypeService.Get(contentTypeId); - if (source == null) - { - return NotFound(); - } - } - - allContentTypes = MemberTypeService.GetAll().Cast().ToArray(); - break; - - default: - throw new ArgumentOutOfRangeException("The entity type was not a content type"); - } - - ContentTypeAvailableCompositionsResults availableCompositions = - ContentTypeService.GetAvailableCompositeContentTypes(source, allContentTypes, filterContentTypes, - filterPropertyTypes, isElement); - - - IContentTypeComposition[] currCompositions = - source == null ? new IContentTypeComposition[] { } : source.ContentTypeComposition.ToArray(); - var compAliases = currCompositions.Select(x => x.Alias).ToArray(); - IEnumerable ancestors = availableCompositions.Ancestors.Select(x => x.Alias); - - return availableCompositions.Results - .Select(x => - new Tuple(UmbracoMapper.Map(x.Composition), - x.Allowed)) - .Select(x => + case UmbracoObjectTypes.DocumentType: + if (contentTypeId > 0) { - //we need to ensure that the item is enabled if it is already selected - // but do not allow it if it is any of the ancestors - if (compAliases.Contains(x.Item1?.Alias) && ancestors.Contains(x.Item1?.Alias) == false) + source = ContentTypeService.Get(contentTypeId); + if (source == null) { - //re-set x to be allowed (NOTE: I didn't know you could set an enumerable item in a lambda!) - x = new Tuple(x.Item1, true); + return NotFound(); } + } - //translate the name - if (x.Item1 is not null) + allContentTypes = ContentTypeService.GetAll().Cast().ToArray(); + break; + + case UmbracoObjectTypes.MediaType: + if (contentTypeId > 0) + { + source = MediaTypeService.Get(contentTypeId); + if (source == null) { - x.Item1.Name = TranslateItem(x.Item1.Name); + return NotFound(); } + } - IContentTypeComposition? contentType = allContentTypes.FirstOrDefault(c => c.Key == x.Item1?.Key); - EntityContainer[] containers = GetEntityContainers(contentType, type).ToArray(); - var containerPath = - $"/{(containers.Any() ? $"{string.Join("/", containers.Select(c => c.Name))}/" : null)}"; - if (x.Item1 is not null) + allContentTypes = MediaTypeService.GetAll().Cast().ToArray(); + break; + + case UmbracoObjectTypes.MemberType: + if (contentTypeId > 0) + { + source = MemberTypeService.Get(contentTypeId); + if (source == null) { - x.Item1.AdditionalData["containerPath"] = containerPath; + return NotFound(); } + } - return x; - }) - .ToList(); + allContentTypes = MemberTypeService.GetAll().Cast().ToArray(); + break; + + default: + throw new ArgumentOutOfRangeException("The entity type was not a content type"); } - private IEnumerable GetEntityContainers(IContentTypeComposition? contentType, - UmbracoObjectTypes type) - { - if (contentType == null) + ContentTypeAvailableCompositionsResults availableCompositions = + ContentTypeService.GetAvailableCompositeContentTypes(source, allContentTypes, filterContentTypes, filterPropertyTypes, isElement); + + + IContentTypeComposition[] currCompositions = + source == null ? new IContentTypeComposition[] { } : source.ContentTypeComposition.ToArray(); + var compAliases = currCompositions.Select(x => x.Alias).ToArray(); + IEnumerable ancestors = availableCompositions.Ancestors.Select(x => x.Alias); + + return availableCompositions.Results + .Select(x => + new Tuple(UmbracoMapper.Map(x.Composition), x.Allowed)) + .Select(x => { + //we need to ensure that the item is enabled if it is already selected + // but do not allow it if it is any of the ancestors + if (compAliases.Contains(x.Item1?.Alias) && ancestors.Contains(x.Item1?.Alias) == false) + { + //re-set x to be allowed (NOTE: I didn't know you could set an enumerable item in a lambda!) + x = new Tuple(x.Item1, true); + } + + //translate the name + if (x.Item1 is not null) + { + x.Item1.Name = TranslateItem(x.Item1.Name); + } + + IContentTypeComposition? contentType = allContentTypes.FirstOrDefault(c => c.Key == x.Item1?.Key); + EntityContainer[] containers = GetEntityContainers(contentType, type).ToArray(); + var containerPath = + $"/{(containers.Any() ? $"{string.Join("/", containers.Select(c => c.Name))}/" : null)}"; + if (x.Item1 is not null) + { + x.Item1.AdditionalData["containerPath"] = containerPath; + } + + return x; + }) + .ToList(); + } + + private IEnumerable GetEntityContainers(IContentTypeComposition? contentType, UmbracoObjectTypes type) + { + if (contentType == null) + { + return Enumerable.Empty(); + } + + switch (type) + { + case UmbracoObjectTypes.DocumentType: + if (contentType is IContentType documentContentType) + { + return ContentTypeService.GetContainers(documentContentType); + } + return Enumerable.Empty(); - } + case UmbracoObjectTypes.MediaType: + if (contentType is IMediaType mediaContentType) + { + return MediaTypeService.GetContainers(mediaContentType); + } - switch (type) - { - case UmbracoObjectTypes.DocumentType: - if (contentType is IContentType documentContentType) - { - return ContentTypeService.GetContainers(documentContentType); - } - - return Enumerable.Empty(); - case UmbracoObjectTypes.MediaType: - if (contentType is IMediaType mediaContentType) - { - return MediaTypeService.GetContainers(mediaContentType); - } - - return Enumerable.Empty(); - case UmbracoObjectTypes.MemberType: - return Enumerable.Empty(); - default: - throw new ArgumentOutOfRangeException("The entity type was not a content type"); - } + return Enumerable.Empty(); + case UmbracoObjectTypes.MemberType: + return Enumerable.Empty(); + default: + throw new ArgumentOutOfRangeException("The entity type was not a content type"); } + } - /// - /// Returns a list of content types where a particular composition content type is used - /// - /// Type of content Type, eg documentType or mediaType - /// Id of composition content type - /// - protected ActionResult> PerformGetWhereCompositionIsUsedInContentTypes( - int contentTypeId, UmbracoObjectTypes type) + /// + /// Returns a list of content types where a particular composition content type is used + /// + /// Type of content Type, eg documentType or mediaType + /// Id of composition content type + /// + protected ActionResult> PerformGetWhereCompositionIsUsedInContentTypes( + int contentTypeId, UmbracoObjectTypes type) + { + var id = 0; + + if (contentTypeId > 0) { - var id = 0; - - if (contentTypeId > 0) - { - IContentTypeComposition? source; - - switch (type) - { - case UmbracoObjectTypes.DocumentType: - source = ContentTypeService.Get(contentTypeId); - break; - - case UmbracoObjectTypes.MediaType: - source = MediaTypeService.Get(contentTypeId); - break; - - case UmbracoObjectTypes.MemberType: - source = MemberTypeService.Get(contentTypeId); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(type)); - } - - if (source == null) - { - return NotFound(); - } - - id = source.Id; - } - - IEnumerable composedOf; + IContentTypeComposition? source; switch (type) { case UmbracoObjectTypes.DocumentType: - composedOf = ContentTypeService.GetComposedOf(id); + source = ContentTypeService.Get(contentTypeId); break; case UmbracoObjectTypes.MediaType: - composedOf = MediaTypeService.GetComposedOf(id); + source = MediaTypeService.Get(contentTypeId); break; case UmbracoObjectTypes.MemberType: - composedOf = MemberTypeService.GetComposedOf(id); + source = MemberTypeService.Get(contentTypeId); break; default: throw new ArgumentOutOfRangeException(nameof(type)); } - EntityBasic TranslateName(EntityBasic e) - { - e.Name = TranslateItem(e.Name); - return e; - } - - return composedOf - .Select(UmbracoMapper.Map) - .WhereNotNull() - .Select(TranslateName) - .ToList(); - } - - protected string? TranslateItem(string? text) - { - if (text == null) - { - return null; - } - - if (text.StartsWith("#") == false) - { - return text; - } - - text = text.Substring(1); - return CultureDictionary[text].IfNullOrWhiteSpace(text); - } - - protected ActionResult PerformPostSave( - TContentTypeSave contentTypeSave, - Func getContentType, - Action saveContentType, - Action? beforeCreateNew = null) - where TContentTypeDisplay : ContentTypeCompositionDisplay - where TContentTypeSave : ContentTypeSave - where TPropertyType : PropertyTypeBasic - { - var ctId = Convert.ToInt32(contentTypeSave.Id); - TContentType? ct = ctId > 0 ? getContentType(ctId) : null; - if (ctId > 0 && ct == null) + if (source == null) { return NotFound(); } - //Validate that there's no other ct with the same alias - // it in fact cannot be the same as any content type alias (member, content or media) because - // this would interfere with how ModelsBuilder works and also how many of the published caches - // works since that is based on aliases. - IEnumerable allAliases = ContentTypeService.GetAllContentTypeAliases(); - var exists = allAliases.InvariantContains(contentTypeSave.Alias); - if (exists && (ctId == 0 || (!ct?.Alias.InvariantEquals(contentTypeSave.Alias) ?? false))) - { - ModelState.AddModelError("Alias", - LocalizedTextService.Localize("editcontenttype", "aliasAlreadyExists")); - } - - // execute the external validators - ValidateExternalValidators(ModelState, contentTypeSave); - - if (ModelState.IsValid == false) - { - TContentTypeDisplay? err = - CreateModelStateValidationEror(ctId, contentTypeSave, ct); - return ValidationProblem(err); - } - - //filter out empty properties - contentTypeSave.Groups = contentTypeSave.Groups.Where(x => x.Name.IsNullOrWhiteSpace() == false).ToList(); - foreach (PropertyGroupBasic group in contentTypeSave.Groups) - { - group.Properties = group.Properties.Where(x => x.Alias.IsNullOrWhiteSpace() == false).ToList(); - } - - if (ctId > 0) - { - //its an update to an existing content type - - //This mapping will cause a lot of content type validation to occur which we need to deal with - try - { - UmbracoMapper.Map(contentTypeSave, ct); - } - catch (Exception ex) - { - TContentTypeDisplay? responseEx = - CreateInvalidCompositionResponseException( - ex, contentTypeSave, ct, ctId); - if (responseEx != null) - { - return ValidationProblem(responseEx); - } - } - - TContentTypeDisplay? exResult = - CreateCompositionValidationExceptionIfInvalid( - contentTypeSave, ct); - if (exResult != null) - { - return ValidationProblem(exResult); - } - - saveContentType(ct); - - return ct; - } - else - { - if (beforeCreateNew != null) - { - beforeCreateNew(contentTypeSave); - } - - //check if the type is trying to allow type 0 below itself - id zero refers to the currently unsaved type - //always filter these 0 types out - var allowItselfAsChild = false; - var allowIfselfAsChildSortOrder = -1; - if (contentTypeSave.AllowedContentTypes != null) - { - allowIfselfAsChildSortOrder = contentTypeSave.AllowedContentTypes.IndexOf(0); - allowItselfAsChild = contentTypeSave.AllowedContentTypes.Any(x => x == 0); - - contentTypeSave.AllowedContentTypes = - contentTypeSave.AllowedContentTypes.Where(x => x > 0).ToList(); - } - - //save as new - - TContentType? newCt = null; - try - { - //This mapping will cause a lot of content type validation to occur which we need to deal with - newCt = UmbracoMapper.Map(contentTypeSave); - } - catch (Exception ex) - { - TContentTypeDisplay? responseEx = - CreateInvalidCompositionResponseException( - ex, contentTypeSave, ct, ctId); - if (responseEx is null) - { - throw ex; - } - - return ValidationProblem(responseEx); - } - - TContentTypeDisplay? exResult = - CreateCompositionValidationExceptionIfInvalid( - contentTypeSave, newCt); - if (exResult != null) - { - return ValidationProblem(exResult); - } - - //set id to null to ensure its handled as a new type - contentTypeSave.Id = null; - contentTypeSave.CreateDate = DateTime.Now; - contentTypeSave.UpdateDate = DateTime.Now; - - saveContentType(newCt); - - //we need to save it twice to allow itself under itself. - if (allowItselfAsChild && newCt != null) - { - newCt.AllowedContentTypes = - newCt.AllowedContentTypes?.Union( - new[] { new ContentTypeSort(newCt.Id, allowIfselfAsChildSortOrder) } - ); - saveContentType(newCt); - } - - return newCt; - } + id = source.Id; } - private void ValidateExternalValidators(ModelStateDictionary modelState, object model) + IEnumerable composedOf; + + switch (type) { - Type modelType = model.GetType(); + case UmbracoObjectTypes.DocumentType: + composedOf = ContentTypeService.GetComposedOf(id); + break; - IEnumerable validationResults = _editorValidatorCollection - .Where(x => x.ModelType == modelType) - .SelectMany(x => x.Validate(model)) - .Where(x => !string.IsNullOrWhiteSpace(x.ErrorMessage) && x.MemberNames.Any()); + case UmbracoObjectTypes.MediaType: + composedOf = MediaTypeService.GetComposedOf(id); + break; - foreach (ValidationResult r in validationResults) + case UmbracoObjectTypes.MemberType: + composedOf = MemberTypeService.GetComposedOf(id); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(type)); + } + + EntityBasic TranslateName(EntityBasic e) + { + e.Name = TranslateItem(e.Name); + return e; + } + + return composedOf + .Select(UmbracoMapper.Map) + .WhereNotNull() + .Select(TranslateName) + .ToList(); + } + + protected string? TranslateItem(string? text) + { + if (text == null) + { + return null; + } + + if (text.StartsWith("#") == false) + { + return text; + } + + text = text.Substring(1); + return CultureDictionary[text].IfNullOrWhiteSpace(text); + } + + protected ActionResult PerformPostSave( + TContentTypeSave contentTypeSave, + Func getContentType, + Action saveContentType, + Action? beforeCreateNew = null) + where TContentTypeDisplay : ContentTypeCompositionDisplay + where TContentTypeSave : ContentTypeSave + where TPropertyType : PropertyTypeBasic + { + var ctId = Convert.ToInt32(contentTypeSave.Id); + TContentType? ct = ctId > 0 ? getContentType(ctId) : null; + if (ctId > 0 && ct == null) + { + return NotFound(); + } + + //Validate that there's no other ct with the same alias + // it in fact cannot be the same as any content type alias (member, content or media) because + // this would interfere with how ModelsBuilder works and also how many of the published caches + // works since that is based on aliases. + IEnumerable allAliases = ContentTypeService.GetAllContentTypeAliases(); + var exists = allAliases.InvariantContains(contentTypeSave.Alias); + if (exists && (ctId == 0 || (!ct?.Alias.InvariantEquals(contentTypeSave.Alias) ?? false))) + { + ModelState.AddModelError("Alias", LocalizedTextService.Localize("editcontenttype", "aliasAlreadyExists")); + } + + // execute the external validators + ValidateExternalValidators(ModelState, contentTypeSave); + + if (ModelState.IsValid == false) + { + TContentTypeDisplay? err = + CreateModelStateValidationEror(ctId, contentTypeSave, ct); + return ValidationProblem(err); + } + + //filter out empty properties + contentTypeSave.Groups = contentTypeSave.Groups.Where(x => x.Name.IsNullOrWhiteSpace() == false).ToList(); + foreach (PropertyGroupBasic group in contentTypeSave.Groups) + { + group.Properties = group.Properties.Where(x => x.Alias.IsNullOrWhiteSpace() == false).ToList(); + } + + if (ctId > 0) + { + //its an update to an existing content type + + //This mapping will cause a lot of content type validation to occur which we need to deal with + try + { + UmbracoMapper.Map(contentTypeSave, ct); + } + catch (Exception ex) + { + TContentTypeDisplay? responseEx = + CreateInvalidCompositionResponseException( + ex, contentTypeSave, ct, ctId); + if (responseEx != null) + { + return ValidationProblem(responseEx); + } + } + + TContentTypeDisplay? exResult = + CreateCompositionValidationExceptionIfInvalid( + contentTypeSave, ct); + if (exResult != null) + { + return ValidationProblem(exResult); + } + + saveContentType(ct); + + return ct; + } + else + { + beforeCreateNew?.Invoke(contentTypeSave); + + //check if the type is trying to allow type 0 below itself - id zero refers to the currently unsaved type + //always filter these 0 types out + var allowItselfAsChild = false; + var allowIfselfAsChildSortOrder = -1; + if (contentTypeSave.AllowedContentTypes != null) + { + allowIfselfAsChildSortOrder = contentTypeSave.AllowedContentTypes.IndexOf(0); + allowItselfAsChild = contentTypeSave.AllowedContentTypes.Any(x => x == 0); + + contentTypeSave.AllowedContentTypes = + contentTypeSave.AllowedContentTypes.Where(x => x > 0).ToList(); + } + + //save as new + + TContentType? newCt = null; + try + { + //This mapping will cause a lot of content type validation to occur which we need to deal with + newCt = UmbracoMapper.Map(contentTypeSave); + } + catch (Exception ex) + { + TContentTypeDisplay? responseEx = + CreateInvalidCompositionResponseException( + ex, contentTypeSave, ct, ctId); + if (responseEx is null) + { + throw; + } + + return ValidationProblem(responseEx); + } + + TContentTypeDisplay? exResult = + CreateCompositionValidationExceptionIfInvalid( + contentTypeSave, newCt); + if (exResult != null) + { + return ValidationProblem(exResult); + } + + //set id to null to ensure its handled as a new type + contentTypeSave.Id = null; + contentTypeSave.CreateDate = DateTime.Now; + contentTypeSave.UpdateDate = DateTime.Now; + + saveContentType(newCt); + + //we need to save it twice to allow itself under itself. + if (allowItselfAsChild && newCt != null) + { + newCt.AllowedContentTypes = + newCt.AllowedContentTypes?.Union( + new[] { new ContentTypeSort(newCt.Id, allowIfselfAsChildSortOrder) }); + saveContentType(newCt); + } + + return newCt; + } + } + + private void ValidateExternalValidators(ModelStateDictionary modelState, object model) + { + Type modelType = model.GetType(); + + IEnumerable validationResults = _editorValidatorCollection + .Where(x => x.ModelType == modelType) + .SelectMany(x => x.Validate(model)) + .Where(x => !string.IsNullOrWhiteSpace(x.ErrorMessage) && x.MemberNames.Any()); + + foreach (ValidationResult r in validationResults) + { foreach (var m in r.MemberNames) { modelState.AddModelError(m, r.ErrorMessage ?? string.Empty); } } + } - /// - /// Move - /// - /// - /// - /// - /// - protected IActionResult PerformMove( - MoveOrCopy move, - Func getContentType, - Func?>> doMove) + /// + /// Move + /// + /// + /// + /// + /// + protected IActionResult PerformMove( + MoveOrCopy move, + Func getContentType, + Func?>> doMove) + { + TContentType? toMove = getContentType(move.Id); + if (toMove == null) { - TContentType? toMove = getContentType(move.Id); - if (toMove == null) - { + return NotFound(); + } + + Attempt?> result = doMove(toMove, move.ParentId); + if (result.Success) + { + return Content(toMove.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); + } + + switch (result.Result?.Result) + { + case MoveOperationStatusType.FailedParentNotFound: return NotFound(); - } - - Attempt?> result = doMove(toMove, move.ParentId); - if (result.Success) - { - return Content(toMove.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); - } - - switch (result.Result?.Result) - { - case MoveOperationStatusType.FailedParentNotFound: - return NotFound(); - case MoveOperationStatusType.FailedCancelledByEvent: - return ValidationProblem(); - case MoveOperationStatusType.FailedNotAllowedByPath: - return ValidationProblem(LocalizedTextService.Localize("moveOrCopy", "notAllowedByPath")); - default: - throw new ArgumentOutOfRangeException(); - } - } - - /// - /// Move - /// - /// - /// - /// - /// - protected IActionResult PerformCopy( - MoveOrCopy move, - Func getContentType, - Func?>> doCopy) - { - TContentType? toMove = getContentType(move.Id); - if (toMove == null) - { - return NotFound(); - } - - Attempt?> result = doCopy(toMove, move.ParentId); - if (result.Success) - { - return Content(toMove.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); - } - - switch (result.Result?.Result) - { - case MoveOperationStatusType.FailedParentNotFound: - return NotFound(); - case MoveOperationStatusType.FailedCancelledByEvent: - return ValidationProblem(); - case MoveOperationStatusType.FailedNotAllowedByPath: - return ValidationProblem(LocalizedTextService.Localize("moveOrCopy", "notAllowedByPath")); - default: - throw new ArgumentOutOfRangeException(); - } - } - - /// - /// Validates the composition and adds errors to the model state if any are found then throws an error response if - /// there are errors - /// - /// - /// - /// - private TContentTypeDisplay? CreateCompositionValidationExceptionIfInvalid(TContentTypeSave contentTypeSave, TContentType? composition) - where TContentTypeSave : ContentTypeSave - where TPropertyType : PropertyTypeBasic - where TContentTypeDisplay : ContentTypeCompositionDisplay - { - IContentTypeBaseService? service = GetContentTypeService(); - Attempt validateAttempt = service?.ValidateComposition(composition) ?? Attempt.Fail(); - if (validateAttempt == false) - { - // if it's not successful then we need to return some model state for the property type and property group - // aliases that are duplicated - IEnumerable? duplicatePropertyTypeAliases = validateAttempt.Result?.Distinct(); - var invalidPropertyGroupAliases = - (validateAttempt.Exception as InvalidCompositionException)?.PropertyGroupAliases ?? - Array.Empty(); - - AddCompositionValidationErrors(contentTypeSave, - duplicatePropertyTypeAliases, invalidPropertyGroupAliases); - - TContentTypeDisplay? display = UmbracoMapper.Map(composition); - //map the 'save' data on top - display = UmbracoMapper.Map(contentTypeSave, display); - if (display is not null) - { - display.Errors = ModelState.ToErrorDictionary(); - } - - return display; - } - - return null; - } - - public IContentTypeBaseService? GetContentTypeService() - where T : IContentTypeComposition - { - if (typeof(T).Implements()) - { - return ContentTypeService as IContentTypeBaseService; - } - - if (typeof(T).Implements()) - { - return MediaTypeService as IContentTypeBaseService; - } - - if (typeof(T).Implements()) - { - return MemberTypeService as IContentTypeBaseService; - } - - throw new ArgumentException("Type " + typeof(T).FullName + " does not have a service."); - } - - /// - /// Adds errors to the model state if any invalid aliases are found then throws an error response if there are errors - /// - /// - /// - /// - /// - private void AddCompositionValidationErrors(TContentTypeSave contentTypeSave, - IEnumerable? duplicatePropertyTypeAliases, IEnumerable? invalidPropertyGroupAliases) - where TContentTypeSave : ContentTypeSave - where TPropertyType : PropertyTypeBasic - { - if (duplicatePropertyTypeAliases is not null) - { - foreach (var propertyTypeAlias in duplicatePropertyTypeAliases) - { - // Find the property type relating to these - TPropertyType property = contentTypeSave.Groups.SelectMany(x => x.Properties) - .Single(x => x.Alias == propertyTypeAlias); - PropertyGroupBasic group = - contentTypeSave.Groups.Single(x => x.Properties.Contains(property)); - var propertyIndex = group.Properties.IndexOf(property); - var groupIndex = contentTypeSave.Groups.IndexOf(group); - - var key = $"Groups[{groupIndex}].Properties[{propertyIndex}].Alias"; - ModelState.AddModelError(key, "Duplicate property aliases aren't allowed between compositions"); - } - } - - if (invalidPropertyGroupAliases is not null) - { - foreach (var propertyGroupAlias in invalidPropertyGroupAliases) - { - // Find the property group relating to these - PropertyGroupBasic group = - contentTypeSave.Groups.Single(x => x.Alias == propertyGroupAlias); - var groupIndex = contentTypeSave.Groups.IndexOf(group); - var key = $"Groups[{groupIndex}].Name"; - ModelState.AddModelError(key, "Different group types aren't allowed between compositions"); - } - } - } - - /// - /// If the exception is an InvalidCompositionException create a response exception to be thrown for validation errors - /// - /// - /// - /// - /// - /// - /// - /// - /// - private TContentTypeDisplay? CreateInvalidCompositionResponseException( - Exception ex, TContentTypeSave contentTypeSave, TContentType? ct, int ctId) - where TContentTypeDisplay : ContentTypeCompositionDisplay - where TContentTypeSave : ContentTypeSave - where TPropertyType : PropertyTypeBasic - { - InvalidCompositionException? invalidCompositionException = null; - if (ex is InvalidCompositionException) - { - invalidCompositionException = (InvalidCompositionException)ex; - } - else if (ex.InnerException is InvalidCompositionException) - { - invalidCompositionException = (InvalidCompositionException)ex.InnerException; - } - - if (invalidCompositionException != null) - { - AddCompositionValidationErrors(contentTypeSave, - invalidCompositionException.PropertyTypeAliases, invalidCompositionException.PropertyGroupAliases); - return CreateModelStateValidationEror(ctId, contentTypeSave, ct); - } - - return null; - } - - /// - /// Used to throw the ModelState validation results when the ModelState is invalid - /// - /// - /// - /// - /// - /// - private TContentTypeDisplay? CreateModelStateValidationEror(int ctId, - TContentTypeSave contentTypeSave, TContentType? ct) - where TContentTypeDisplay : ContentTypeCompositionDisplay - where TContentTypeSave : ContentTypeSave - { - TContentTypeDisplay? forDisplay; - if (ctId > 0) - { - //Required data is invalid so we cannot continue - forDisplay = UmbracoMapper.Map(ct); - //map the 'save' data on top - forDisplay = UmbracoMapper.Map(contentTypeSave, forDisplay); - } - else - { - //map the 'save' data to display - forDisplay = UmbracoMapper.Map(contentTypeSave); - } - - if (forDisplay is not null) - { - forDisplay.Errors = ModelState.ToErrorDictionary(); - } - - return forDisplay; + case MoveOperationStatusType.FailedCancelledByEvent: + return ValidationProblem(); + case MoveOperationStatusType.FailedNotAllowedByPath: + return ValidationProblem(LocalizedTextService.Localize("moveOrCopy", "notAllowedByPath")); + default: + throw new ArgumentOutOfRangeException(); } } + + /// + /// Move + /// + /// + /// + /// + /// + protected IActionResult PerformCopy( + MoveOrCopy move, + Func getContentType, + Func?>> doCopy) + { + TContentType? toMove = getContentType(move.Id); + if (toMove == null) + { + return NotFound(); + } + + Attempt?> result = doCopy(toMove, move.ParentId); + if (result.Success) + { + return Content(toMove.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); + } + + switch (result.Result?.Result) + { + case MoveOperationStatusType.FailedParentNotFound: + return NotFound(); + case MoveOperationStatusType.FailedCancelledByEvent: + return ValidationProblem(); + case MoveOperationStatusType.FailedNotAllowedByPath: + return ValidationProblem(LocalizedTextService.Localize("moveOrCopy", "notAllowedByPath")); + default: + throw new ArgumentOutOfRangeException(); + } + } + + /// + /// Validates the composition and adds errors to the model state if any are found then throws an error response if + /// there are errors + /// + /// + /// + /// + private TContentTypeDisplay? CreateCompositionValidationExceptionIfInvalid(TContentTypeSave contentTypeSave, TContentType? composition) + where TContentTypeSave : ContentTypeSave + where TPropertyType : PropertyTypeBasic + where TContentTypeDisplay : ContentTypeCompositionDisplay + { + IContentTypeBaseService? service = GetContentTypeService(); + Attempt validateAttempt = service?.ValidateComposition(composition) ?? Attempt.Fail(); + if (validateAttempt == false) + { + // if it's not successful then we need to return some model state for the property type and property group + // aliases that are duplicated + IEnumerable? duplicatePropertyTypeAliases = validateAttempt.Result?.Distinct(); + var invalidPropertyGroupAliases = + (validateAttempt.Exception as InvalidCompositionException)?.PropertyGroupAliases ?? + Array.Empty(); + + AddCompositionValidationErrors(contentTypeSave, duplicatePropertyTypeAliases, invalidPropertyGroupAliases); + + TContentTypeDisplay? display = UmbracoMapper.Map(composition); + //map the 'save' data on top + display = UmbracoMapper.Map(contentTypeSave, display); + if (display is not null) + { + display.Errors = ModelState.ToErrorDictionary(); + } + + return display; + } + + return null; + } + + public IContentTypeBaseService? GetContentTypeService() + where T : IContentTypeComposition + { + if (typeof(T).Implements()) + { + return ContentTypeService as IContentTypeBaseService; + } + + if (typeof(T).Implements()) + { + return MediaTypeService as IContentTypeBaseService; + } + + if (typeof(T).Implements()) + { + return MemberTypeService as IContentTypeBaseService; + } + + throw new ArgumentException("Type " + typeof(T).FullName + " does not have a service."); + } + + /// + /// Adds errors to the model state if any invalid aliases are found then throws an error response if there are errors + /// + /// + /// + /// + /// + private void AddCompositionValidationErrors( + TContentTypeSave contentTypeSave, + IEnumerable? duplicatePropertyTypeAliases, + IEnumerable? invalidPropertyGroupAliases) + where TContentTypeSave : ContentTypeSave + where TPropertyType : PropertyTypeBasic + { + if (duplicatePropertyTypeAliases is not null) + { + foreach (var propertyTypeAlias in duplicatePropertyTypeAliases) + { + // Find the property type relating to these + TPropertyType property = contentTypeSave.Groups.SelectMany(x => x.Properties) + .Single(x => x.Alias == propertyTypeAlias); + PropertyGroupBasic group = + contentTypeSave.Groups.Single(x => x.Properties.Contains(property)); + var propertyIndex = group.Properties.IndexOf(property); + var groupIndex = contentTypeSave.Groups.IndexOf(group); + + var key = $"Groups[{groupIndex}].Properties[{propertyIndex}].Alias"; + ModelState.AddModelError(key, "Duplicate property aliases aren't allowed between compositions"); + } + } + + if (invalidPropertyGroupAliases is not null) + { + foreach (var propertyGroupAlias in invalidPropertyGroupAliases) + { + // Find the property group relating to these + PropertyGroupBasic group = + contentTypeSave.Groups.Single(x => x.Alias == propertyGroupAlias); + var groupIndex = contentTypeSave.Groups.IndexOf(group); + var key = $"Groups[{groupIndex}].Name"; + ModelState.AddModelError(key, "Different group types aren't allowed between compositions"); + } + } + } + + /// + /// If the exception is an InvalidCompositionException create a response exception to be thrown for validation errors + /// + /// + /// + /// + /// + /// + /// + /// + /// + private TContentTypeDisplay? CreateInvalidCompositionResponseException( + Exception ex, TContentTypeSave contentTypeSave, TContentType? ct, int ctId) + where TContentTypeDisplay : ContentTypeCompositionDisplay + where TContentTypeSave : ContentTypeSave + where TPropertyType : PropertyTypeBasic + { + InvalidCompositionException? invalidCompositionException = null; + if (ex is InvalidCompositionException) + { + invalidCompositionException = (InvalidCompositionException)ex; + } + else if (ex.InnerException is InvalidCompositionException) + { + invalidCompositionException = (InvalidCompositionException)ex.InnerException; + } + + if (invalidCompositionException != null) + { + AddCompositionValidationErrors( + contentTypeSave, + invalidCompositionException.PropertyTypeAliases, + invalidCompositionException.PropertyGroupAliases); + return CreateModelStateValidationEror(ctId, contentTypeSave, ct); + } + + return null; + } + + /// + /// Used to throw the ModelState validation results when the ModelState is invalid + /// + /// + /// + /// + /// + /// + private TContentTypeDisplay? CreateModelStateValidationEror(int ctId, TContentTypeSave contentTypeSave, TContentType? ct) + where TContentTypeDisplay : ContentTypeCompositionDisplay + where TContentTypeSave : ContentTypeSave + { + TContentTypeDisplay? forDisplay; + if (ctId > 0) + { + //Required data is invalid so we cannot continue + forDisplay = UmbracoMapper.Map(ct); + //map the 'save' data on top + forDisplay = UmbracoMapper.Map(contentTypeSave, forDisplay); + } + else + { + //map the 'save' data to display + forDisplay = UmbracoMapper.Map(contentTypeSave); + } + + if (forDisplay is not null) + { + forDisplay.Errors = ModelState.ToErrorDictionary(); + } + + return forDisplay; + } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/CurrentUserController.cs b/src/Umbraco.Web.BackOffice/Controllers/CurrentUserController.cs index 5c75534a8b..f867ccc5a1 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/CurrentUserController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/CurrentUserController.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -29,298 +26,326 @@ using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// Controller to back the User.Resource service, used for fetching user data when already authenticated. user.service +/// is currently used for handling authentication +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class CurrentUserController : UmbracoAuthorizedJsonController { - /// - /// Controller to back the User.Resource service, used for fetching user data when already authenticated. user.service is currently used for handling authentication - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class CurrentUserController : UmbracoAuthorizedJsonController + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IBackOfficeUserManager _backOfficeUserManager; + private readonly ContentSettings _contentSettings; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly ILocalizedTextService _localizedTextService; + private readonly MediaFileManager _mediaFileManager; + private readonly IPasswordChanger _passwordChanger; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IUserDataService _userDataService; + private readonly IUserService _userService; + + [ActivatorUtilitiesConstructor] + public CurrentUserController( + MediaFileManager mediaFileManager, + IOptionsSnapshot contentSettings, + IHostingEnvironment hostingEnvironment, + IImageUrlGenerator imageUrlGenerator, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IUserService userService, + IUmbracoMapper umbracoMapper, + IBackOfficeUserManager backOfficeUserManager, + ILocalizedTextService localizedTextService, + AppCaches appCaches, + IShortStringHelper shortStringHelper, + IPasswordChanger passwordChanger, + IUserDataService userDataService) { - private readonly MediaFileManager _mediaFileManager; - private readonly ContentSettings _contentSettings; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly IImageUrlGenerator _imageUrlGenerator; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IUserService _userService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IBackOfficeUserManager _backOfficeUserManager; - private readonly ILocalizedTextService _localizedTextService; - private readonly AppCaches _appCaches; - private readonly IShortStringHelper _shortStringHelper; - private readonly IPasswordChanger _passwordChanger; - private readonly IUserDataService _userDataService; + _mediaFileManager = mediaFileManager; + _contentSettings = contentSettings.Value; + _hostingEnvironment = hostingEnvironment; + _imageUrlGenerator = imageUrlGenerator; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _userService = userService; + _umbracoMapper = umbracoMapper; + _backOfficeUserManager = backOfficeUserManager; + _localizedTextService = localizedTextService; + _appCaches = appCaches; + _shortStringHelper = shortStringHelper; + _passwordChanger = passwordChanger; + _userDataService = userDataService; + } - [ActivatorUtilitiesConstructor] - public CurrentUserController( - MediaFileManager mediaFileManager, - IOptionsSnapshot contentSettings, - IHostingEnvironment hostingEnvironment, - IImageUrlGenerator imageUrlGenerator, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IUserService userService, - IUmbracoMapper umbracoMapper, - IBackOfficeUserManager backOfficeUserManager, - ILocalizedTextService localizedTextService, - AppCaches appCaches, - IShortStringHelper shortStringHelper, - IPasswordChanger passwordChanger, - IUserDataService userDataService) + [Obsolete("This constructor is obsolete and will be removed in v11, use constructor with all values")] + public CurrentUserController( + MediaFileManager mediaFileManager, + IOptions contentSettings, + IHostingEnvironment hostingEnvironment, + IImageUrlGenerator imageUrlGenerator, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IUserService userService, + IUmbracoMapper umbracoMapper, + IBackOfficeUserManager backOfficeUserManager, + ILoggerFactory loggerFactory, + ILocalizedTextService localizedTextService, + AppCaches appCaches, + IShortStringHelper shortStringHelper, + IPasswordChanger passwordChanger) : this( + mediaFileManager, + StaticServiceProvider.Instance.GetRequiredService>(), + hostingEnvironment, + imageUrlGenerator, + backofficeSecurityAccessor, + userService, + umbracoMapper, + backOfficeUserManager, + localizedTextService, + appCaches, + shortStringHelper, + passwordChanger, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + + /// + /// Returns permissions for all nodes passed in for the current user + /// + /// + /// + [HttpPost] + public Dictionary GetPermissions(int[] nodeIds) + { + EntityPermissionCollection permissions = _userService + .GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, nodeIds); + + var permissionsDictionary = new Dictionary(); + foreach (var nodeId in nodeIds) { - _mediaFileManager = mediaFileManager; - _contentSettings = contentSettings.Value; - _hostingEnvironment = hostingEnvironment; - _imageUrlGenerator = imageUrlGenerator; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _userService = userService; - _umbracoMapper = umbracoMapper; - _backOfficeUserManager = backOfficeUserManager; - _localizedTextService = localizedTextService; - _appCaches = appCaches; - _shortStringHelper = shortStringHelper; - _passwordChanger = passwordChanger; - _userDataService = userDataService; + var aggregatePerms = permissions.GetAllPermissions(nodeId).ToArray(); + permissionsDictionary.Add(nodeId, aggregatePerms); } - [Obsolete("This constructor is obsolete and will be removed in v11, use constructor with all values")] - public CurrentUserController( - MediaFileManager mediaFileManager, - IOptions contentSettings, - IHostingEnvironment hostingEnvironment, - IImageUrlGenerator imageUrlGenerator, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IUserService userService, - IUmbracoMapper umbracoMapper, - IBackOfficeUserManager backOfficeUserManager, - ILoggerFactory loggerFactory, - ILocalizedTextService localizedTextService, - AppCaches appCaches, - IShortStringHelper shortStringHelper, - IPasswordChanger passwordChanger) : this( - mediaFileManager, - StaticServiceProvider.Instance.GetRequiredService>(), - hostingEnvironment, - imageUrlGenerator, - backofficeSecurityAccessor, - userService, - umbracoMapper, - backOfficeUserManager, - localizedTextService, - appCaches, - shortStringHelper, - passwordChanger, - StaticServiceProvider.Instance.GetRequiredService()) - { + return permissionsDictionary; + } + /// + /// Checks a nodes permission for the current user + /// + /// + /// + /// + [HttpGet] + public bool HasPermission(string permissionToCheck, int nodeId) + { + IEnumerable p = _userService + .GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, nodeId).GetAllPermissions(); + if (p.Contains(permissionToCheck.ToString(CultureInfo.InvariantCulture))) + { + return true; } + return false; + } - /// - /// Returns permissions for all nodes passed in for the current user - /// - /// - /// - [HttpPost] - public Dictionary GetPermissions(int[] nodeIds) + /// + /// Saves a tour status for the current user + /// + /// + /// + public IEnumerable PostSetUserTour(UserTourStatus? status) + { + if (status == null) { - var permissions = _userService - .GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, nodeIds); - - var permissionsDictionary = new Dictionary(); - foreach (var nodeId in nodeIds) - { - var aggregatePerms = permissions.GetAllPermissions(nodeId).ToArray(); - permissionsDictionary.Add(nodeId, aggregatePerms); - } - - return permissionsDictionary; + throw new ArgumentNullException(nameof(status)); } - /// - /// Checks a nodes permission for the current user - /// - /// - /// - /// - [HttpGet] - public bool HasPermission(string permissionToCheck, int nodeId) + List? userTours = null; + if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.TourData.IsNullOrWhiteSpace() ?? true) { - var p = _userService.GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, nodeId).GetAllPermissions(); - if (p.Contains(permissionToCheck.ToString(CultureInfo.InvariantCulture))) - { - return true; - } - - return false; - } - - /// - /// Saves a tour status for the current user - /// - /// - /// - public IEnumerable PostSetUserTour(UserTourStatus? status) - { - if (status == null) throw new ArgumentNullException(nameof(status)); - - List? userTours = null; - if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.TourData.IsNullOrWhiteSpace() ?? true) - { - userTours = new List { status }; - if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser is not null) - { - _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.TourData = JsonConvert.SerializeObject(userTours); - _userService.Save(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser); - } - - return userTours; - } - - if (_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.TourData is not null) - { - userTours = JsonConvert.DeserializeObject>(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.TourData)?.ToList(); - var found = userTours?.FirstOrDefault(x => x.Alias == status.Alias); - if (found != null) - { - //remove it and we'll replace it next - userTours?.Remove(found); - } - userTours?.Add(status); - } - - _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.TourData = JsonConvert.SerializeObject(userTours); - _userService.Save(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser); - return userTours ?? Enumerable.Empty(); - } - - /// - /// Returns the user's tours - /// - /// - public IEnumerable? GetUserTours() - { - if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.TourData.IsNullOrWhiteSpace() ?? true) - return Enumerable.Empty(); - - var userTours = JsonConvert.DeserializeObject>(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.TourData!); - return userTours ?? Enumerable.Empty(); - } - - public IEnumerable GetUserData() => _userDataService.GetUserData(); - - /// - /// When a user is invited and they click on the invitation link, they will be partially logged in - /// where they can set their username/password - /// - /// - /// - /// - /// This only works when the user is logged in (partially) - /// - [AllowAnonymous] - public async Task> PostSetInvitedUserPassword([FromBody]string newPassword) - { - var user = await _backOfficeUserManager.FindByIdAsync(_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().ResultOr(0).ToString()); - if (user == null) throw new InvalidOperationException("Could not find user"); - - var result = await _backOfficeUserManager.AddPasswordAsync(user, newPassword); - - if (result.Succeeded == false) - { - //it wasn't successful, so add the change error to the model state, we've name the property alias _umb_password on the form - // so that is why it is being used here. - ModelState.AddModelError("value", result.Errors.ToErrorMessage()); - - return ValidationProblem(ModelState); - } - + userTours = new List { status }; if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser is not null) { - //They've successfully set their password, we can now update their user account to be approved - _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.IsApproved = true; - //They've successfully set their password, and will now get fully logged into the back office, so the lastlogindate is set so the backoffice shows they have logged in - _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.LastLoginDate = DateTime.UtcNow; + _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.TourData = + JsonConvert.SerializeObject(userTours); _userService.Save(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser); } - - //now we can return their full object since they are now really logged into the back office - var userDisplay = _umbracoMapper.Map(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser); - - if (userDisplay is not null) - { - userDisplay.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds(); - } - - return userDisplay; + return userTours; } - [AppendUserModifiedHeader] - public IActionResult PostSetAvatar(IList file) + if (_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.TourData is not null) { - var userId = _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId(); - var result = userId?.ResultOr(0); - //borrow the logic from the user controller - return UsersController.PostSetAvatarInternal(file, _userService, _appCaches.RuntimeCache, _mediaFileManager, _shortStringHelper, _contentSettings, _hostingEnvironment, _imageUrlGenerator, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0); + userTours = JsonConvert + .DeserializeObject>(_backofficeSecurityAccessor.BackOfficeSecurity + .CurrentUser.TourData)?.ToList(); + UserTourStatus? found = userTours?.FirstOrDefault(x => x.Alias == status.Alias); + if (found != null) + { + //remove it and we'll replace it next + userTours?.Remove(found); + } + + userTours?.Add(status); } - /// - /// Changes the users password - /// - /// The changing password model - /// - /// If the password is being reset it will return the newly reset password, otherwise will return an empty value - /// - public async Task>?> PostChangePassword(ChangingPasswordModel changingPasswordModel) + _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.TourData = JsonConvert.SerializeObject(userTours); + _userService.Save(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser); + return userTours ?? Enumerable.Empty(); + } + + /// + /// Returns the user's tours + /// + /// + public IEnumerable? GetUserTours() + { + if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.TourData.IsNullOrWhiteSpace() ?? true) { - IUser? currentUser = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - if (currentUser is null) - { - return null; - } + return Enumerable.Empty(); + } - changingPasswordModel.Id = currentUser.Id; + IEnumerable? userTours = + JsonConvert.DeserializeObject>(_backofficeSecurityAccessor.BackOfficeSecurity + .CurrentUser.TourData!); + return userTours ?? Enumerable.Empty(); + } - // all current users have access to reset/manually change their password + public IEnumerable GetUserData() => _userDataService.GetUserData(); - Attempt passwordChangeResult = await _passwordChanger.ChangePasswordWithIdentityAsync(changingPasswordModel, _backOfficeUserManager); + /// + /// When a user is invited and they click on the invitation link, they will be partially logged in + /// where they can set their username/password + /// + /// + /// + /// + /// This only works when the user is logged in (partially) + /// + [AllowAnonymous] + public async Task> PostSetInvitedUserPassword([FromBody] string newPassword) + { + BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByIdAsync(_backofficeSecurityAccessor + .BackOfficeSecurity?.GetUserId().ResultOr(0).ToString()); + if (user == null) + { + throw new InvalidOperationException("Could not find user"); + } - if (passwordChangeResult.Success) - { - // even if we weren't resetting this, it is the correct value (null), otherwise if we were resetting then it will contain the new pword - var result = new ModelWithNotifications(passwordChangeResult.Result?.ResetPassword); - result.AddSuccessNotification(_localizedTextService.Localize("user","password"), _localizedTextService.Localize("user","passwordChanged")); - return result; - } + IdentityResult result = await _backOfficeUserManager.AddPasswordAsync(user, newPassword); - if (passwordChangeResult.Result?.ChangeError?.MemberNames is not null) - { - foreach (string memberName in passwordChangeResult.Result.ChangeError.MemberNames) - { - ModelState.AddModelError(memberName, passwordChangeResult.Result.ChangeError.ErrorMessage ?? string.Empty); - } - } + if (result.Succeeded == false) + { + //it wasn't successful, so add the change error to the model state, we've name the property alias _umb_password on the form + // so that is why it is being used here. + ModelState.AddModelError("value", result.Errors.ToErrorMessage()); return ValidationProblem(ModelState); } - // TODO: Why is this necessary? This inherits from UmbracoAuthorizedApiController - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - [ValidateAngularAntiForgeryToken] - public async Task> GetCurrentUserLinkedLogins() + if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser is not null) { - var identityUser = await _backOfficeUserManager.FindByIdAsync(_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().ResultOr(0).ToString(CultureInfo.InvariantCulture)); + //They've successfully set their password, we can now update their user account to be approved + _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.IsApproved = true; + //They've successfully set their password, and will now get fully logged into the back office, so the lastlogindate is set so the backoffice shows they have logged in + _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.LastLoginDate = DateTime.UtcNow; + _userService.Save(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser); + } - // deduplicate in case there are duplicates (there shouldn't be now since we have a unique constraint on the external logins - // but there didn't used to be) - var result = new Dictionary(); - foreach (var l in identityUser.Logins) - { - result[l.LoginProvider] = l.ProviderKey; - } + + //now we can return their full object since they are now really logged into the back office + UserDetail? userDisplay = + _umbracoMapper.Map(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser); + + if (userDisplay is not null) + { + userDisplay.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds(); + } + + return userDisplay; + } + + [AppendUserModifiedHeader] + public IActionResult PostSetAvatar(IList file) + { + Attempt? userId = _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId(); + var result = userId?.ResultOr(0); + //borrow the logic from the user controller + return UsersController.PostSetAvatarInternal( + file, + _userService, + _appCaches.RuntimeCache, + _mediaFileManager, + _shortStringHelper, + _contentSettings, + _hostingEnvironment, + _imageUrlGenerator, + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0); + } + + /// + /// Changes the users password + /// + /// The changing password model + /// + /// If the password is being reset it will return the newly reset password, otherwise will return an empty value + /// + public async Task>?> PostChangePassword( + ChangingPasswordModel changingPasswordModel) + { + IUser? currentUser = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + if (currentUser is null) + { + return null; + } + + changingPasswordModel.Id = currentUser.Id; + + // all current users have access to reset/manually change their password + + Attempt passwordChangeResult = + await _passwordChanger.ChangePasswordWithIdentityAsync(changingPasswordModel, _backOfficeUserManager); + + if (passwordChangeResult.Success) + { + // even if we weren't resetting this, it is the correct value (null), otherwise if we were resetting then it will contain the new pword + var result = new ModelWithNotifications(passwordChangeResult.Result?.ResetPassword); + result.AddSuccessNotification(_localizedTextService.Localize("user", "password"), _localizedTextService.Localize("user", "passwordChanged")); return result; } + + if (passwordChangeResult.Result?.ChangeError?.MemberNames is not null) + { + foreach (var memberName in passwordChangeResult.Result.ChangeError.MemberNames) + { + ModelState.AddModelError(memberName, passwordChangeResult.Result.ChangeError.ErrorMessage ?? string.Empty); + } + } + + return ValidationProblem(ModelState); + } + + // TODO: Why is this necessary? This inherits from UmbracoAuthorizedApiController + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] + [ValidateAngularAntiForgeryToken] + public async Task> GetCurrentUserLinkedLogins() + { + BackOfficeIdentityUser identityUser = await _backOfficeUserManager.FindByIdAsync(_backofficeSecurityAccessor + .BackOfficeSecurity?.GetUserId().ResultOr(0).ToString(CultureInfo.InvariantCulture)); + + // deduplicate in case there are duplicates (there shouldn't be now since we have a unique constraint on the external logins + // but there didn't used to be) + var result = new Dictionary(); + foreach (IIdentityUserLogin l in identityUser.Logins) + { + result[l.LoginProvider] = l.ProviderKey; + } + + return result; } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs b/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs index 44b4551eda..be71a7544a 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/DashboardController.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -12,11 +7,12 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Dashboards; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; @@ -25,281 +21,274 @@ using Umbraco.Cms.Web.BackOffice.Filters; 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; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +//we need to fire up the controller like this to enable loading of remote css directly from this controller +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[ValidationFilter] +[AngularJsonOnlyConfiguration] // TODO: This could be applied with our Application Model conventions +[IsBackOffice] +[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] +public class DashboardController : UmbracoApiController { - //we need to fire up the controller like this to enable loading of remote css directly from this controller - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [ValidationFilter] - [AngularJsonOnlyConfiguration] // TODO: This could be applied with our Application Model conventions - [IsBackOffice] - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - public class DashboardController : UmbracoApiController + //we have just one instance of HttpClient shared for the entire application + private static readonly HttpClient HttpClient = new(); + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IDashboardService _dashboardService; + private readonly ContentDashboardSettings _dashboardSettings; + private readonly ILogger _logger; + private readonly IShortStringHelper _shortStringHelper; + private readonly ISiteIdentifierService _siteIdentifierService; + private readonly IUmbracoVersion _umbracoVersion; + + /// + /// Initializes a new instance of the with all its dependencies. + /// + [ActivatorUtilitiesConstructor] + public DashboardController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + AppCaches appCaches, + ILogger logger, + IDashboardService dashboardService, + IUmbracoVersion umbracoVersion, + IShortStringHelper shortStringHelper, + IOptionsSnapshot dashboardSettings, + ISiteIdentifierService siteIdentifierService) + { - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly AppCaches _appCaches; - private readonly ILogger _logger; - private readonly IDashboardService _dashboardService; - private readonly IUmbracoVersion _umbracoVersion; - private readonly IShortStringHelper _shortStringHelper; - private readonly ISiteIdentifierService _siteIdentifierService; - private readonly ContentDashboardSettings _dashboardSettings; - - /// - /// Initializes a new instance of the with all its dependencies. - /// - [ActivatorUtilitiesConstructor] - public DashboardController( - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - AppCaches appCaches, - ILogger logger, - IDashboardService dashboardService, - IUmbracoVersion umbracoVersion, - IShortStringHelper shortStringHelper, - IOptionsSnapshot dashboardSettings, - ISiteIdentifierService siteIdentifierService) + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _appCaches = appCaches; + _logger = logger; + _dashboardService = dashboardService; + _umbracoVersion = umbracoVersion; + _shortStringHelper = shortStringHelper; + _siteIdentifierService = siteIdentifierService; + _dashboardSettings = dashboardSettings.Value; + } + // TODO(V10) : change return type to Task> and consider removing baseUrl as parameter + //we have baseurl as a param to make previewing easier, so we can test with a dev domain from client side + [ValidateAngularAntiForgeryToken] + public async Task GetRemoteDashboardContent(string section, string? baseUrl) + { + if (baseUrl is null) { - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _appCaches = appCaches; - _logger = logger; - _dashboardService = dashboardService; - _umbracoVersion = umbracoVersion; - _shortStringHelper = shortStringHelper; - _siteIdentifierService = siteIdentifierService; - _dashboardSettings = dashboardSettings.Value; + baseUrl = "https://dashboard.umbraco.com/"; } - //we have just one instance of HttpClient shared for the entire application - private static readonly HttpClient HttpClient = new HttpClient(); + IUser? user = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + var allowedSections = string.Join(",", user?.AllowedSections ?? Array.Empty()); + var language = user?.Language; + var version = _umbracoVersion.SemanticVersion.ToSemanticStringWithoutBuild(); + var isAdmin = user?.IsAdmin() ?? false; + _siteIdentifierService.TryGetOrCreateSiteIdentifier(out Guid siteIdentifier); - // TODO(V10) : change return type to Task> and consider removing baseUrl as parameter - //we have baseurl as a param to make previewing easier, so we can test with a dev domain from client side - [ValidateAngularAntiForgeryToken] - public async Task GetRemoteDashboardContent(string section, string? baseUrl) + if (!IsAllowedUrl(baseUrl)) { - if (baseUrl is null) - { - baseUrl = "https://dashboard.umbraco.com/"; - } - - var user = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - var allowedSections = string.Join(",", user?.AllowedSections ?? Array.Empty()); - var language = user?.Language; - var version = _umbracoVersion.SemanticVersion.ToSemanticStringWithoutBuild(); - var isAdmin = user?.IsAdmin() ?? false; - _siteIdentifierService.TryGetOrCreateSiteIdentifier(out Guid siteIdentifier); - - if (!IsAllowedUrl(baseUrl)) - { - _logger.LogError($"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {baseUrl}"); - HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; - - // Hacking the response - can't set the HttpContext.Response.Body, so instead returning the error as JSON - var errorJson = JsonConvert.SerializeObject(new { Error = "Dashboard source not permitted" }); - return JObject.Parse(errorJson); - } - - var url = string.Format("{0}{1}?section={2}&allowed={3}&lang={4}&version={5}&admin={6}&siteid={7}", - baseUrl, - _dashboardSettings.ContentDashboardPath, - section, - allowedSections, - language, - version, - isAdmin, - siteIdentifier); - var key = "umbraco-dynamic-dashboard-" + language + allowedSections.Replace(",", "-") + section; - - var content = _appCaches.RuntimeCache.GetCacheItem(key); - var result = new JObject(); - if (content != null) + _logger.LogError( + $"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {baseUrl}"); + HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + + // Hacking the response - can't set the HttpContext.Response.Body, so instead returning the error as JSON + var errorJson = JsonConvert.SerializeObject(new { Error = "Dashboard source not permitted" }); + return JObject.Parse(errorJson); + } + + var url = string.Format( + "{0}{1}?section={2}&allowed={3}&lang={4}&version={5}&admin={6}&siteid={7}", + baseUrl, + _dashboardSettings.ContentDashboardPath, + section, + allowedSections, + language, + version, + isAdmin, + siteIdentifier); + var key = "umbraco-dynamic-dashboard-" + language + allowedSections.Replace(",", "-") + section; + + JObject? content = _appCaches.RuntimeCache.GetCacheItem(key); + var result = new JObject(); + if (content != null) + { + result = content; + } + else + { + //content is null, go get it + try { + //fetch dashboard json and parse to JObject + var json = await HttpClient.GetStringAsync(url); + content = JObject.Parse(json); result = content; + + _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 30, 0)); } - else + catch (HttpRequestException ex) { - //content is null, go get it - try - { - //fetch dashboard json and parse to JObject - var json = await HttpClient.GetStringAsync(url); - content = JObject.Parse(json); - result = content; + _logger.LogError(ex.InnerException ?? ex, "Error getting dashboard content from {Url}", url); - _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 30, 0)); - } - catch (HttpRequestException ex) - { - _logger.LogError(ex.InnerException ?? ex, "Error getting dashboard content from {Url}", url); - - //it's still new JObject() - we return it like this to avoid error codes which triggers UI warnings - _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 5, 0)); - } + //it's still new JObject() - we return it like this to avoid error codes which triggers UI warnings + _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 5, 0)); } - - return result; } - // TODO(V10) : consider removing baseUrl as parameter - public async Task GetRemoteDashboardCss(string section, string? baseUrl) + return result; + } + + // TODO(V10) : consider removing baseUrl as parameter + public async Task GetRemoteDashboardCss(string section, string? baseUrl) + { + if (baseUrl is null) { - if (baseUrl is null) + baseUrl = "https://dashboard.umbraco.org/"; + } + + if (!IsAllowedUrl(baseUrl)) + { + _logger.LogError( + $"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {baseUrl}"); + return BadRequest("Dashboard source not permitted"); + } + + var url = string.Format(baseUrl + "css/dashboard.css?section={0}", section); + var key = "umbraco-dynamic-dashboard-css-" + section; + + var content = _appCaches.RuntimeCache.GetCacheItem(key); + var result = string.Empty; + + if (content != null) + { + result = content; + } + else + { + //content is null, go get it + try { - baseUrl = "https://dashboard.umbraco.org/"; - } + //fetch remote css + content = await HttpClient.GetStringAsync(url); - if (!IsAllowedUrl(baseUrl)) - { - _logger.LogError($"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {baseUrl}"); - return BadRequest("Dashboard source not permitted"); - } - - var url = string.Format(baseUrl + "css/dashboard.css?section={0}", section); - var key = "umbraco-dynamic-dashboard-css-" + section; - - var content = _appCaches.RuntimeCache.GetCacheItem(key); - var result = string.Empty; - - if (content != null) - { + //can't use content directly, modified closure problem result = content; + + //save server content for 30 mins + _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 30, 0)); } - else + catch (HttpRequestException ex) { - //content is null, go get it - try - { - //fetch remote css - content = await HttpClient.GetStringAsync(url); + _logger.LogError(ex.InnerException ?? ex, "Error getting dashboard CSS from {Url}", url); - //can't use content directly, modified closure problem - result = content; - - //save server content for 30 mins - _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 30, 0)); - } - catch (HttpRequestException ex) - { - _logger.LogError(ex.InnerException ?? ex, "Error getting dashboard CSS from {Url}", url); - - //it's still string.Empty - we return it like this to avoid error codes which triggers UI warnings - _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 5, 0)); - } + //it's still string.Empty - we return it like this to avoid error codes which triggers UI warnings + _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 5, 0)); } - - - return Content(result, "text/css", Encoding.UTF8); - } - public async Task GetRemoteXml(string site, string url) + + return Content(result, "text/css", Encoding.UTF8); + } + + public async Task GetRemoteXml(string site, string url) + { + if (!IsAllowedUrl(url)) { - if (!IsAllowedUrl(url)) + _logger.LogError( + $"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {url}"); + return BadRequest("Dashboard source not permitted"); + } + + // This is used in place of the old feedproxy.config + // Which was used to grab data from our.umbraco.com, umbraco.com or umbraco.tv + // for certain dashboards or the help drawer + var urlPrefix = string.Empty; + switch (site.ToUpper()) + { + case "TV": + urlPrefix = "https://umbraco.tv/"; + break; + + case "OUR": + urlPrefix = "https://our.umbraco.com/"; + break; + + case "COM": + urlPrefix = "https://umbraco.com/"; + break; + + default: + return NotFound(); + } + + + //Make remote call to fetch videos or remote dashboard feed data + var key = $"umbraco-XML-feed-{site}-{url.ToCleanString(_shortStringHelper, CleanStringType.UrlSegment)}"; + + var content = _appCaches.RuntimeCache.GetCacheItem(key); + var result = string.Empty; + + if (content != null) + { + result = content; + } + else + { + //content is null, go get it + try { - _logger.LogError($"The following URL is not listed in the setting 'Umbraco:CMS:ContentDashboard:ContentDashboardUrlAllowlist' in configuration: {url}"); - return BadRequest("Dashboard source not permitted"); - } + //fetch remote css + content = await HttpClient.GetStringAsync($"{urlPrefix}{url}"); - // This is used in place of the old feedproxy.config - // Which was used to grab data from our.umbraco.com, umbraco.com or umbraco.tv - // for certain dashboards or the help drawer - var urlPrefix = string.Empty; - switch (site.ToUpper()) - { - case "TV": - urlPrefix = "https://umbraco.tv/"; - break; - - case "OUR": - urlPrefix = "https://our.umbraco.com/"; - break; - - case "COM": - urlPrefix = "https://umbraco.com/"; - break; - - default: - return NotFound(); - } - - - //Make remote call to fetch videos or remote dashboard feed data - var key = $"umbraco-XML-feed-{site}-{url.ToCleanString(_shortStringHelper, CleanStringType.UrlSegment)}"; - - var content = _appCaches.RuntimeCache.GetCacheItem(key); - var result = string.Empty; - - if (content != null) - { + //can't use content directly, modified closure problem result = content; + + //save server content for 30 mins + _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 30, 0)); } - else + catch (HttpRequestException ex) { - //content is null, go get it - try - { - //fetch remote css - content = await HttpClient.GetStringAsync($"{urlPrefix}{url}"); + _logger.LogError(ex.InnerException ?? ex, "Error getting remote dashboard data from {UrlPrefix}{Url}", urlPrefix, url); - //can't use content directly, modified closure problem - result = content; - - //save server content for 30 mins - _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 30, 0)); - } - catch (HttpRequestException ex) - { - _logger.LogError(ex.InnerException ?? ex, "Error getting remote dashboard data from {UrlPrefix}{Url}", urlPrefix, url); - - //it's still string.Empty - we return it like this to avoid error codes which triggers UI warnings - _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 5, 0)); - } + //it's still string.Empty - we return it like this to avoid error codes which triggers UI warnings + _appCaches.RuntimeCache.InsertCacheItem(key, () => result, new TimeSpan(0, 5, 0)); } - - return Content(result, "text/xml", Encoding.UTF8); - } - // return IDashboardSlim - we don't need sections nor access rules - [ValidateAngularAntiForgeryToken] - [OutgoingEditorModelEvent] - public IEnumerable> GetDashboard(string section) + return Content(result, "text/xml", Encoding.UTF8); + } + + // return IDashboardSlim - we don't need sections nor access rules + [ValidateAngularAntiForgeryToken] + [OutgoingEditorModelEvent] + public IEnumerable> GetDashboard(string section) + { + IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + return _dashboardService.GetDashboards(section, currentUser).Select(x => new Tab { - var currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - return _dashboardService.GetDashboards(section, currentUser).Select(x => new Tab - { - Id = x.Id, - Key = x.Key, - Label = x.Label, - Alias = x.Alias, - Type = x.Type, - Expanded = x.Expanded, - IsActive = x.IsActive, - Properties = x.Properties?.Select(y => new DashboardSlim - { - Alias = y.Alias, - View = y.View - }) - }).ToList(); + Id = x.Id, + Key = x.Key, + Label = x.Label, + Alias = x.Alias, + Type = x.Type, + Expanded = x.Expanded, + IsActive = x.IsActive, + Properties = x.Properties?.Select(y => new DashboardSlim { Alias = y.Alias, View = y.View }) + }).ToList(); + } + + // Checks if the passed URL is part of the configured allowlist of addresses + private bool IsAllowedUrl(string url) + { + // No addresses specified indicates that any URL is allowed + if (_dashboardSettings.ContentDashboardUrlAllowlist is null || + _dashboardSettings.ContentDashboardUrlAllowlist.Contains(url, StringComparer.OrdinalIgnoreCase)) + { + return true; } - // Checks if the passed URL is part of the configured allowlist of addresses - private bool IsAllowedUrl(string url) - { - // No addresses specified indicates that any URL is allowed - if (_dashboardSettings.ContentDashboardUrlAllowlist is null || _dashboardSettings.ContentDashboardUrlAllowlist.Contains(url, StringComparer.OrdinalIgnoreCase)) - { - return true; - } - else - { - return false; - } - } + return false; } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/DictionaryController.cs b/src/Umbraco.Web.BackOffice/Controllers/DictionaryController.cs index 91e3385242..4863845ac3 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/DictionaryController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/DictionaryController.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Net.Http; using System.Net.Mime; using System.Text; using Microsoft.AspNetCore.Authorization; @@ -19,341 +15,373 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// +/// The API controller used for editing dictionary items +/// +/// +/// The security for this controller is defined to allow full CRUD access to dictionary if the user has access to +/// either: +/// Dictionary +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDictionary)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +public class DictionaryController : BackOfficeNotificationsController { - /// - /// - /// The API controller used for editing dictionary items - /// - /// - /// The security for this controller is defined to allow full CRUD access to dictionary if the user has access to either: - /// Dictionary - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDictionary)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - public class DictionaryController : BackOfficeNotificationsController + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly GlobalSettings _globalSettings; + private readonly ILocalizationService _localizationService; + private readonly ILocalizedTextService _localizedTextService; + private readonly ILogger _logger; + private readonly IUmbracoMapper _umbracoMapper; + + public DictionaryController( + ILogger logger, + ILocalizationService localizationService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IOptionsSnapshot globalSettings, + ILocalizedTextService localizedTextService, + IUmbracoMapper umbracoMapper) { - private readonly ILogger _logger; - private readonly ILocalizationService _localizationService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly GlobalSettings _globalSettings; - private readonly ILocalizedTextService _localizedTextService; - private readonly IUmbracoMapper _umbracoMapper; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _globalSettings = globalSettings.Value ?? throw new ArgumentNullException(nameof(globalSettings)); + _localizedTextService = localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + } - public DictionaryController( - ILogger logger, - ILocalizationService localizationService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IOptionsSnapshot globalSettings, - ILocalizedTextService localizedTextService, - IUmbracoMapper umbracoMapper - ) + /// + /// Deletes a data type with a given ID + /// + /// + /// + /// + /// + [HttpDelete] + [HttpPost] + public IActionResult DeleteById(int id) + { + IDictionaryItem? foundDictionary = _localizationService.GetDictionaryItemById(id); + + if (foundDictionary == null) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _globalSettings = globalSettings.Value ?? throw new ArgumentNullException(nameof(globalSettings)); - _localizedTextService = localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + return NotFound(); } - /// - /// Deletes a data type with a given ID - /// - /// - /// - [HttpDelete] - [HttpPost] - public IActionResult DeleteById(int id) + IEnumerable foundDictionaryDescendants = + _localizationService.GetDictionaryItemDescendants(foundDictionary.Key); + + foreach (IDictionaryItem dictionaryItem in foundDictionaryDescendants) { - var foundDictionary = _localizationService.GetDictionaryItemById(id); + _localizationService.Delete(dictionaryItem, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + } - if (foundDictionary == null) - return NotFound(); + _localizationService.Delete(foundDictionary, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - var foundDictionaryDescendants = _localizationService.GetDictionaryItemDescendants(foundDictionary.Key); + return Ok(); + } - foreach (var dictionaryItem in foundDictionaryDescendants) + /// + /// Creates a new dictionary item + /// + /// + /// The parent id. + /// + /// + /// The key. + /// + /// + /// The . + /// + [HttpPost] + public ActionResult Create(int parentId, string key) + { + if (string.IsNullOrEmpty(key)) + { + return ValidationProblem("Key can not be empty."); // TODO: translate + } + + if (_localizationService.DictionaryItemExists(key)) + { + var message = _localizedTextService.Localize( + "dictionaryItem", + "changeKeyError", + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetUserCulture(_localizedTextService, _globalSettings), + new Dictionary + { + {"0", key} + }); + return ValidationProblem(message); + } + + try + { + Guid? parentGuid = null; + + if (parentId > 0) { - _localizationService.Delete(dictionaryItem, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + parentGuid = _localizationService.GetDictionaryItemById(parentId)?.Key; } - _localizationService.Delete(foundDictionary, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + IDictionaryItem item = _localizationService.CreateDictionaryItemWithIdentity( + key, + parentGuid, + string.Empty); - return Ok(); + + return item.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating dictionary with {Name} under {ParentId}", key, parentId); + return ValidationProblem("Error creating dictionary item"); + } + } + + /// + /// Gets a dictionary item by id + /// + /// + /// The id. + /// + /// + /// The . Returns a not found response when dictionary item does not exist + /// + public ActionResult GetById(int id) + { + IDictionaryItem? dictionary = _localizationService.GetDictionaryItemById(id); + if (dictionary == null) + { + return NotFound(); } - /// - /// Creates a new dictionary item - /// - /// - /// The parent id. - /// - /// - /// The key. - /// - /// - /// The . - /// - [HttpPost] - public ActionResult Create(int parentId, string key) - { - if (string.IsNullOrEmpty(key)) - return ValidationProblem("Key can not be empty."); // TODO: translate + return _umbracoMapper.Map(dictionary); + } - if (_localizationService.DictionaryItemExists(key)) + /// + /// Gets a dictionary item by guid + /// + /// + /// The id. + /// + /// + /// The . Returns a not found response when dictionary item does not exist + /// + public ActionResult GetById(Guid id) + { + IDictionaryItem? dictionary = _localizationService.GetDictionaryItemById(id); + if (dictionary == null) + { + return NotFound(); + } + + return _umbracoMapper.Map(dictionary); + } + + /// + /// Gets a dictionary item by udi + /// + /// + /// The id. + /// + /// + /// The . Returns a not found response when dictionary item does not exist + /// + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) + { + return NotFound(); + } + + IDictionaryItem? dictionary = _localizationService.GetDictionaryItemById(guidUdi.Guid); + if (dictionary == null) + { + return NotFound(); + } + + return _umbracoMapper.Map(dictionary); + } + + /// + /// Changes the structure for dictionary items + /// + /// + /// + public IActionResult? PostMove(MoveOrCopy move) + { + IDictionaryItem? dictionaryItem = _localizationService.GetDictionaryItemById(move.Id); + if (dictionaryItem == null) + { + return ValidationProblem(_localizedTextService.Localize("dictionary", "itemDoesNotExists")); + } + + IDictionaryItem? parent = _localizationService.GetDictionaryItemById(move.ParentId); + if (parent == null) + { + if (move.ParentId == Constants.System.Root) { - var message = _localizedTextService.Localize( - "dictionaryItem","changeKeyError", - _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetUserCulture(_localizedTextService, _globalSettings), - new Dictionary { { "0", key } }); - return ValidationProblem(message); - } - - try - { - Guid? parentGuid = null; - - if (parentId > 0) - parentGuid = _localizationService.GetDictionaryItemById(parentId)?.Key; - - var item = _localizationService.CreateDictionaryItemWithIdentity( - key, - parentGuid, - string.Empty); - - - return item.Id; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating dictionary with {Name} under {ParentId}", key, parentId); - return ValidationProblem("Error creating dictionary item"); - } - } - - /// - /// Gets a dictionary item by id - /// - /// - /// The id. - /// - /// - /// The . Returns a not found response when dictionary item does not exist - /// - public ActionResult GetById(int id) - { - var dictionary = _localizationService.GetDictionaryItemById(id); - if (dictionary == null) - return NotFound(); - - return _umbracoMapper.Map(dictionary); - } - - /// - /// Gets a dictionary item by guid - /// - /// - /// The id. - /// - /// - /// The . Returns a not found response when dictionary item does not exist - /// - public ActionResult GetById(Guid id) - { - var dictionary = _localizationService.GetDictionaryItemById(id); - if (dictionary == null) - return NotFound(); - - return _umbracoMapper.Map(dictionary); - } - - /// - /// Gets a dictionary item by udi - /// - /// - /// The id. - /// - /// - /// The . Returns a not found response when dictionary item does not exist - /// - public ActionResult GetById(Udi id) - { - var guidUdi = id as GuidUdi; - if (guidUdi == null) - return NotFound(); - - var dictionary = _localizationService.GetDictionaryItemById(guidUdi.Guid); - if (dictionary == null) - return NotFound(); - - return _umbracoMapper.Map(dictionary); - } - - /// - /// Changes the structure for dictionary items - /// - /// - /// - public IActionResult? PostMove(MoveOrCopy move) - { - var dictionaryItem = _localizationService.GetDictionaryItemById(move.Id); - if (dictionaryItem == null) - return ValidationProblem(_localizedTextService.Localize("dictionary", "itemDoesNotExists")); - - var parent = _localizationService.GetDictionaryItemById(move.ParentId); - if (parent == null) - { - if (move.ParentId == Constants.System.Root) - dictionaryItem.ParentId = null; - else - return ValidationProblem(_localizedTextService.Localize("dictionary", "parentDoesNotExists")); + dictionaryItem.ParentId = null; } else { - dictionaryItem.ParentId = parent.Key; - if (dictionaryItem.Key == parent.ParentId) - return ValidationProblem(_localizedTextService.Localize("moveOrCopy", "notAllowedByPath")); + return ValidationProblem(_localizedTextService.Localize("dictionary", "parentDoesNotExists")); + } + } + else + { + dictionaryItem.ParentId = parent.Key; + if (dictionaryItem.Key == parent.ParentId) + { + return ValidationProblem(_localizedTextService.Localize("moveOrCopy", "notAllowedByPath")); + } + } + + _localizationService.Save(dictionaryItem); + + DictionaryDisplay? model = _umbracoMapper.Map(dictionaryItem); + + return Content(model!.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); + } + + /// + /// Saves a dictionary item + /// + /// + /// The dictionary. + /// + /// + /// The . + /// + public ActionResult PostSave(DictionarySave dictionary) + { + IDictionaryItem? dictionaryItem = dictionary.Id is null + ? null + : _localizationService.GetDictionaryItemById(int.Parse(dictionary.Id.ToString()!, CultureInfo.InvariantCulture)); + + if (dictionaryItem == null) + { + return ValidationProblem("Dictionary item does not exist"); + } + + CultureInfo? userCulture = + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetUserCulture(_localizedTextService, _globalSettings); + + if (dictionary.NameIsDirty) + { + // if the name (key) has changed, we need to check if the new key does not exist + IDictionaryItem? dictionaryByKey = _localizationService.GetDictionaryItemByKey(dictionary.Name!); + + if (dictionaryByKey != null && dictionaryItem.Id != dictionaryByKey.Id) + { + var message = _localizedTextService.Localize( + "dictionaryItem", + "changeKeyError", + userCulture, + new Dictionary { { "0", dictionary.Name } }); + ModelState.AddModelError("Name", message); + return ValidationProblem(ModelState); } + dictionaryItem.ItemKey = dictionary.Name!; + } + + foreach (DictionaryTranslationSave translation in dictionary.Translations) + { + _localizationService.AddOrUpdateDictionaryValue(dictionaryItem, _localizationService.GetLanguageById(translation.LanguageId), translation.Translation); + } + + try + { _localizationService.Save(dictionaryItem); - var model = _umbracoMapper.Map(dictionaryItem); + DictionaryDisplay? model = _umbracoMapper.Map(dictionaryItem); - return Content(model!.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); + model?.Notifications.Add(new BackOfficeNotification( + _localizedTextService.Localize("speechBubbles", "dictionaryItemSaved", userCulture), string.Empty, NotificationStyle.Success)); + + return model; } - - /// - /// Saves a dictionary item - /// - /// - /// The dictionary. - /// - /// - /// The . - /// - public ActionResult PostSave(DictionarySave dictionary) + catch (Exception ex) { - var dictionaryItem = dictionary.Id is null ? null : - _localizationService.GetDictionaryItemById(int.Parse(dictionary.Id.ToString()!, CultureInfo.InvariantCulture)); - - if (dictionaryItem == null) - return ValidationProblem("Dictionary item does not exist"); - - var userCulture = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetUserCulture(_localizedTextService, _globalSettings); - - if (dictionary.NameIsDirty) - { - // if the name (key) has changed, we need to check if the new key does not exist - var dictionaryByKey = _localizationService.GetDictionaryItemByKey(dictionary.Name!); - - if (dictionaryByKey != null && dictionaryItem.Id != dictionaryByKey.Id) - { - - var message = _localizedTextService.Localize( - "dictionaryItem","changeKeyError", - userCulture, - new Dictionary { { "0", dictionary.Name } }); - ModelState.AddModelError("Name", message); - return ValidationProblem(ModelState); - } - - dictionaryItem.ItemKey = dictionary.Name!; - } - - foreach (var translation in dictionary.Translations) - { - _localizationService.AddOrUpdateDictionaryValue(dictionaryItem, - _localizationService.GetLanguageById(translation.LanguageId), translation.Translation); - } - - try - { - _localizationService.Save(dictionaryItem); - - var model = _umbracoMapper.Map(dictionaryItem); - - model?.Notifications.Add(new BackOfficeNotification( - _localizedTextService.Localize("speechBubbles","dictionaryItemSaved", userCulture), string.Empty, - NotificationStyle.Success)); - - return model; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving dictionary with {Name} under {ParentId}", dictionary.Name, dictionary.ParentId); - return ValidationProblem("Something went wrong saving dictionary"); - } + _logger.LogError(ex, "Error saving dictionary with {Name} under {ParentId}", dictionary.Name, dictionary.ParentId); + return ValidationProblem("Something went wrong saving dictionary"); } - - /// - /// Retrieves a list with all dictionary items - /// - /// - /// The . - /// - public IEnumerable GetList() - { - var items = _localizationService.GetDictionaryItemDescendants(null).ToArray(); - var list = new List(items.Length); - - // recursive method to build a tree structure from the flat structure returned above - void BuildTree(int level = 0, Guid? parentId = null) - { - var children = items.Where(t => t.ParentId == parentId).ToArray(); - if(children.Any() == false) - { - return; - } - - foreach(var child in children.OrderBy(ItemSort())) - { - var display = _umbracoMapper.Map(child); - if (display is not null) - { - display.Level = level; - list.Add(display); - } - - BuildTree(level + 1, child.Key); - } - } - - BuildTree(); - - return list; - } - - /// - /// Get child items for list. - /// - /// - /// The dictionary item. - /// - /// - /// The level. - /// - /// - /// The list. - /// - private void GetChildItemsForList(IDictionaryItem dictionaryItem, int level, ICollection list) - { - foreach (var childItem in _localizationService.GetDictionaryItemChildren(dictionaryItem.Key)?.OrderBy(ItemSort()) ?? Enumerable.Empty()) - { - var item = _umbracoMapper.Map(childItem); - if (item is not null) - { - item.Level = level; - list.Add(item); - } - - GetChildItemsForList(childItem, level + 1, list); - } - } - - private static Func ItemSort() => item => item.ItemKey; } + + /// + /// Retrieves a list with all dictionary items + /// + /// + /// The . + /// + public IEnumerable GetList() + { + IDictionaryItem[] items = _localizationService.GetDictionaryItemDescendants(null).ToArray(); + var list = new List(items.Length); + + // recursive method to build a tree structure from the flat structure returned above + void BuildTree(int level = 0, Guid? parentId = null) + { + IDictionaryItem[] children = items.Where(t => t.ParentId == parentId).ToArray(); + if (children.Any() == false) + { + return; + } + + foreach (IDictionaryItem child in children.OrderBy(ItemSort())) + { + DictionaryOverviewDisplay? display = + _umbracoMapper.Map(child); + if (display is not null) + { + display.Level = level; + list.Add(display); + } + + BuildTree(level + 1, child.Key); + } + } + + BuildTree(); + + return list; + } + + /// + /// Get child items for list. + /// + /// + /// The dictionary item. + /// + /// + /// The level. + /// + /// + /// The list. + /// + private void GetChildItemsForList(IDictionaryItem dictionaryItem, int level, ICollection list) + { + foreach (IDictionaryItem childItem in _localizationService.GetDictionaryItemChildren(dictionaryItem.Key) + ?.OrderBy(ItemSort()) ?? Enumerable.Empty()) + { + DictionaryOverviewDisplay? item = _umbracoMapper.Map(childItem); + if (item is not null) + { + item.Level = level; + list.Add(item); + } + + GetChildItemsForList(childItem, level + 1, list); + } + } + + private static Func ItemSort() => item => item.ItemKey; } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ElementTypeController.cs b/src/Umbraco.Web.BackOffice/Controllers/ElementTypeController.cs index f15010387d..c2710c57c9 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ElementTypeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ElementTypeController.cs @@ -1,40 +1,32 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController("UmbracoApi")] +public class ElementTypeController : UmbracoAuthorizedJsonController { - [PluginController("UmbracoApi")] - public class ElementTypeController : UmbracoAuthorizedJsonController - { - private readonly IContentTypeService _contentTypeService; + private readonly IContentTypeService _contentTypeService; - public ElementTypeController(IContentTypeService contentTypeService) - { - _contentTypeService = contentTypeService; - } + public ElementTypeController(IContentTypeService contentTypeService) => _contentTypeService = contentTypeService; - [HttpGet] - public IEnumerable GetAll() - { - return _contentTypeService - .GetAllElementTypes() - .OrderBy(x => x.SortOrder) - .Select(x => new - { - id = x.Id, - key = x.Key, - name = x.Name, - description = x.Description, - alias = x.Alias, - icon = x.Icon - }); - } - } + [HttpGet] + public IEnumerable GetAll() => + _contentTypeService + .GetAllElementTypes() + .OrderBy(x => x.SortOrder) + .Select(x => new + { + id = x.Id, + key = x.Key, + name = x.Name, + description = x.Description, + alias = x.Alias, + icon = x.Icon + }); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs index a3716f53aa..3f51e909b9 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs @@ -1,12 +1,8 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Dynamic; using System.Globalization; -using System.Linq; using System.Linq.Expressions; using System.Reflection; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -32,1610 +28,1619 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.ModelBinders; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// The API controller used for getting entity objects, basic name, icon, id representation of umbraco objects that are +/// based on CMSNode +/// +/// +/// +/// This controller allows resolving basic entity data for various entities without placing the hard restrictions +/// on users that may not have access +/// to the sections these entities entities exist in. This is to allow pickers, etc... of data to work for all +/// users. In some cases such as accessing +/// Members, more explicit security checks are done. +/// +/// Some objects such as macros are not based on CMSNode +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[ParameterSwapControllerActionSelector(nameof(GetAncestors), "id", typeof(int), typeof(Guid))] +[ParameterSwapControllerActionSelector(nameof(GetPagedChildren), "id", typeof(int), typeof(string))] +[ParameterSwapControllerActionSelector(nameof(GetPath), "id", typeof(int), typeof(Guid), typeof(Udi))] +[ParameterSwapControllerActionSelector(nameof(GetUrlAndAnchors), "id", typeof(int), typeof(Guid), typeof(Udi))] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +[ParameterSwapControllerActionSelector(nameof(GetByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))] +[ParameterSwapControllerActionSelector(nameof(GetUrl), "id", typeof(int), typeof(Udi))] +[ParameterSwapControllerActionSelector(nameof(GetUrlsByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))] +public class EntityController : UmbracoAuthorizedJsonController { - /// - /// The API controller used for getting entity objects, basic name, icon, id representation of umbraco objects that are - /// based on CMSNode - /// - /// - /// - /// This controller allows resolving basic entity data for various entities without placing the hard restrictions - /// on users that may not have access - /// to the sections these entities entities exist in. This is to allow pickers, etc... of data to work for all - /// users. In some cases such as accessing - /// Members, more explicit security checks are done. - /// - /// Some objects such as macros are not based on CMSNode - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [ParameterSwapControllerActionSelector(nameof(GetAncestors), "id", typeof(int), typeof(Guid))] - [ParameterSwapControllerActionSelector(nameof(GetPagedChildren), "id", typeof(int), typeof(string))] - [ParameterSwapControllerActionSelector(nameof(GetPath), "id", typeof(int), typeof(Guid), typeof(Udi))] - [ParameterSwapControllerActionSelector(nameof(GetUrlAndAnchors), "id", typeof(int), typeof(Guid), typeof(Udi))] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - [ParameterSwapControllerActionSelector(nameof(GetByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))] - [ParameterSwapControllerActionSelector(nameof(GetUrl), "id", typeof(int), typeof(Udi))] - [ParameterSwapControllerActionSelector(nameof(GetUrlsByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))] - public class EntityController : UmbracoAuthorizedJsonController + private static readonly string[] _postFilterSplitStrings = { "=", "==", "!=", "<>", ">", "<", ">=", "<=" }; + + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IContentService _contentService; + private readonly IContentTypeService _contentTypeService; + private readonly IDataTypeService _dataTypeService; + private readonly IEntityService _entityService; + private readonly IFileService _fileService; + private readonly ILocalizationService _localizationService; + private readonly ILocalizedTextService _localizedTextService; + private readonly IMacroService _macroService; + private readonly IMediaTypeService _mediaTypeService; + private readonly IPublishedContentQuery _publishedContentQuery; + private readonly IPublishedUrlProvider _publishedUrlProvider; + private readonly SearchableTreeCollection _searchableTreeCollection; + private readonly IShortStringHelper _shortStringHelper; + private readonly ISqlContext _sqlContext; + private readonly UmbracoTreeSearcher _treeSearcher; + private readonly ITreeService _treeService; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IUserService _userService; + + public EntityController( + ITreeService treeService, + UmbracoTreeSearcher treeSearcher, + SearchableTreeCollection searchableTreeCollection, + IPublishedContentQuery publishedContentQuery, + IShortStringHelper shortStringHelper, + IEntityService entityService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IPublishedUrlProvider publishedUrlProvider, + IContentService contentService, + IUmbracoMapper umbracoMapper, + IDataTypeService dataTypeService, + ISqlContext sqlContext, + ILocalizedTextService localizedTextService, + IFileService fileService, + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMacroService macroService, + IUserService userService, + ILocalizationService localizationService, + AppCaches appCaches) { - private static readonly string[] _postFilterSplitStrings = { "=", "==", "!=", "<>", ">", "<", ">=", "<=" }; + _treeService = treeService ?? throw new ArgumentNullException(nameof(treeService)); + _treeSearcher = treeSearcher ?? throw new ArgumentNullException(nameof(treeSearcher)); + _searchableTreeCollection = searchableTreeCollection ?? + throw new ArgumentNullException(nameof(searchableTreeCollection)); + _publishedContentQuery = + publishedContentQuery ?? throw new ArgumentNullException(nameof(publishedContentQuery)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _publishedUrlProvider = + publishedUrlProvider ?? throw new ArgumentNullException(nameof(publishedUrlProvider)); + _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); + _sqlContext = sqlContext ?? throw new ArgumentNullException(nameof(sqlContext)); + _localizedTextService = + localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + _fileService = fileService ?? throw new ArgumentNullException(nameof(fileService)); + _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); + _mediaTypeService = mediaTypeService ?? throw new ArgumentNullException(nameof(mediaTypeService)); + _macroService = macroService ?? throw new ArgumentNullException(nameof(macroService)); + _userService = userService ?? throw new ArgumentNullException(nameof(userService)); + _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); + _appCaches = appCaches ?? throw new ArgumentNullException(nameof(appCaches)); + } - private readonly AppCaches _appCaches; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IContentService _contentService; - private readonly IContentTypeService _contentTypeService; - private readonly IDataTypeService _dataTypeService; - private readonly IEntityService _entityService; - private readonly IFileService _fileService; - private readonly ILocalizationService _localizationService; - private readonly ILocalizedTextService _localizedTextService; - private readonly IMacroService _macroService; - private readonly IMediaTypeService _mediaTypeService; - private readonly IPublishedContentQuery _publishedContentQuery; - private readonly IPublishedUrlProvider _publishedUrlProvider; - private readonly SearchableTreeCollection _searchableTreeCollection; - private readonly IShortStringHelper _shortStringHelper; - private readonly ISqlContext _sqlContext; - private readonly UmbracoTreeSearcher _treeSearcher; - private readonly ITreeService _treeService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IUserService _userService; - public EntityController( - ITreeService treeService, - UmbracoTreeSearcher treeSearcher, - SearchableTreeCollection searchableTreeCollection, - IPublishedContentQuery publishedContentQuery, - IShortStringHelper shortStringHelper, - IEntityService entityService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IPublishedUrlProvider publishedUrlProvider, - IContentService contentService, - IUmbracoMapper umbracoMapper, - IDataTypeService dataTypeService, - ISqlContext sqlContext, - ILocalizedTextService localizedTextService, - IFileService fileService, - IContentTypeService contentTypeService, - IMediaTypeService mediaTypeService, - IMacroService macroService, - IUserService userService, - ILocalizationService localizationService, - AppCaches appCaches) + /// + /// Returns an Umbraco alias given a string + /// + /// + /// + /// + public dynamic GetSafeAlias(string value, bool camelCase = true) + { + var returnValue = string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.ToSafeAlias(_shortStringHelper, camelCase); + dynamic returnObj = new ExpandoObject(); + returnObj.alias = returnValue; + returnObj.original = value; + returnObj.camelCase = camelCase; + + return returnObj; + } + + /// + /// Searches for results based on the entity type + /// + /// + /// + /// + /// A starting point for the search, generally a node id, but for members this is a member type alias + /// + /// If set used to look up whether user and group start node permissions will be ignored. + /// + [HttpGet] + public IEnumerable Search(string query, UmbracoEntityTypes type, string? searchFrom = null, Guid? dataTypeKey = null) + { + // NOTE: Theoretically you shouldn't be able to see member data if you don't have access to members right? ... but there is a member picker, so can't really do that + + if (string.IsNullOrEmpty(query)) { - _treeService = treeService ?? throw new ArgumentNullException(nameof(treeService)); - _treeSearcher = treeSearcher ?? throw new ArgumentNullException(nameof(treeSearcher)); - _searchableTreeCollection = searchableTreeCollection ?? - throw new ArgumentNullException(nameof(searchableTreeCollection)); - _publishedContentQuery = - publishedContentQuery ?? throw new ArgumentNullException(nameof(publishedContentQuery)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? - throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _publishedUrlProvider = - publishedUrlProvider ?? throw new ArgumentNullException(nameof(publishedUrlProvider)); - _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); - _sqlContext = sqlContext ?? throw new ArgumentNullException(nameof(sqlContext)); - _localizedTextService = - localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); - _fileService = fileService ?? throw new ArgumentNullException(nameof(fileService)); - _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); - _mediaTypeService = mediaTypeService ?? throw new ArgumentNullException(nameof(mediaTypeService)); - _macroService = macroService ?? throw new ArgumentNullException(nameof(macroService)); - _userService = userService ?? throw new ArgumentNullException(nameof(userService)); - _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); - _appCaches = appCaches ?? throw new ArgumentNullException(nameof(appCaches)); + return Enumerable.Empty(); } + //TODO: This uses the internal UmbracoTreeSearcher, this instead should delgate to the ISearchableTree implementation for the type - /// - /// Returns an Umbraco alias given a string - /// - /// - /// - /// - public dynamic GetSafeAlias(string value, bool camelCase = true) + var ignoreUserStartNodes = dataTypeKey.HasValue && + _dataTypeService.IsDataTypeIgnoringUserStartNodes(dataTypeKey.Value); + return ExamineSearch(query, type, searchFrom, ignoreUserStartNodes); + } + + /// + /// Searches for all content that the user is allowed to see (based on their allowed sections) + /// + /// + /// + /// + /// Even though a normal entity search will allow any user to search on a entity type that they may not have access to + /// edit, we need + /// to filter these results to the sections they are allowed to edit since this search function is explicitly for the + /// global search + /// so if we showed entities that they weren't allowed to edit they would get errors when clicking on the result. + /// The reason a user is allowed to search individual entity types that they are not allowed to edit is because those + /// search + /// methods might be used in things like pickers in the content editor. + /// + [HttpGet] + public async Task> SearchAll(string query) + { + var result = new ConcurrentDictionary(); + + if (string.IsNullOrEmpty(query)) { - var returnValue = string.IsNullOrWhiteSpace(value) - ? string.Empty - : value.ToSafeAlias(_shortStringHelper, camelCase); - dynamic returnObj = new ExpandoObject(); - returnObj.alias = returnValue; - returnObj.original = value; - returnObj.camelCase = camelCase; - - return returnObj; - } - - /// - /// Searches for results based on the entity type - /// - /// - /// - /// - /// A starting point for the search, generally a node id, but for members this is a member type alias - /// - /// If set used to look up whether user and group start node permissions will be ignored. - /// - [HttpGet] - public IEnumerable Search(string query, UmbracoEntityTypes type, string? searchFrom = null, - Guid? dataTypeKey = null) - { - // NOTE: Theoretically you shouldn't be able to see member data if you don't have access to members right? ... but there is a member picker, so can't really do that - - if (string.IsNullOrEmpty(query)) - { - return Enumerable.Empty(); - } - - //TODO: This uses the internal UmbracoTreeSearcher, this instead should delgate to the ISearchableTree implementation for the type - - var ignoreUserStartNodes = dataTypeKey.HasValue && - _dataTypeService.IsDataTypeIgnoringUserStartNodes(dataTypeKey.Value); - return ExamineSearch(query, type, searchFrom, ignoreUserStartNodes); - } - - /// - /// Searches for all content that the user is allowed to see (based on their allowed sections) - /// - /// - /// - /// - /// Even though a normal entity search will allow any user to search on a entity type that they may not have access to - /// edit, we need - /// to filter these results to the sections they are allowed to edit since this search function is explicitly for the - /// global search - /// so if we showed entities that they weren't allowed to edit they would get errors when clicking on the result. - /// The reason a user is allowed to search individual entity types that they are not allowed to edit is because those - /// search - /// methods might be used in things like pickers in the content editor. - /// - [HttpGet] - public async Task> SearchAll(string query) - { - var result = new ConcurrentDictionary(); - - if (string.IsNullOrEmpty(query)) - { - return result; - } - - var allowedSections = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.AllowedSections.ToArray(); - - var searchTasks = new List(); - foreach (KeyValuePair searchableTree in _searchableTreeCollection - .SearchableApplicationTrees.OrderBy(t => t.Value.SortOrder)) - { - if (allowedSections?.Contains(searchableTree.Value.AppAlias) ?? false) - { - Tree? tree = _treeService.GetByAlias(searchableTree.Key); - if (tree == null) - { - continue; //shouldn't occur - } - - var rootNodeDisplayName = Tree.GetRootNodeDisplayName(tree, _localizedTextService); - if (rootNodeDisplayName is not null) - { - searchTasks.Add(ExecuteSearchAsync(query, searchableTree, rootNodeDisplayName,result)); - } - } - } - - await Task.WhenAll(searchTasks); return result; } - private static async Task ExecuteSearchAsync( - string query, - KeyValuePair searchableTree, - string rootNodeDisplayName, - ConcurrentDictionary result) - { - var searchResult = new TreeSearchResult - { - Results = (await searchableTree.Value.SearchableTree.SearchAsync(query, 200, 0)).WhereNotNull(), - TreeAlias = searchableTree.Key, - AppAlias = searchableTree.Value.AppAlias, - JsFormatterService = searchableTree.Value.FormatterService, - JsFormatterMethod = searchableTree.Value.FormatterMethod - }; + var allowedSections = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.AllowedSections.ToArray(); - result.AddOrUpdate(rootNodeDisplayName, _=> searchResult, (_,_) => searchResult); - } - /// - /// Gets the path for a given node ID - /// - /// - /// - /// - public IConvertToActionResult GetPath(int id, UmbracoEntityTypes type) + var searchTasks = new List(); + foreach (KeyValuePair searchableTree in _searchableTreeCollection + .SearchableApplicationTrees.OrderBy(t => t.Value.SortOrder)) { - ActionResult foundContentResult = GetResultForId(id, type); - EntityBasic? foundContent = foundContentResult.Value; - if (foundContent is null) + if (allowedSections?.Contains(searchableTree.Value.AppAlias) ?? false) { - return foundContentResult; + Tree? tree = _treeService.GetByAlias(searchableTree.Key); + if (tree == null) + { + continue; //shouldn't occur + } + + var rootNodeDisplayName = Tree.GetRootNodeDisplayName(tree, _localizedTextService); + if (rootNodeDisplayName is not null) + { + searchTasks.Add(ExecuteSearchAsync(query, searchableTree, rootNodeDisplayName, result)); + } } - - return new ActionResult>(foundContent.Path - .Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Select( - s => int.Parse(s, CultureInfo.InvariantCulture))); } - /// - /// Gets the path for a given node ID - /// - /// - /// - /// - public IConvertToActionResult GetPath(Guid id, UmbracoEntityTypes type) - { - ActionResult foundContentResult = GetResultForKey(id, type); - EntityBasic? foundContent = foundContentResult.Value; - if (foundContent is null) - { - return foundContentResult; - } + await Task.WhenAll(searchTasks); + return result; + } - return new ActionResult>(foundContent.Path - .Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Select( - s => int.Parse(s, CultureInfo.InvariantCulture))); + private static async Task ExecuteSearchAsync( + string query, + KeyValuePair searchableTree, + string rootNodeDisplayName, + ConcurrentDictionary result) + { + var searchResult = new TreeSearchResult + { + Results = (await searchableTree.Value.SearchableTree.SearchAsync(query, 200, 0)).WhereNotNull(), + TreeAlias = searchableTree.Key, + AppAlias = searchableTree.Value.AppAlias, + JsFormatterService = searchableTree.Value.FormatterService, + JsFormatterMethod = searchableTree.Value.FormatterMethod + }; + + result.AddOrUpdate(rootNodeDisplayName, _ => searchResult, (_, _) => searchResult); + } + + /// + /// Gets the path for a given node ID + /// + /// + /// + /// + public IConvertToActionResult GetPath(int id, UmbracoEntityTypes type) + { + ActionResult foundContentResult = GetResultForId(id, type); + EntityBasic? foundContent = foundContentResult.Value; + if (foundContent is null) + { + return foundContentResult; } - /// - /// Gets the path for a given node ID - /// - /// - /// - /// - public IActionResult GetPath(Udi id, UmbracoEntityTypes type) - { - var guidUdi = id as GuidUdi; - if (guidUdi != null) - { - return GetPath(guidUdi.Guid, type).Convert(); - } + return new ActionResult>(foundContent.Path + .Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Select( + s => int.Parse(s, CultureInfo.InvariantCulture))); + } + /// + /// Gets the path for a given node ID + /// + /// + /// + /// + public IConvertToActionResult GetPath(Guid id, UmbracoEntityTypes type) + { + ActionResult foundContentResult = GetResultForKey(id, type); + EntityBasic? foundContent = foundContentResult.Value; + if (foundContent is null) + { + return foundContentResult; + } + + return new ActionResult>(foundContent.Path + .Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Select( + s => int.Parse(s, CultureInfo.InvariantCulture))); + } + + /// + /// Gets the path for a given node ID + /// + /// + /// + /// + public IActionResult GetPath(Udi id, UmbracoEntityTypes type) + { + var guidUdi = id as GuidUdi; + if (guidUdi != null) + { + return GetPath(guidUdi.Guid, type).Convert(); + } + + return NotFound(); + } + + /// + /// Gets the URL of an entity + /// + /// UDI of the entity to fetch URL for + /// The culture to fetch the URL for + /// The URL or path to the item + public IActionResult GetUrl(Udi id, string culture = "*") + { + Attempt intId = _entityService.GetId(id); + if (!intId.Success) + { return NotFound(); } - /// - /// Gets the URL of an entity - /// - /// UDI of the entity to fetch URL for - /// The culture to fetch the URL for - /// The URL or path to the item - public IActionResult GetUrl(Udi id, string culture = "*") + UmbracoEntityTypes entityType; + switch (id.EntityType) { - Attempt intId = _entityService.GetId(id); - if (!intId.Success) - { + case Constants.UdiEntityType.Document: + entityType = UmbracoEntityTypes.Document; + break; + case Constants.UdiEntityType.Media: + entityType = UmbracoEntityTypes.Media; + break; + case Constants.UdiEntityType.Member: + entityType = UmbracoEntityTypes.Member; + break; + default: return NotFound(); - } - - UmbracoEntityTypes entityType; - switch (id.EntityType) - { - case Constants.UdiEntityType.Document: - entityType = UmbracoEntityTypes.Document; - break; - case Constants.UdiEntityType.Media: - entityType = UmbracoEntityTypes.Media; - break; - case Constants.UdiEntityType.Member: - entityType = UmbracoEntityTypes.Member; - break; - default: - return NotFound(); - } - - return GetUrl(intId.Result, entityType, culture); } - /// - /// Get entity URLs by IDs - /// - /// - /// A list of IDs to lookup items by - /// - /// The entity type to look for. - /// The culture to fetch the URL for. - /// Dictionary mapping Udi -> Url - /// - /// We allow for POST because there could be quite a lot of Ids. - /// - [HttpGet] - [HttpPost] - public IDictionary GetUrlsByIds([FromJsonPath] int[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string? culture = null) + return GetUrl(intId.Result, entityType, culture); + } + + /// + /// Get entity URLs by IDs + /// + /// + /// A list of IDs to lookup items by + /// + /// The entity type to look for. + /// The culture to fetch the URL for. + /// Dictionary mapping Udi -> Url + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + public IDictionary GetUrlsByIds([FromJsonPath] int[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string? culture = null) + { + if (ids == null || !ids.Any()) { - if (ids == null || !ids.Any()) - { - return new Dictionary(); - } - - string? MediaOrDocumentUrl(int id) - { - switch (type) - { - case UmbracoEntityTypes.Document: - return _publishedUrlProvider.GetUrl(id, culture: culture ?? ClientCulture()); - - case UmbracoEntityTypes.Media: - { - IPublishedContent? media = _publishedContentQuery.Media(id); - - // NOTE: If culture is passed here we get an empty string rather than a media item URL. - return _publishedUrlProvider.GetMediaUrl(media, culture: null); - } - - default: - return null; - } - } - - return ids - .Distinct() - .Select(id => new { - Id = id, - Url = MediaOrDocumentUrl(id) - }).ToDictionary(x => x.Id, x => x.Url); + return new Dictionary(); } - /// - /// Get entity URLs by IDs - /// - /// - /// A list of IDs to lookup items by - /// - /// The entity type to look for. - /// The culture to fetch the URL for. - /// Dictionary mapping Udi -> Url - /// - /// We allow for POST because there could be quite a lot of Ids. - /// - [HttpGet] - [HttpPost] - public IDictionary GetUrlsByIds([FromJsonPath] Guid[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string? culture = null) - { - if (ids == null || !ids.Any()) - { - return new Dictionary(); - } - - string? MediaOrDocumentUrl(Guid id) - { - return type switch - { - UmbracoEntityTypes.Document => _publishedUrlProvider.GetUrl(id, culture: culture ?? ClientCulture()), - - // NOTE: If culture is passed here we get an empty string rather than a media item URL. - UmbracoEntityTypes.Media => _publishedUrlProvider.GetMediaUrl(id, culture: null), - - _ => null - }; - } - - return ids - .Distinct() - .Select(id => new { - Id = id, - Url = MediaOrDocumentUrl(id) - }).ToDictionary(x => x.Id, x => x.Url); - } - - /// - /// Get entity URLs by IDs - /// - /// - /// A list of IDs to lookup items by - /// - /// The entity type to look for. - /// The culture to fetch the URL for. - /// Dictionary mapping Udi -> Url - /// - /// We allow for POST because there could be quite a lot of Ids. - /// - [HttpGet] - [HttpPost] - public IDictionary GetUrlsByIds([FromJsonPath] Udi[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string? culture = null) - { - if (ids == null || !ids.Any()) - { - return new Dictionary(); - } - - // TODO: PMJ 2021-09-27 - Should GetUrl(Udi) exist as an extension method on UrlProvider/IUrlProvider (in v9) - string? MediaOrDocumentUrl(Udi id) - { - if (id is not GuidUdi guidUdi) - { - return null; - } - - return type switch - { - UmbracoEntityTypes.Document => _publishedUrlProvider.GetUrl(guidUdi.Guid, culture: culture ?? ClientCulture()), - - // NOTE: If culture is passed here we get an empty string rather than a media item URL. - UmbracoEntityTypes.Media => _publishedUrlProvider.GetMediaUrl(guidUdi.Guid, culture: null), - - _ => null - }; - } - - return ids - .Distinct() - .Select(id => new { - Id = id, - Url = MediaOrDocumentUrl(id) - }).ToDictionary(x => x.Id, x => x.Url); - } - - /// - /// Get entity URLs by UDIs - /// - /// - /// A list of UDIs to lookup items by - /// - /// The culture to fetch the URL for - /// Dictionary mapping Udi -> Url - /// - /// We allow for POST because there could be quite a lot of Ids. - /// - [HttpGet] - [HttpPost] - [Obsolete("Use GetUrlsByIds instead.")] - public IDictionary GetUrlsByUdis([FromJsonPath] Udi[] udis, string? culture = null) - { - if (udis == null || !udis.Any()) - { - return new Dictionary(); - } - - var udiEntityType = udis.First().EntityType; - UmbracoEntityTypes entityType; - - switch (udiEntityType) - { - case Constants.UdiEntityType.Document: - entityType = UmbracoEntityTypes.Document; - break; - case Constants.UdiEntityType.Media: - entityType = UmbracoEntityTypes.Media; - break; - default: - entityType = (UmbracoEntityTypes)(-1); - break; - } - - return GetUrlsByIds(udis, entityType, culture); - } - - /// - /// Gets the URL of an entity - /// - /// Int id of the entity to fetch URL for - /// The type of entity such as Document, Media, Member - /// The culture to fetch the URL for - /// The URL or path to the item - /// - /// We are not restricting this with security because there is no sensitive data - /// - public IActionResult GetUrl(int id, UmbracoEntityTypes type, string? culture = null) - { - culture = culture ?? ClientCulture(); - - var returnUrl = string.Empty; - - if (type == UmbracoEntityTypes.Document) - { - var foundUrl = _publishedUrlProvider.GetUrl(id, culture: culture); - if (string.IsNullOrEmpty(foundUrl) == false && foundUrl != "#") - { - returnUrl = foundUrl; - - return Ok(returnUrl); - } - } - - IEnumerable ancestors = GetResultForAncestors(id, type); - - //if content, skip the first node for replicating NiceUrl defaults - if (type == UmbracoEntityTypes.Document) - { - ancestors = ancestors.Skip(1); - } - - returnUrl = "/" + string.Join("/", ancestors.Select(x => x.Name)); - - return Ok(returnUrl); - } - - - /// - /// Gets an entity by a xpath query - /// - /// - /// - /// - /// - public ActionResult? GetByQuery(string query, int nodeContextId, UmbracoEntityTypes type) - { - // TODO: Rename this!!! It's misleading, it should be GetByXPath - - - if (type != UmbracoEntityTypes.Document) - { - throw new ArgumentException("Get by query is only compatible with entities of type Document"); - } - - - var q = ParseXPathQuery(query, nodeContextId); - IPublishedContent? node = _publishedContentQuery.ContentSingleAtXPath(q); - - if (node == null) - { - return null; - } - - return GetById(node.Id, type); - } - - // PP: Work in progress on the query parser - private string ParseXPathQuery(string query, int id) => - UmbracoXPathPathSyntaxParser.ParseXPathQuery( - query, - id, - nodeid => - { - IEntitySlim? ent = _entityService.Get(nodeid); - return ent?.Path.Split(Constants.CharArrays.Comma).Reverse(); - }, - i => _publishedContentQuery.Content(i) != null); - - [HttpGet] - public ActionResult GetUrlAndAnchors(Udi id, string culture = "*") - { - Attempt intId = _entityService.GetId(id); - if (!intId.Success) - { - return NotFound(); - } - - return GetUrlAndAnchors(intId.Result, culture); - } - - [HttpGet] - public UrlAndAnchors GetUrlAndAnchors(int id, string? culture = "*") - { - culture = culture ?? ClientCulture(); - - var url = _publishedUrlProvider.GetUrl(id, culture: culture); - IEnumerable anchorValues = _contentService.GetAnchorValuesFromRTEs(id, culture); - return new UrlAndAnchors(url, anchorValues); - } - - [HttpGet] - [HttpPost] - public IEnumerable GetAnchors(AnchorsModel model) - { - if (model.RteContent is null) - { - return Enumerable.Empty(); - } - - IEnumerable anchorValues = _contentService.GetAnchorValuesFromRTEContent(model.RteContent); - return anchorValues; - } - - public IEnumerable GetChildren(int id, UmbracoEntityTypes type, Guid? dataTypeKey = null) - { - UmbracoObjectTypes? objectType = ConvertToObjectType(type); - if (objectType.HasValue) - { - //TODO: Need to check for Object types that support hierarchy here, some might not. - - var startNodes = GetStartNodes(type); - - var ignoreUserStartNodes = IsDataTypeIgnoringUserStartNodes(dataTypeKey); - - // root is special: we reduce it to start nodes if the user's start node is not the default, then we need to return their start nodes - if (id == Constants.System.Root && startNodes.Length > 0 && - startNodes.Contains(Constants.System.Root) == false && !ignoreUserStartNodes) - { - IEntitySlim[] nodes = _entityService.GetAll(objectType.Value, startNodes).ToArray(); - if (nodes.Length == 0) - { - return Enumerable.Empty(); - } - - var pr = new List(nodes.Select(_umbracoMapper.Map).WhereNotNull()); - return pr; - } - - // else proceed as usual - - return _entityService.GetChildren(id, objectType.Value) - .Select(_umbracoMapper.Map) - .WhereNotNull(); - } - - //now we need to convert the unknown ones - switch (type) - { - case UmbracoEntityTypes.Language: - case UmbracoEntityTypes.User: - case UmbracoEntityTypes.Macro: - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + type); - } - } - - /// - /// Get paged child entities by id - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - public ActionResult> GetPagedChildren( - string id, - UmbracoEntityTypes type, - int pageNumber, - int pageSize, - string orderBy = "SortOrder", - Direction orderDirection = Direction.Ascending, - string filter = "", - Guid? dataTypeKey = null) - { - if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) - { - return GetPagedChildren(intId, type, pageNumber, pageSize, orderBy, orderDirection, filter); - } - - if (Guid.TryParse(id, out _)) - { - //Not supported currently - return NotFound(); - } - - if (UdiParser.TryParse(id, out _)) - { - //Not supported currently - return NotFound(); - } - - //so we don't have an INT, GUID or UDI, it's just a string, so now need to check if it's a special id or a member type - if (id == Constants.Conventions.MemberTypes.AllMembersListId) - { - //the EntityService can search paged members from the root - - intId = -1; - return GetPagedChildren(intId, type, pageNumber, pageSize, orderBy, orderDirection, filter, - dataTypeKey); - } - - //the EntityService cannot search members of a certain type, this is currently not supported and would require - //quite a bit of plumbing to do in the Services/Repository, we'll revert to a paged search - - //TODO: We should really fix this in the EntityService but if we don't we should allow the ISearchableTree for the members controller - // to be used for this search instead of the built in/internal searcher - - IEnumerable searchResult = _treeSearcher.ExamineSearch(filter ?? "", type, pageSize, - pageNumber - 1, out var total, null, id); - - return new PagedResult(total, pageNumber, pageSize) { Items = searchResult }; - } - - /// - /// Get paged child entities by id - /// - /// - /// - /// - /// - /// - /// - /// - /// - public ActionResult> GetPagedChildren( - int id, - UmbracoEntityTypes type, - int pageNumber, - int pageSize, - string orderBy = "SortOrder", - Direction orderDirection = Direction.Ascending, - string filter = "", - Guid? dataTypeKey = null) - { - if (pageNumber <= 0) - { - return NotFound(); - } - - if (pageSize <= 0) - { - return NotFound(); - } - - UmbracoObjectTypes? objectType = ConvertToObjectType(type); - if (objectType.HasValue) - { - IEnumerable entities; - long totalRecords; - - var startNodes = GetStartNodes(type); - - var ignoreUserStartNodes = IsDataTypeIgnoringUserStartNodes(dataTypeKey); - - // root is special: we reduce it to start nodes if the user's start node is not the default, then we need to return their start nodes - if (id == Constants.System.Root && startNodes.Length > 0 && - startNodes.Contains(Constants.System.Root) == false && !ignoreUserStartNodes) - { - return new PagedResult(0, 0, 0); - } - - // else proceed as usual - entities = _entityService.GetPagedChildren(id, objectType.Value, pageNumber - 1, pageSize, - out totalRecords, - filter.IsNullOrWhiteSpace() - ? null - : _sqlContext.Query().Where(x => x.Name!.Contains(filter)), - Ordering.By(orderBy, orderDirection)); - - - if (totalRecords == 0) - { - return new PagedResult(0, 0, 0); - } - - var culture = ClientCulture(); - var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) - { - Items = entities.Select(source => - { - EntityBasic? target = _umbracoMapper.Map(source, context => - { - context.SetCulture(culture); - context.SetCulture(culture); - }); - - if (target is not null) - { - //TODO: Why is this here and not in the mapping? - target.AdditionalData["hasChildren"] = source.HasChildren; - } - return target; - }).WhereNotNull(), - }; - - return pagedResult; - } - - //now we need to convert the unknown ones - switch (type) - { - case UmbracoEntityTypes.PropertyType: - case UmbracoEntityTypes.PropertyGroup: - case UmbracoEntityTypes.Language: - case UmbracoEntityTypes.User: - case UmbracoEntityTypes.Macro: - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + type); - } - } - - private int[] GetStartNodes(UmbracoEntityTypes type) + string? MediaOrDocumentUrl(int id) { switch (type) { case UmbracoEntityTypes.Document: - return _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateContentStartNodeIds( - _entityService, _appCaches) ?? Array.Empty(); + return _publishedUrlProvider.GetUrl(id, culture: culture ?? ClientCulture()); + case UmbracoEntityTypes.Media: - return _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds( - _entityService, _appCaches) ?? Array.Empty(); + { + IPublishedContent? media = _publishedContentQuery.Media(id); + + // NOTE: If culture is passed here we get an empty string rather than a media item URL. + return _publishedUrlProvider.GetMediaUrl(media, culture: null); + } + default: - return Array.Empty(); + return null; } } - public ActionResult> GetPagedDescendants( - int id, - UmbracoEntityTypes type, - int pageNumber, - int pageSize, - string orderBy = "SortOrder", - Direction orderDirection = Direction.Ascending, - string filter = "", - Guid? dataTypeKey = null) + return ids + .Distinct() + .Select(id => new { Id = id, Url = MediaOrDocumentUrl(id) }).ToDictionary(x => x.Id, x => x.Url); + } + + /// + /// Get entity URLs by IDs + /// + /// + /// A list of IDs to lookup items by + /// + /// The entity type to look for. + /// The culture to fetch the URL for. + /// Dictionary mapping Udi -> Url + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + public IDictionary GetUrlsByIds([FromJsonPath] Guid[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string? culture = null) + { + if (ids == null || !ids.Any()) { - if (pageNumber <= 0) + return new Dictionary(); + } + + string? MediaOrDocumentUrl(Guid id) + { + return type switch { - return NotFound(); + UmbracoEntityTypes.Document => _publishedUrlProvider.GetUrl(id, culture: culture ?? ClientCulture()), + + // NOTE: If culture is passed here we get an empty string rather than a media item URL. + UmbracoEntityTypes.Media => _publishedUrlProvider.GetMediaUrl(id, culture: null), + + _ => null + }; + } + + return ids + .Distinct() + .Select(id => new { Id = id, Url = MediaOrDocumentUrl(id) }).ToDictionary(x => x.Id, x => x.Url); + } + + /// + /// Get entity URLs by IDs + /// + /// + /// A list of IDs to lookup items by + /// + /// The entity type to look for. + /// The culture to fetch the URL for. + /// Dictionary mapping Udi -> Url + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + public IDictionary GetUrlsByIds([FromJsonPath] Udi[] ids, [FromQuery] UmbracoEntityTypes type, [FromQuery] string? culture = null) + { + if (ids == null || !ids.Any()) + { + return new Dictionary(); + } + + // TODO: PMJ 2021-09-27 - Should GetUrl(Udi) exist as an extension method on UrlProvider/IUrlProvider (in v9) + string? MediaOrDocumentUrl(Udi id) + { + if (id is not GuidUdi guidUdi) + { + return null; } - if (pageSize <= 0) + return type switch { - return NotFound(); + UmbracoEntityTypes.Document => _publishedUrlProvider.GetUrl(guidUdi.Guid, culture: culture ?? ClientCulture()), + + // NOTE: If culture is passed here we get an empty string rather than a media item URL. + UmbracoEntityTypes.Media => _publishedUrlProvider.GetMediaUrl(guidUdi.Guid, culture: null), + + _ => null + }; + } + + return ids + .Distinct() + .Select(id => new { Id = id, Url = MediaOrDocumentUrl(id) }).ToDictionary(x => x.Id, x => x.Url); + } + + /// + /// Get entity URLs by UDIs + /// + /// + /// A list of UDIs to lookup items by + /// + /// The culture to fetch the URL for + /// Dictionary mapping Udi -> Url + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + [Obsolete("Use GetUrlsByIds instead.")] + public IDictionary GetUrlsByUdis([FromJsonPath] Udi[] udis, string? culture = null) + { + if (udis == null || !udis.Any()) + { + return new Dictionary(); + } + + var udiEntityType = udis.First().EntityType; + UmbracoEntityTypes entityType; + + switch (udiEntityType) + { + case Constants.UdiEntityType.Document: + entityType = UmbracoEntityTypes.Document; + break; + case Constants.UdiEntityType.Media: + entityType = UmbracoEntityTypes.Media; + break; + default: + entityType = (UmbracoEntityTypes)(-1); + break; + } + + return GetUrlsByIds(udis, entityType, culture); + } + + /// + /// Gets the URL of an entity + /// + /// Int id of the entity to fetch URL for + /// The type of entity such as Document, Media, Member + /// The culture to fetch the URL for + /// The URL or path to the item + /// + /// We are not restricting this with security because there is no sensitive data + /// + public IActionResult GetUrl(int id, UmbracoEntityTypes type, string? culture = null) + { + culture ??= ClientCulture(); + + var returnUrl = string.Empty; + + if (type == UmbracoEntityTypes.Document) + { + var foundUrl = _publishedUrlProvider.GetUrl(id, culture: culture); + if (string.IsNullOrEmpty(foundUrl) == false && foundUrl != "#") + { + returnUrl = foundUrl; + + return Ok(returnUrl); } + } - // re-normalize since NULL can be passed in - filter = filter ?? string.Empty; + IEnumerable ancestors = GetResultForAncestors(id, type); - UmbracoObjectTypes? objectType = ConvertToObjectType(type); - if (objectType.HasValue) + //if content, skip the first node for replicating NiceUrl defaults + if (type == UmbracoEntityTypes.Document) + { + ancestors = ancestors.Skip(1); + } + + returnUrl = "/" + string.Join("/", ancestors.Select(x => x.Name)); + + return Ok(returnUrl); + } + + + /// + /// Gets an entity by a xpath query + /// + /// + /// + /// + /// + public ActionResult? GetByQuery(string query, int nodeContextId, UmbracoEntityTypes type) + { + // TODO: Rename this!!! It's misleading, it should be GetByXPath + + + if (type != UmbracoEntityTypes.Document) + { + throw new ArgumentException("Get by query is only compatible with entities of type Document"); + } + + + var q = ParseXPathQuery(query, nodeContextId); + IPublishedContent? node = _publishedContentQuery.ContentSingleAtXPath(q); + + if (node == null) + { + return null; + } + + return GetById(node.Id, type); + } + + // PP: Work in progress on the query parser + private string ParseXPathQuery(string query, int id) => + UmbracoXPathPathSyntaxParser.ParseXPathQuery( + query, + id, + nodeid => { - IEnumerable entities; - long totalRecords; + IEntitySlim? ent = _entityService.Get(nodeid); + return ent?.Path.Split(Constants.CharArrays.Comma).Reverse(); + }, + i => _publishedContentQuery.Content(i) != null); - if (id == Constants.System.Root) + [HttpGet] + public ActionResult GetUrlAndAnchors(Udi id, string culture = "*") + { + Attempt intId = _entityService.GetId(id); + if (!intId.Success) + { + return NotFound(); + } + + return GetUrlAndAnchors(intId.Result, culture); + } + + [HttpGet] + public UrlAndAnchors GetUrlAndAnchors(int id, string? culture = "*") + { + culture ??= ClientCulture(); + + var url = _publishedUrlProvider.GetUrl(id, culture: culture); + IEnumerable anchorValues = _contentService.GetAnchorValuesFromRTEs(id, culture); + return new UrlAndAnchors(url, anchorValues); + } + + [HttpGet] + [HttpPost] + public IEnumerable GetAnchors(AnchorsModel model) + { + if (model.RteContent is null) + { + return Enumerable.Empty(); + } + + IEnumerable anchorValues = _contentService.GetAnchorValuesFromRTEContent(model.RteContent); + return anchorValues; + } + + public IEnumerable GetChildren(int id, UmbracoEntityTypes type, Guid? dataTypeKey = null) + { + UmbracoObjectTypes? objectType = ConvertToObjectType(type); + if (objectType.HasValue) + { + //TODO: Need to check for Object types that support hierarchy here, some might not. + + var startNodes = GetStartNodes(type); + + var ignoreUserStartNodes = IsDataTypeIgnoringUserStartNodes(dataTypeKey); + + // root is special: we reduce it to start nodes if the user's start node is not the default, then we need to return their start nodes + if (id == Constants.System.Root && startNodes.Length > 0 && + startNodes.Contains(Constants.System.Root) == false && !ignoreUserStartNodes) + { + IEntitySlim[] nodes = _entityService.GetAll(objectType.Value, startNodes).ToArray(); + if (nodes.Length == 0) { - // root is special: we reduce it to start nodes - - var aids = GetStartNodes(type); - - var ignoreUserStartNodes = IsDataTypeIgnoringUserStartNodes(dataTypeKey); - entities = aids == null || aids.Contains(Constants.System.Root) || ignoreUserStartNodes - ? _entityService.GetPagedDescendants(objectType.Value, pageNumber - 1, pageSize, - out totalRecords, - _sqlContext.Query().Where(x => x.Name!.Contains(filter)), - Ordering.By(orderBy, orderDirection), false) - : _entityService.GetPagedDescendants(aids, objectType.Value, pageNumber - 1, pageSize, - out totalRecords, - _sqlContext.Query().Where(x => x.Name!.Contains(filter)), - Ordering.By(orderBy, orderDirection)); + return Enumerable.Empty(); } - else + + var pr = new List(nodes.Select(_umbracoMapper.Map).WhereNotNull()); + return pr; + } + + // else proceed as usual + + return _entityService.GetChildren(id, objectType.Value) + .Select(_umbracoMapper.Map) + .WhereNotNull(); + } + + //now we need to convert the unknown ones + switch (type) + { + case UmbracoEntityTypes.Language: + case UmbracoEntityTypes.User: + case UmbracoEntityTypes.Macro: + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + type); + } + } + + /// + /// Get paged child entities by id + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public ActionResult> GetPagedChildren( + string id, + UmbracoEntityTypes type, + int pageNumber, + int pageSize, + string orderBy = "SortOrder", + Direction orderDirection = Direction.Ascending, + string filter = "", + Guid? dataTypeKey = null) + { + if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) + { + return GetPagedChildren(intId, type, pageNumber, pageSize, orderBy, orderDirection, filter); + } + + if (Guid.TryParse(id, out _)) + { + //Not supported currently + return NotFound(); + } + + if (UdiParser.TryParse(id, out _)) + { + //Not supported currently + return NotFound(); + } + + //so we don't have an INT, GUID or UDI, it's just a string, so now need to check if it's a special id or a member type + if (id == Constants.Conventions.MemberTypes.AllMembersListId) + { + //the EntityService can search paged members from the root + + intId = -1; + return GetPagedChildren(intId, type, pageNumber, pageSize, orderBy, orderDirection, filter, dataTypeKey); + } + + //the EntityService cannot search members of a certain type, this is currently not supported and would require + //quite a bit of plumbing to do in the Services/Repository, we'll revert to a paged search + + //TODO: We should really fix this in the EntityService but if we don't we should allow the ISearchableTree for the members controller + // to be used for this search instead of the built in/internal searcher + + IEnumerable searchResult = _treeSearcher.ExamineSearch( + filter ?? string.Empty, + type, + pageSize, + pageNumber - 1, + out var total, + null, + id); + + return new PagedResult(total, pageNumber, pageSize) { Items = searchResult }; + } + + /// + /// Get paged child entities by id + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public ActionResult> GetPagedChildren( + int id, + UmbracoEntityTypes type, + int pageNumber, + int pageSize, + string orderBy = "SortOrder", + Direction orderDirection = Direction.Ascending, + string filter = "", + Guid? dataTypeKey = null) + { + if (pageNumber <= 0) + { + return NotFound(); + } + + if (pageSize <= 0) + { + return NotFound(); + } + + UmbracoObjectTypes? objectType = ConvertToObjectType(type); + if (objectType.HasValue) + { + IEnumerable entities; + + var startNodes = GetStartNodes(type); + + var ignoreUserStartNodes = IsDataTypeIgnoringUserStartNodes(dataTypeKey); + + // root is special: we reduce it to start nodes if the user's start node is not the default, then we need to return their start nodes + if (id == Constants.System.Root && startNodes.Length > 0 && + startNodes.Contains(Constants.System.Root) == false && !ignoreUserStartNodes) + { + return new PagedResult(0, 0, 0); + } + + // else proceed as usual + entities = _entityService.GetPagedChildren( + id, + objectType.Value, + pageNumber - 1, + pageSize, + out long totalRecords, + filter.IsNullOrWhiteSpace() + ? null + : _sqlContext.Query().Where(x => x.Name!.Contains(filter)), + Ordering.By(orderBy, orderDirection)); + + + if (totalRecords == 0) + { + return new PagedResult(0, 0, 0); + } + + var culture = ClientCulture(); + var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) + { + Items = entities.Select(source => { - entities = _entityService.GetPagedDescendants(id, objectType.Value, pageNumber - 1, pageSize, + EntityBasic? target = _umbracoMapper.Map(source, context => + { + context.SetCulture(culture); + context.SetCulture(culture); + }); + + if (target is not null) + { + //TODO: Why is this here and not in the mapping? + target.AdditionalData["hasChildren"] = source.HasChildren; + } + + return target; + }).WhereNotNull() + }; + + return pagedResult; + } + + //now we need to convert the unknown ones + switch (type) + { + case UmbracoEntityTypes.PropertyType: + case UmbracoEntityTypes.PropertyGroup: + case UmbracoEntityTypes.Language: + case UmbracoEntityTypes.User: + case UmbracoEntityTypes.Macro: + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + type); + } + } + + private int[] GetStartNodes(UmbracoEntityTypes type) + { + switch (type) + { + case UmbracoEntityTypes.Document: + return _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateContentStartNodeIds( + _entityService, _appCaches) ?? Array.Empty(); + case UmbracoEntityTypes.Media: + return _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds( + _entityService, _appCaches) ?? Array.Empty(); + default: + return Array.Empty(); + } + } + + public ActionResult> GetPagedDescendants( + int id, + UmbracoEntityTypes type, + int pageNumber, + int pageSize, + string orderBy = "SortOrder", + Direction orderDirection = Direction.Ascending, + string filter = "", + Guid? dataTypeKey = null) + { + if (pageNumber <= 0) + { + return NotFound(); + } + + if (pageSize <= 0) + { + return NotFound(); + } + + // re-normalize since NULL can be passed in + filter ??= string.Empty; + + UmbracoObjectTypes? objectType = ConvertToObjectType(type); + if (objectType.HasValue) + { + IEnumerable entities; + long totalRecords; + + if (id == Constants.System.Root) + { + // root is special: we reduce it to start nodes + + var aids = GetStartNodes(type); + + var ignoreUserStartNodes = IsDataTypeIgnoringUserStartNodes(dataTypeKey); + entities = aids == null || aids.Contains(Constants.System.Root) || ignoreUserStartNodes + ? _entityService.GetPagedDescendants( + objectType.Value, + pageNumber - 1, + pageSize, + out totalRecords, + _sqlContext.Query() + .Where(x => x.Name!.Contains(filter)), + Ordering.By(orderBy, orderDirection), + false) + : _entityService.GetPagedDescendants( + aids, + objectType.Value, + pageNumber - 1, + pageSize, out totalRecords, _sqlContext.Query().Where(x => x.Name!.Contains(filter)), Ordering.By(orderBy, orderDirection)); - } - - if (totalRecords == 0) - { - return new PagedResult(0, 0, 0); - } - - var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) - { - Items = entities.Select(MapEntities()).WhereNotNull(), - }; - - return pagedResult; } - - //now we need to convert the unknown ones - switch (type) + else { - case UmbracoEntityTypes.PropertyType: - case UmbracoEntityTypes.PropertyGroup: - case UmbracoEntityTypes.Language: - case UmbracoEntityTypes.User: - case UmbracoEntityTypes.Macro: - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + type); + entities = _entityService.GetPagedDescendants( + id, + objectType.Value, + pageNumber - 1, + pageSize, + out totalRecords, + _sqlContext.Query() + .Where(x => x.Name!.Contains(filter)), + Ordering.By(orderBy, orderDirection)); } + + if (totalRecords == 0) + { + return new PagedResult(0, 0, 0); + } + + var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) + { + Items = entities.Select(MapEntities()).WhereNotNull() + }; + + return pagedResult; } - private bool IsDataTypeIgnoringUserStartNodes(Guid? dataTypeKey) => dataTypeKey.HasValue && - _dataTypeService - .IsDataTypeIgnoringUserStartNodes( - dataTypeKey.Value); - - public IEnumerable GetAncestors(int id, UmbracoEntityTypes type, - [ModelBinder(typeof(HttpQueryStringModelBinder))] - FormCollection queryStrings) => - GetResultForAncestors(id, type, queryStrings); - - public ActionResult> GetAncestors(Guid id, UmbracoEntityTypes type, - [ModelBinder(typeof(HttpQueryStringModelBinder))] - FormCollection queryStrings) + //now we need to convert the unknown ones + switch (type) { - IEntitySlim? entity = _entityService.Get(id); - if (entity is null) + case UmbracoEntityTypes.PropertyType: + case UmbracoEntityTypes.PropertyGroup: + case UmbracoEntityTypes.Language: + case UmbracoEntityTypes.User: + case UmbracoEntityTypes.Macro: + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + type); + } + } + + private bool IsDataTypeIgnoringUserStartNodes(Guid? dataTypeKey) => dataTypeKey.HasValue && + _dataTypeService + .IsDataTypeIgnoringUserStartNodes( + dataTypeKey.Value); + + public IEnumerable GetAncestors(int id, UmbracoEntityTypes type, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings) => + GetResultForAncestors(id, type, queryStrings); + + public ActionResult> GetAncestors(Guid id, UmbracoEntityTypes type, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings) + { + IEntitySlim? entity = _entityService.Get(id); + if (entity is null) + { + return NotFound(); + } + + return Ok(GetResultForAncestors(entity.Id, type, queryStrings)); + } + + /// + /// Searches for results based on the entity type + /// + /// + /// + /// + /// If set to true, user and group start node permissions will be ignored. + /// + private IEnumerable ExamineSearch(string query, UmbracoEntityTypes entityType, string? searchFrom = null, bool ignoreUserStartNodes = false) + { + var culture = ClientCulture(); + return _treeSearcher.ExamineSearch(query, entityType, 200, 0, out _, culture, searchFrom, ignoreUserStartNodes); + } + + private IEnumerable GetResultForChildren(int id, UmbracoEntityTypes entityType) + { + UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); + if (objectType.HasValue) + { + // TODO: Need to check for Object types that support hierarchic here, some might not. + + return _entityService.GetChildren(id, objectType.Value) + .Select(MapEntities()) + .WhereNotNull(); + } + + //now we need to convert the unknown ones + switch (entityType) + { + case UmbracoEntityTypes.Language: + case UmbracoEntityTypes.User: + case UmbracoEntityTypes.Macro: + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + entityType); + } + } + + private IEnumerable GetResultForAncestors(int id, UmbracoEntityTypes entityType, FormCollection? queryStrings = null) + { + UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); + if (objectType.HasValue) + { + // TODO: Need to check for Object types that support hierarchic here, some might not. + + var ids = _entityService.Get(id)?.Path.Split(Constants.CharArrays.Comma) + .Select(s => int.Parse(s, CultureInfo.InvariantCulture)).Distinct().ToArray(); + + var ignoreUserStartNodes = + IsDataTypeIgnoringUserStartNodes(queryStrings?.GetValue("dataTypeId")); + if (ignoreUserStartNodes == false) + { + int[]? aids = null; + switch (entityType) + { + case UmbracoEntityTypes.Document: + aids = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser? + .CalculateContentStartNodeIds(_entityService, _appCaches); + break; + case UmbracoEntityTypes.Media: + aids = + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds( + _entityService, _appCaches); + break; + } + + if (aids != null) + { + var lids = new List(); + var ok = false; + if (ids is not null) + { + foreach (var i in ids) + { + if (ok) + { + lids.Add(i); + continue; + } + + if (aids.Contains(i)) + { + lids.Add(i); + ok = true; + } + } + } + + ids = lids.ToArray(); + } + } + + var culture = queryStrings?.GetValue("culture"); + + return ids is null || ids.Length == 0 + ? Enumerable.Empty() + : _entityService.GetAll(objectType.Value, ids) + .OrderBy(x => x.Level) + .Select(MapEntities(culture)) + .WhereNotNull(); + } + + //now we need to convert the unknown ones + switch (entityType) + { + case UmbracoEntityTypes.PropertyType: + case UmbracoEntityTypes.PropertyGroup: + case UmbracoEntityTypes.Language: + case UmbracoEntityTypes.User: + case UmbracoEntityTypes.Macro: + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + entityType); + } + } + + private IEnumerable GetResultForKeys(Guid[] keys, UmbracoEntityTypes entityType) + { + if (keys.Length == 0) + { + return Enumerable.Empty(); + } + + UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); + if (objectType.HasValue) + { + IEnumerable entities = _entityService.GetAll(objectType.Value, keys) + .Select(MapEntities()) + .WhereNotNull(); + + // entities are in "some" order, put them back in order + var xref = entities.ToDictionary(x => x.Key); + IEnumerable result = keys.Select(x => xref.ContainsKey(x) ? xref[x] : null) + .WhereNotNull(); + + return result; + } + + //now we need to convert the unknown ones + switch (entityType) + { + case UmbracoEntityTypes.PropertyType: + case UmbracoEntityTypes.PropertyGroup: + case UmbracoEntityTypes.Language: + case UmbracoEntityTypes.User: + case UmbracoEntityTypes.Macro: + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + entityType); + } + } + + private IEnumerable GetResultForIds(int[] ids, UmbracoEntityTypes entityType) + { + if (ids.Length == 0) + { + return Enumerable.Empty(); + } + + UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); + if (objectType.HasValue) + { + IEnumerable entities = _entityService.GetAll(objectType.Value, ids) + .Select(MapEntities()) + .WhereNotNull(); + + // entities are in "some" order, put them back in order + var xref = entities.Where(x => x.Id != null).ToDictionary(x => x.Id!); + IEnumerable result = ids.Select(x => xref.ContainsKey(x) ? xref[x] : null) + .WhereNotNull(); + + return result; + } + + //now we need to convert the unknown ones + switch (entityType) + { + case UmbracoEntityTypes.PropertyType: + case UmbracoEntityTypes.PropertyGroup: + case UmbracoEntityTypes.Language: + case UmbracoEntityTypes.User: + case UmbracoEntityTypes.Macro: + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + entityType); + } + } + + private ActionResult GetResultForKey(Guid key, UmbracoEntityTypes entityType) + { + UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); + if (objectType.HasValue) + { + IEntitySlim? found = _entityService.Get(key, objectType.Value); + if (found == null) { return NotFound(); } - return Ok(GetResultForAncestors(entity.Id, type, queryStrings)); + return _umbracoMapper.Map(found); } - /// - /// Searches for results based on the entity type - /// - /// - /// - /// - /// If set to true, user and group start node permissions will be ignored. - /// - private IEnumerable ExamineSearch(string query, UmbracoEntityTypes entityType, - string? searchFrom = null, bool ignoreUserStartNodes = false) + //now we need to convert the unknown ones + switch (entityType) { - var culture = ClientCulture(); - return _treeSearcher.ExamineSearch(query, entityType, 200, 0, out _, culture, searchFrom, - ignoreUserStartNodes); - } + case UmbracoEntityTypes.PropertyType: - private IEnumerable GetResultForChildren(int id, UmbracoEntityTypes entityType) - { - UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); - if (objectType.HasValue) - { - // TODO: Need to check for Object types that support hierarchic here, some might not. + case UmbracoEntityTypes.PropertyGroup: - return _entityService.GetChildren(id, objectType.Value) - .Select(MapEntities()) - .WhereNotNull(); - } + case UmbracoEntityTypes.Language: - //now we need to convert the unknown ones - switch (entityType) - { - case UmbracoEntityTypes.Language: - case UmbracoEntityTypes.User: - case UmbracoEntityTypes.Macro: - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + entityType); - } - } + case UmbracoEntityTypes.User: - private IEnumerable GetResultForAncestors(int id, UmbracoEntityTypes entityType, - FormCollection? queryStrings = null) - { - UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); - if (objectType.HasValue) - { - // TODO: Need to check for Object types that support hierarchic here, some might not. + case UmbracoEntityTypes.Macro: - var ids = _entityService.Get(id)?.Path.Split(Constants.CharArrays.Comma) - .Select(s => int.Parse(s, CultureInfo.InvariantCulture)).Distinct().ToArray(); - - var ignoreUserStartNodes = - IsDataTypeIgnoringUserStartNodes(queryStrings?.GetValue("dataTypeId")); - if (ignoreUserStartNodes == false) - { - int[]? aids = null; - switch (entityType) - { - case UmbracoEntityTypes.Document: - aids = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser? - .CalculateContentStartNodeIds(_entityService, _appCaches); - break; - case UmbracoEntityTypes.Media: - aids = - _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds( - _entityService, _appCaches); - break; - } - - if (aids != null) - { - var lids = new List(); - var ok = false; - if (ids is not null) - { - foreach (var i in ids) - { - if (ok) - { - lids.Add(i); - continue; - } - - if (aids.Contains(i)) - { - lids.Add(i); - ok = true; - } - } - } - - ids = lids.ToArray(); - } - } - - var culture = queryStrings?.GetValue("culture"); - - return ids is null || ids.Length == 0 - ? Enumerable.Empty() - : _entityService.GetAll(objectType.Value, ids) - .OrderBy(x => x.Level) - .Select(MapEntities(culture)) - .WhereNotNull(); - } - - //now we need to convert the unknown ones - switch (entityType) - { - case UmbracoEntityTypes.PropertyType: - case UmbracoEntityTypes.PropertyGroup: - case UmbracoEntityTypes.Language: - case UmbracoEntityTypes.User: - case UmbracoEntityTypes.Macro: - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + entityType); - } - } - - private IEnumerable GetResultForKeys(Guid[] keys, UmbracoEntityTypes entityType) - { - if (keys.Length == 0) - { - return Enumerable.Empty(); - } - - UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); - if (objectType.HasValue) - { - IEnumerable entities = _entityService.GetAll(objectType.Value, keys) - .Select(MapEntities()) - .WhereNotNull(); - - // entities are in "some" order, put them back in order - var xref = entities.ToDictionary(x => x.Key); - IEnumerable result = keys.Select(x => xref.ContainsKey(x) ? xref[x] : null) - .WhereNotNull(); - - return result; - } - - //now we need to convert the unknown ones - switch (entityType) - { - case UmbracoEntityTypes.PropertyType: - case UmbracoEntityTypes.PropertyGroup: - case UmbracoEntityTypes.Language: - case UmbracoEntityTypes.User: - case UmbracoEntityTypes.Macro: - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + entityType); - } - } - - private IEnumerable GetResultForIds(int[] ids, UmbracoEntityTypes entityType) - { - if (ids.Length == 0) - { - return Enumerable.Empty(); - } - - UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); - if (objectType.HasValue) - { - IEnumerable entities = _entityService.GetAll(objectType.Value, ids) - .Select(MapEntities()) - .WhereNotNull(); - - // entities are in "some" order, put them back in order - var xref = entities.Where(x => x.Id != null).ToDictionary(x => x.Id!); - IEnumerable result = ids.Select(x => xref.ContainsKey(x) ? xref[x] : null) - .WhereNotNull(); - - return result; - } - - //now we need to convert the unknown ones - switch (entityType) - { - case UmbracoEntityTypes.PropertyType: - case UmbracoEntityTypes.PropertyGroup: - case UmbracoEntityTypes.Language: - case UmbracoEntityTypes.User: - case UmbracoEntityTypes.Macro: - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + entityType); - } - } - - private ActionResult GetResultForKey(Guid key, UmbracoEntityTypes entityType) - { - UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); - if (objectType.HasValue) - { - IEntitySlim? found = _entityService.Get(key, objectType.Value); - if (found == null) + case UmbracoEntityTypes.Template: + ITemplate? template = _fileService.GetTemplate(key); + if (template is null) { return NotFound(); } - return _umbracoMapper.Map(found); - } + return _umbracoMapper.Map(template); - //now we need to convert the unknown ones - switch (entityType) + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + entityType); + } + } + + private ActionResult GetResultForId(int id, UmbracoEntityTypes entityType) + { + UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); + if (objectType.HasValue) + { + IEntitySlim? found = _entityService.Get(id, objectType.Value); + if (found == null) { - case UmbracoEntityTypes.PropertyType: - - case UmbracoEntityTypes.PropertyGroup: - - case UmbracoEntityTypes.Language: - - case UmbracoEntityTypes.User: - - case UmbracoEntityTypes.Macro: - - case UmbracoEntityTypes.Template: - ITemplate? template = _fileService.GetTemplate(key); - if (template is null) - { - return NotFound(); - } - - return _umbracoMapper.Map(template); - - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + entityType); + return NotFound(); } + + return MapEntity(found); } - private ActionResult GetResultForId(int id, UmbracoEntityTypes entityType) + //now we need to convert the unknown ones + switch (entityType) { - UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); - if (objectType.HasValue) - { - IEntitySlim? found = _entityService.Get(id, objectType.Value); - if (found == null) + case UmbracoEntityTypes.PropertyType: + + case UmbracoEntityTypes.PropertyGroup: + + case UmbracoEntityTypes.Language: + + case UmbracoEntityTypes.User: + + case UmbracoEntityTypes.Macro: + + case UmbracoEntityTypes.Template: + ITemplate? template = _fileService.GetTemplate(id); + if (template is null) { return NotFound(); } - return MapEntity(found); - } + return _umbracoMapper.Map(template); - //now we need to convert the unknown ones - switch (entityType) - { - case UmbracoEntityTypes.PropertyType: + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + entityType); + } + } - case UmbracoEntityTypes.PropertyGroup: + private static UmbracoObjectTypes? ConvertToObjectType(UmbracoEntityTypes entityType) + { + switch (entityType) + { + case UmbracoEntityTypes.Document: + return UmbracoObjectTypes.Document; + case UmbracoEntityTypes.Media: + return UmbracoObjectTypes.Media; + case UmbracoEntityTypes.MemberType: + return UmbracoObjectTypes.MemberType; + case UmbracoEntityTypes.MemberGroup: + return UmbracoObjectTypes.MemberGroup; + case UmbracoEntityTypes.MediaType: + return UmbracoObjectTypes.MediaType; + case UmbracoEntityTypes.DocumentType: + return UmbracoObjectTypes.DocumentType; + case UmbracoEntityTypes.Member: + return UmbracoObjectTypes.Member; + case UmbracoEntityTypes.DataType: + return UmbracoObjectTypes.DataType; + default: + //There is no UmbracoEntity conversion (things like Macros, Users, etc...) + return null; + } + } - case UmbracoEntityTypes.Language: + /// + /// + /// The type of entity. + /// + /// Optional filter - Format like: "BoolVariable==true&IntVariable>=6". Invalid filters are + /// ignored. + /// + /// + public IEnumerable? GetAll(UmbracoEntityTypes type, string postFilter) => + GetResultForAll(type, postFilter); - case UmbracoEntityTypes.User: - - case UmbracoEntityTypes.Macro: - - case UmbracoEntityTypes.Template: - ITemplate? template = _fileService.GetTemplate(id); - if (template is null) - { - return NotFound(); - } - - return _umbracoMapper.Map(template); - - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + entityType); - } + /// + /// Gets the result for the entity list based on the type + /// + /// + /// A string where filter that will filter the results dynamically with linq - optional + /// + private IEnumerable? GetResultForAll(UmbracoEntityTypes entityType, string? postFilter = null) + { + UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); + if (objectType.HasValue) + { + // TODO: Should we order this by something ? + IEnumerable entities = + _entityService.GetAll(objectType.Value).Select(MapEntities()).WhereNotNull(); + return ExecutePostFilter(entities, postFilter); } - private static UmbracoObjectTypes? ConvertToObjectType(UmbracoEntityTypes entityType) + //now we need to convert the unknown ones + switch (entityType) { - switch (entityType) - { - case UmbracoEntityTypes.Document: - return UmbracoObjectTypes.Document; - case UmbracoEntityTypes.Media: - return UmbracoObjectTypes.Media; - case UmbracoEntityTypes.MemberType: - return UmbracoObjectTypes.MemberType; - case UmbracoEntityTypes.MemberGroup: - return UmbracoObjectTypes.MemberGroup; - case UmbracoEntityTypes.MediaType: - return UmbracoObjectTypes.MediaType; - case UmbracoEntityTypes.DocumentType: - return UmbracoObjectTypes.DocumentType; - case UmbracoEntityTypes.Member: - return UmbracoObjectTypes.Member; - case UmbracoEntityTypes.DataType: - return UmbracoObjectTypes.DataType; - default: - //There is no UmbracoEntity conversion (things like Macros, Users, etc...) - return null; - } - } + case UmbracoEntityTypes.Template: + IEnumerable? templates = _fileService.GetTemplates(); + IEnumerable? filteredTemplates = ExecutePostFilter(templates, postFilter); + return filteredTemplates?.Select(MapEntities()).WhereNotNull(); - /// - /// - /// The type of entity. - /// - /// Optional filter - Format like: "BoolVariable==true&IntVariable>=6". Invalid filters are - /// ignored. - /// - /// - public IEnumerable? GetAll(UmbracoEntityTypes type, string postFilter) => - GetResultForAll(type, postFilter); + case UmbracoEntityTypes.Macro: + //Get all macros from the macro service + IOrderedEnumerable macros = _macroService.GetAll().WhereNotNull().OrderBy(x => x.Name); + IEnumerable? filteredMacros = ExecutePostFilter(macros, postFilter); + return filteredMacros?.Select(MapEntities()).WhereNotNull(); - /// - /// Gets the result for the entity list based on the type - /// - /// - /// A string where filter that will filter the results dynamically with linq - optional - /// - private IEnumerable? GetResultForAll(UmbracoEntityTypes entityType, string? postFilter = null) - { - UmbracoObjectTypes? objectType = ConvertToObjectType(entityType); - if (objectType.HasValue) - { - // TODO: Should we order this by something ? - IEnumerable entities = - _entityService.GetAll(objectType.Value).Select(MapEntities()).WhereNotNull(); - return ExecutePostFilter(entities, postFilter); - } + case UmbracoEntityTypes.PropertyType: - //now we need to convert the unknown ones - switch (entityType) - { - case UmbracoEntityTypes.Template: - IEnumerable? templates = _fileService.GetTemplates(); - IEnumerable? filteredTemplates = ExecutePostFilter(templates, postFilter); - return filteredTemplates?.Select(MapEntities()).WhereNotNull(); + //get all document types, then combine all property types into one list + IEnumerable propertyTypes = _contentTypeService.GetAll() + .Cast() + .Concat(_mediaTypeService.GetAll()) + .ToArray() + .SelectMany(x => x.PropertyTypes) + .DistinctBy(composition => composition.Alias); + IEnumerable? filteredPropertyTypes = ExecutePostFilter(propertyTypes, postFilter); + return _umbracoMapper + .MapEnumerable(filteredPropertyTypes ?? + Enumerable.Empty()).WhereNotNull(); - case UmbracoEntityTypes.Macro: - //Get all macros from the macro service - IOrderedEnumerable macros = _macroService.GetAll().WhereNotNull().OrderBy(x => x.Name); - IEnumerable? filteredMacros = ExecutePostFilter(macros, postFilter); - return filteredMacros?.Select(MapEntities()).WhereNotNull(); + case UmbracoEntityTypes.PropertyGroup: - case UmbracoEntityTypes.PropertyType: + //get all document types, then combine all property types into one list + IEnumerable propertyGroups = _contentTypeService.GetAll() + .Cast() + .Concat(_mediaTypeService.GetAll()) + .ToArray() + .SelectMany(x => x.PropertyGroups) + .DistinctBy(composition => composition.Name); + IEnumerable? filteredpropertyGroups = ExecutePostFilter(propertyGroups, postFilter); + return _umbracoMapper + .MapEnumerable(filteredpropertyGroups ?? + Enumerable.Empty()).WhereNotNull(); - //get all document types, then combine all property types into one list - IEnumerable propertyTypes = _contentTypeService.GetAll() - .Cast() - .Concat(_mediaTypeService.GetAll()) - .ToArray() - .SelectMany(x => x.PropertyTypes) - .DistinctBy(composition => composition.Alias); - IEnumerable? filteredPropertyTypes = ExecutePostFilter(propertyTypes, postFilter); - return _umbracoMapper.MapEnumerable(filteredPropertyTypes ?? Enumerable.Empty()).WhereNotNull(); + case UmbracoEntityTypes.User: - case UmbracoEntityTypes.PropertyGroup: + IEnumerable users = _userService.GetAll(0, int.MaxValue, out _); + IEnumerable? filteredUsers = ExecutePostFilter(users, postFilter); + return _umbracoMapper.MapEnumerable(filteredUsers ?? Enumerable.Empty()) + .WhereNotNull(); - //get all document types, then combine all property types into one list - IEnumerable propertyGroups = _contentTypeService.GetAll() - .Cast() - .Concat(_mediaTypeService.GetAll()) - .ToArray() - .SelectMany(x => x.PropertyGroups) - .DistinctBy(composition => composition.Name); - IEnumerable? filteredpropertyGroups = ExecutePostFilter(propertyGroups, postFilter); - return _umbracoMapper.MapEnumerable(filteredpropertyGroups ?? Enumerable.Empty()).WhereNotNull(); + case UmbracoEntityTypes.Stylesheet: - case UmbracoEntityTypes.User: - - IEnumerable users = _userService.GetAll(0, int.MaxValue, out _); - IEnumerable? filteredUsers = ExecutePostFilter(users, postFilter); - return _umbracoMapper.MapEnumerable(filteredUsers ?? Enumerable.Empty()).WhereNotNull(); - - case UmbracoEntityTypes.Stylesheet: - - if (!postFilter.IsNullOrWhiteSpace()) - { - throw new NotSupportedException("Filtering on stylesheets is not currently supported"); - } - - return _fileService.GetStylesheets().Select(MapEntities()).WhereNotNull(); - - case UmbracoEntityTypes.Script: - - if (!postFilter.IsNullOrWhiteSpace()) - { - throw new NotSupportedException("Filtering on scripts is not currently supported"); - } - - return _fileService.GetScripts().Select(MapEntities()).WhereNotNull(); - - case UmbracoEntityTypes.PartialView: - - if (!postFilter.IsNullOrWhiteSpace()) - { - throw new NotSupportedException("Filtering on partial views is not currently supported"); - } - - return _fileService.GetPartialViews().Select(MapEntities()).WhereNotNull(); - - case UmbracoEntityTypes.Language: - - if (!postFilter.IsNullOrWhiteSpace()) - { - throw new NotSupportedException("Filtering on languages is not currently supported"); - } - - return _localizationService.GetAllLanguages().Select(MapEntities()).WhereNotNull(); - case UmbracoEntityTypes.DictionaryItem: - - if (!postFilter.IsNullOrWhiteSpace()) - { - throw new NotSupportedException("Filtering on dictionary items is not currently supported"); - } - - return GetAllDictionaryItems(); - - default: - throw new NotSupportedException("The " + typeof(EntityController) + - " does not currently support data for the type " + entityType); - } - } - - private IEnumerable? ExecutePostFilter(IEnumerable? entities, string? postFilter) - { - if (postFilter.IsNullOrWhiteSpace()) - { - return entities; - } - - var postFilterConditions = postFilter!.Split(Constants.CharArrays.Ampersand); - - foreach (var postFilterCondition in postFilterConditions) - { - QueryCondition? queryCondition = BuildQueryCondition(postFilterCondition); - - if (queryCondition != null) + if (!postFilter.IsNullOrWhiteSpace()) { - Expression> whereClauseExpression = queryCondition.BuildCondition("x"); - - entities = entities?.Where(whereClauseExpression.Compile()); + throw new NotSupportedException("Filtering on stylesheets is not currently supported"); } - } + return _fileService.GetStylesheets().Select(MapEntities()).WhereNotNull(); + + case UmbracoEntityTypes.Script: + + if (!postFilter.IsNullOrWhiteSpace()) + { + throw new NotSupportedException("Filtering on scripts is not currently supported"); + } + + return _fileService.GetScripts().Select(MapEntities()).WhereNotNull(); + + case UmbracoEntityTypes.PartialView: + + if (!postFilter.IsNullOrWhiteSpace()) + { + throw new NotSupportedException("Filtering on partial views is not currently supported"); + } + + return _fileService.GetPartialViews().Select(MapEntities()).WhereNotNull(); + + case UmbracoEntityTypes.Language: + + if (!postFilter.IsNullOrWhiteSpace()) + { + throw new NotSupportedException("Filtering on languages is not currently supported"); + } + + return _localizationService.GetAllLanguages().Select(MapEntities()).WhereNotNull(); + case UmbracoEntityTypes.DictionaryItem: + + if (!postFilter.IsNullOrWhiteSpace()) + { + throw new NotSupportedException("Filtering on dictionary items is not currently supported"); + } + + return GetAllDictionaryItems(); + + default: + throw new NotSupportedException("The " + typeof(EntityController) + + " does not currently support data for the type " + entityType); + } + } + + private IEnumerable? ExecutePostFilter(IEnumerable? entities, string? postFilter) + { + if (postFilter.IsNullOrWhiteSpace()) + { return entities; } - private static QueryCondition? BuildQueryCondition(string postFilter) + var postFilterConditions = postFilter!.Split(Constants.CharArrays.Ampersand); + + foreach (var postFilterCondition in postFilterConditions) { - var postFilterParts = postFilter.Split(_postFilterSplitStrings, 2, StringSplitOptions.RemoveEmptyEntries); + QueryCondition? queryCondition = BuildQueryCondition(postFilterCondition); - if (postFilterParts.Length != 2) + if (queryCondition != null) { - return null; + Expression> whereClauseExpression = queryCondition.BuildCondition("x"); + + entities = entities?.Where(whereClauseExpression.Compile()); } - - var propertyName = postFilterParts[0]; - var constraintValue = postFilterParts[1]; - var stringOperator = postFilter.Substring(propertyName.Length, - postFilter.Length - propertyName.Length - constraintValue.Length); - Operator binaryOperator; - - try - { - binaryOperator = OperatorFactory.FromString(stringOperator); - } - catch (ArgumentException) - { - // unsupported operators are ignored - return null; - } - - Type type = typeof(T); - PropertyInfo? property = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); - - if (property == null) - { - return null; - } - - var queryCondition = new QueryCondition - { - Term = new OperatorTerm { Operator = binaryOperator }, - ConstraintValue = constraintValue, - Property = new PropertyModel - { - Alias = propertyName, Name = propertyName, Type = property.PropertyType.Name - } - }; - - return queryCondition; } - private Func MapEntities(string? culture = null) - { - culture = culture ?? ClientCulture(); - return x => MapEntity(x, culture); - } - - private EntityBasic? MapEntity(object entity, string? culture = null) - { - culture = culture ?? ClientCulture(); - return _umbracoMapper.Map(entity, context => { context.SetCulture(culture); }); - } - - private string? ClientCulture() => Request.ClientCulture(); - - - #region GetById - - /// - /// Gets an entity by it's id - /// - /// - /// - /// - public ActionResult GetById(int id, UmbracoEntityTypes type) => GetResultForId(id, type); - - /// - /// Gets an entity by it's key - /// - /// - /// - /// - public ActionResult GetById(Guid id, UmbracoEntityTypes type) => GetResultForKey(id, type); - - /// - /// Gets an entity by it's UDI - /// - /// - /// - /// - public ActionResult GetById(Udi id, UmbracoEntityTypes type) - { - var guidUdi = id as GuidUdi; - if (guidUdi != null) - { - return GetResultForKey(guidUdi.Guid, type); - } - - return NotFound(); - } - - #endregion - - #region GetByIds - - /// - /// Get entities by integer ids - /// - /// - /// - /// - /// - /// We allow for POST because there could be quite a lot of Ids - /// - [HttpGet] - [HttpPost] - public ActionResult> GetByIds([FromJsonPath] int[] ids, - [FromQuery] UmbracoEntityTypes type) - { - if (ids == null) - { - return NotFound(); - } - - return new ActionResult>(GetResultForIds(ids, type)); - } - - /// - /// Get entities by GUID ids - /// - /// - /// - /// - /// - /// We allow for POST because there could be quite a lot of Ids - /// - [HttpGet] - [HttpPost] - public ActionResult> GetByIds([FromJsonPath] Guid[] ids, - [FromQuery] UmbracoEntityTypes type) - { - if (ids == null) - { - return NotFound(); - } - - return new ActionResult>(GetResultForKeys(ids, type)); - } - - /// - /// Get entities by UDIs - /// - /// - /// A list of UDIs to lookup items by, all UDIs must be of the same UDI type! - /// - /// - /// - /// - /// We allow for POST because there could be quite a lot of Ids. - /// - [HttpGet] - [HttpPost] - public ActionResult> GetByIds([FromJsonPath] Udi[] ids, - [FromQuery] UmbracoEntityTypes type) - { - if (ids == null) - { - return NotFound(); - } - - if (ids.Length == 0) - { - return Enumerable.Empty().ToList(); - } - - //all udi types will need to be the same in this list so we'll determine by the first - //currently we only support GuidUdi for this method - - var guidUdi = ids[0] as GuidUdi; - if (guidUdi != null) - { - return new ActionResult>( - GetResultForKeys(ids.Select(x => ((GuidUdi)x).Guid).ToArray(), type)); - } - - return NotFound(); - } - - #endregion - - #region Methods to get all dictionary items - - private IEnumerable GetAllDictionaryItems() - { - var list = new List(); - - var rootDictionaryItems = _localizationService.GetRootDictionaryItems(); - if (rootDictionaryItems is not null) - { - foreach (IDictionaryItem dictionaryItem in rootDictionaryItems - .OrderBy(DictionaryItemSort())) - { - EntityBasic? item = _umbracoMapper.Map(dictionaryItem); - if (item is not null) - { - list.Add(item); - } - - GetChildItemsForList(dictionaryItem, list); - } - } - - return list; - } - - private static Func DictionaryItemSort() => item => item.ItemKey; - - private void GetChildItemsForList(IDictionaryItem dictionaryItem, ICollection list) - { - var itemChildren = _localizationService.GetDictionaryItemChildren(dictionaryItem.Key); - - if (itemChildren is not null) - { - foreach (IDictionaryItem childItem in itemChildren - .OrderBy(DictionaryItemSort())) - { - EntityBasic? item = _umbracoMapper.Map(childItem); - if (item is not null) - { - list.Add(item); - } - - GetChildItemsForList(childItem, list); - } - } - - } - - #endregion + return entities; } + + private static QueryCondition? BuildQueryCondition(string postFilter) + { + var postFilterParts = postFilter.Split(_postFilterSplitStrings, 2, StringSplitOptions.RemoveEmptyEntries); + + if (postFilterParts.Length != 2) + { + return null; + } + + var propertyName = postFilterParts[0]; + var constraintValue = postFilterParts[1]; + var stringOperator = postFilter.Substring(propertyName.Length, postFilter.Length - propertyName.Length - constraintValue.Length); + Operator binaryOperator; + + try + { + binaryOperator = OperatorFactory.FromString(stringOperator); + } + catch (ArgumentException) + { + // unsupported operators are ignored + return null; + } + + Type type = typeof(T); + PropertyInfo? property = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + + if (property == null) + { + return null; + } + + var queryCondition = new QueryCondition + { + Term = new OperatorTerm { Operator = binaryOperator }, + ConstraintValue = constraintValue, + Property = new PropertyModel + { + Alias = propertyName, + Name = propertyName, + Type = property.PropertyType.Name + } + }; + + return queryCondition; + } + + private Func MapEntities(string? culture = null) + { + culture ??= ClientCulture(); + return x => MapEntity(x, culture); + } + + private EntityBasic? MapEntity(object entity, string? culture = null) + { + culture ??= ClientCulture(); + return _umbracoMapper.Map(entity, context => { context.SetCulture(culture); }); + } + + private string? ClientCulture() => Request.ClientCulture(); + + + #region GetById + + /// + /// Gets an entity by it's id + /// + /// + /// + /// + public ActionResult GetById(int id, UmbracoEntityTypes type) => GetResultForId(id, type); + + /// + /// Gets an entity by it's key + /// + /// + /// + /// + public ActionResult GetById(Guid id, UmbracoEntityTypes type) => GetResultForKey(id, type); + + /// + /// Gets an entity by it's UDI + /// + /// + /// + /// + public ActionResult GetById(Udi id, UmbracoEntityTypes type) + { + var guidUdi = id as GuidUdi; + if (guidUdi != null) + { + return GetResultForKey(guidUdi.Guid, type); + } + + return NotFound(); + } + + #endregion + + #region GetByIds + + /// + /// Get entities by integer ids + /// + /// + /// + /// + /// + /// We allow for POST because there could be quite a lot of Ids + /// + [HttpGet] + [HttpPost] + public ActionResult> GetByIds([FromJsonPath] int[] ids, [FromQuery] UmbracoEntityTypes type) + { + if (ids == null) + { + return NotFound(); + } + + return new ActionResult>(GetResultForIds(ids, type)); + } + + /// + /// Get entities by GUID ids + /// + /// + /// + /// + /// + /// We allow for POST because there could be quite a lot of Ids + /// + [HttpGet] + [HttpPost] + public ActionResult> GetByIds([FromJsonPath] Guid[] ids, [FromQuery] UmbracoEntityTypes type) + { + if (ids == null) + { + return NotFound(); + } + + return new ActionResult>(GetResultForKeys(ids, type)); + } + + /// + /// Get entities by UDIs + /// + /// + /// A list of UDIs to lookup items by, all UDIs must be of the same UDI type! + /// + /// + /// + /// + /// We allow for POST because there could be quite a lot of Ids. + /// + [HttpGet] + [HttpPost] + public ActionResult> GetByIds([FromJsonPath] Udi[] ids, [FromQuery] UmbracoEntityTypes type) + { + if (ids == null) + { + return NotFound(); + } + + if (ids.Length == 0) + { + return Enumerable.Empty().ToList(); + } + + //all udi types will need to be the same in this list so we'll determine by the first + //currently we only support GuidUdi for this method + + var guidUdi = ids[0] as GuidUdi; + if (guidUdi != null) + { + return new ActionResult>( + GetResultForKeys(ids.Select(x => ((GuidUdi)x).Guid).ToArray(), type)); + } + + return NotFound(); + } + + #endregion + + #region Methods to get all dictionary items + + private IEnumerable GetAllDictionaryItems() + { + var list = new List(); + + IEnumerable? rootDictionaryItems = _localizationService.GetRootDictionaryItems(); + if (rootDictionaryItems is not null) + { + foreach (IDictionaryItem dictionaryItem in rootDictionaryItems + .OrderBy(DictionaryItemSort())) + { + EntityBasic? item = _umbracoMapper.Map(dictionaryItem); + if (item is not null) + { + list.Add(item); + } + + GetChildItemsForList(dictionaryItem, list); + } + } + + return list; + } + + private static Func DictionaryItemSort() => item => item.ItemKey; + + private void GetChildItemsForList(IDictionaryItem dictionaryItem, ICollection list) + { + IEnumerable? itemChildren = _localizationService.GetDictionaryItemChildren(dictionaryItem.Key); + + if (itemChildren is not null) + { + foreach (IDictionaryItem childItem in itemChildren + .OrderBy(DictionaryItemSort())) + { + EntityBasic? item = _umbracoMapper.Map(childItem); + if (item is not null) + { + list.Add(item); + } + + GetChildItemsForList(childItem, list); + } + } + } + + #endregion } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs b/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs index 0789900228..4ab0ccd072 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs @@ -1,279 +1,282 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Examine; using Examine.Search; using Lucene.Net.QueryParsers.Classic; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; -using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; using SearchResult = Umbraco.Cms.Core.Models.ContentEditing.SearchResult; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class ExamineManagementController : UmbracoAuthorizedJsonController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class ExamineManagementController : UmbracoAuthorizedJsonController + private readonly IExamineManager _examineManager; + private readonly IIndexDiagnosticsFactory _indexDiagnosticsFactory; + private readonly IIndexRebuilder _indexRebuilder; + private readonly ILogger _logger; + private readonly IAppPolicyCache _runtimeCache; + + public ExamineManagementController( + IExamineManager examineManager, + ILogger logger, + IIndexDiagnosticsFactory indexDiagnosticsFactory, + AppCaches appCaches, + IIndexRebuilder indexRebuilder) { - private readonly IExamineManager _examineManager; - private readonly ILogger _logger; - private readonly IIndexDiagnosticsFactory _indexDiagnosticsFactory; - private readonly IAppPolicyCache _runtimeCache; - private readonly IIndexRebuilder _indexRebuilder; + _examineManager = examineManager; + _logger = logger; + _indexDiagnosticsFactory = indexDiagnosticsFactory; + _runtimeCache = appCaches.RuntimeCache; + _indexRebuilder = indexRebuilder; + } - public ExamineManagementController( - IExamineManager examineManager, - ILogger logger, - IIndexDiagnosticsFactory indexDiagnosticsFactory, - AppCaches appCaches, - IIndexRebuilder indexRebuilder) + /// + /// Get the details for indexers + /// + /// + public IEnumerable GetIndexerDetails() + => _examineManager.Indexes + .Select(index => CreateModel(index)) + .OrderBy(examineIndexModel => examineIndexModel.Name?.TrimEnd("Indexer")); + + /// + /// Get the details for searchers + /// + /// + public IEnumerable GetSearcherDetails() + { + var model = new List( + _examineManager.RegisteredSearchers.Select(searcher => new ExamineSearcherModel { Name = searcher.Name }) + .OrderBy(x => + x.Name?.TrimEnd("Searcher"))); //order by name , but strip the "Searcher" from the end if it exists + return model; + } + + public ActionResult GetSearchResults(string searcherName, string? query, int pageIndex = 0, int pageSize = 20) + { + query = query?.Trim(); + + if (query.IsNullOrWhiteSpace()) { - _examineManager = examineManager; - _logger = logger; - _indexDiagnosticsFactory = indexDiagnosticsFactory; - _runtimeCache = appCaches.RuntimeCache; - _indexRebuilder = indexRebuilder; + return SearchResults.Empty(); } - /// - /// Get the details for indexers - /// - /// - public IEnumerable GetIndexerDetails() - => _examineManager.Indexes - .Select(index => CreateModel(index)) - .OrderBy(examineIndexModel => examineIndexModel.Name?.TrimEnd("Indexer")); - - /// - /// Get the details for searchers - /// - /// - public IEnumerable GetSearcherDetails() + ActionResult msg = ValidateSearcher(searcherName, out ISearcher searcher); + if (!msg.IsSuccessStatusCode()) { - var model = new List( - _examineManager.RegisteredSearchers.Select(searcher => new ExamineSearcherModel { Name = searcher.Name }) - .OrderBy(x => x.Name?.TrimEnd("Searcher"))); //order by name , but strip the "Searcher" from the end if it exists - return model; + return msg; } - public ActionResult GetSearchResults(string searcherName, string? query, int pageIndex = 0, int pageSize = 20) + ISearchResults results; + + // NativeQuery will work for a single word/phrase too (but depends on the implementation) the lucene one will work. + try { - query = query?.Trim(); - - if (query.IsNullOrWhiteSpace()) - { - return SearchResults.Empty(); - } - - var msg = ValidateSearcher(searcherName, out var searcher); - if (!msg.IsSuccessStatusCode()) - return msg; - - ISearchResults results; - - // NativeQuery will work for a single word/phrase too (but depends on the implementation) the lucene one will work. - try - { - results = searcher - .CreateQuery() - .NativeQuery(query) - .Execute(QueryOptions.SkipTake(pageSize * pageIndex, pageSize)); - } - catch (ParseException) - { - // will occur if the query parser cannot parse this (i.e. starts with a *) - return SearchResults.Empty(); - } - - return new SearchResults - { - TotalRecords = results.TotalItemCount, - Results = results.Select(x => new SearchResult - { - Id = x.Id, - Score = x.Score, - Values = x.AllValues.OrderBy(y => y.Key).ToDictionary(y => y.Key, y => y.Value) - }) - }; + results = searcher + .CreateQuery() + .NativeQuery(query) + .Execute(QueryOptions.SkipTake(pageSize * pageIndex, pageSize)); + } + catch (ParseException) + { + // will occur if the query parser cannot parse this (i.e. starts with a *) + return SearchResults.Empty(); } - /// - /// Check if the index has been rebuilt - /// - /// - /// - /// - /// This is kind of rudimentary since there's no way we can know that the index has rebuilt, we - /// have a listener for the index op complete so we'll just check if that key is no longer there in the runtime cache - /// - public ActionResult PostCheckRebuildIndex(string indexName) + return new SearchResults { - var validate = ValidateIndex(indexName, out var index); - - if (!validate.IsSuccessStatusCode()) + TotalRecords = results.TotalItemCount, + Results = results.Select(x => new SearchResult { - return validate; - } + Id = x.Id, + Score = x.Score, + Values = x.AllValues.OrderBy(y => y.Key).ToDictionary(y => y.Key, y => y.Value) + }) + }; + } - validate = ValidatePopulator(index!); - if (!validate.IsSuccessStatusCode()) - { - return validate; - } + /// + /// Check if the index has been rebuilt + /// + /// + /// + /// + /// This is kind of rudimentary since there's no way we can know that the index has rebuilt, we + /// have a listener for the index op complete so we'll just check if that key is no longer there in the runtime cache + /// + public ActionResult PostCheckRebuildIndex(string indexName) + { + ActionResult validate = ValidateIndex(indexName, out IIndex? index); - var cacheKey = "temp_indexing_op_" + indexName; - var found = _runtimeCache.Get(cacheKey); - - //if its still there then it's not done - return found != null - ? null - : CreateModel(index!); + if (!validate.IsSuccessStatusCode()) + { + return validate; } - /// - /// Rebuilds the index - /// - /// - /// - public IActionResult PostRebuildIndex(string indexName) + validate = ValidatePopulator(index!); + if (!validate.IsSuccessStatusCode()) { - var validate = ValidateIndex(indexName, out var index); - if (!validate.IsSuccessStatusCode()) - return validate; - - validate = ValidatePopulator(index!); - if (!validate.IsSuccessStatusCode()) - return validate; - - _logger.LogInformation("Rebuilding index '{IndexName}'", indexName); - - //remove it in case there's a handler there already - index!.IndexOperationComplete -= Indexer_IndexOperationComplete; - - //now add a single handler - index.IndexOperationComplete += Indexer_IndexOperationComplete; - - try - { - var cacheKey = "temp_indexing_op_" + index.Name; - //put temp val in cache which is used as a rudimentary way to know when the indexing is done - _runtimeCache.Insert(cacheKey, () => "tempValue", TimeSpan.FromMinutes(5)); - - _indexRebuilder.RebuildIndex(indexName); - - return new OkResult(); - } - catch (Exception ex) - { - //ensure it's not listening - index.IndexOperationComplete -= Indexer_IndexOperationComplete; - _logger.LogError(ex, "An error occurred rebuilding index"); - var response = new ConflictObjectResult("The index could not be rebuilt at this time, most likely there is another thread currently writing to the index. Error: {ex}"); - - HttpContext.SetReasonPhrase("Could Not Rebuild"); - return response; - } + return validate; } - private ExamineIndexModel CreateModel(IIndex index) + var cacheKey = "temp_indexing_op_" + indexName; + var found = _runtimeCache.Get(cacheKey); + + //if its still there then it's not done + return found != null + ? null + : CreateModel(index!); + } + + /// + /// Rebuilds the index + /// + /// + /// + public IActionResult PostRebuildIndex(string indexName) + { + ActionResult validate = ValidateIndex(indexName, out IIndex? index); + if (!validate.IsSuccessStatusCode()) { - var indexName = index.Name; - - var indexDiag = _indexDiagnosticsFactory.Create(index); - - var isHealth = indexDiag.IsHealthy(); - - var properties = new Dictionary - { - ["DocumentCount"] = indexDiag.GetDocumentCount(), - ["FieldCount"] = indexDiag.GetFieldNames().Count(), - }; - - foreach (KeyValuePair p in indexDiag.Metadata) - { - properties[p.Key] = p.Value; - } - - var indexerModel = new ExamineIndexModel - { - Name = indexName, - HealthStatus = isHealth.Success ? (isHealth.Result ?? "Healthy") : (isHealth.Result ?? "Unhealthy"), - ProviderProperties = properties, - CanRebuild = _indexRebuilder.CanRebuild(index.Name) - }; - - return indexerModel; + return validate; } - private ActionResult ValidateSearcher(string searcherName, out ISearcher searcher) + validate = ValidatePopulator(index!); + if (!validate.IsSuccessStatusCode()) { - //try to get the searcher from the indexes - if (_examineManager.TryGetIndex(searcherName, out IIndex index)) - { - searcher = index.Searcher; - return new OkResult(); - } - - //if we didn't find anything try to find it by an explicitly declared searcher - if (_examineManager.TryGetSearcher(searcherName, out searcher)) - { - return new OkResult(); - } - - var response1 = new BadRequestObjectResult($"No searcher found with name = {searcherName}"); - HttpContext.SetReasonPhrase("Searcher Not Found"); - return response1; + return validate; } - private ActionResult ValidatePopulator(IIndex index) - { - if (_indexRebuilder.CanRebuild(index.Name)) - { - return new OkResult(); - } + _logger.LogInformation("Rebuilding index '{IndexName}'", indexName); - var response = new BadRequestObjectResult($"The index {index.Name} cannot be rebuilt because it does not have an associated {typeof(IIndexPopulator)}"); - HttpContext.SetReasonPhrase("Index cannot be rebuilt"); + //remove it in case there's a handler there already + index!.IndexOperationComplete -= Indexer_IndexOperationComplete; + + //now add a single handler + index.IndexOperationComplete += Indexer_IndexOperationComplete; + + try + { + var cacheKey = "temp_indexing_op_" + index.Name; + //put temp val in cache which is used as a rudimentary way to know when the indexing is done + _runtimeCache.Insert(cacheKey, () => "tempValue", TimeSpan.FromMinutes(5)); + + _indexRebuilder.RebuildIndex(indexName); + + return new OkResult(); + } + catch (Exception ex) + { + //ensure it's not listening + index.IndexOperationComplete -= Indexer_IndexOperationComplete; + _logger.LogError(ex, "An error occurred rebuilding index"); + var response = new ConflictObjectResult( + "The index could not be rebuilt at this time, most likely there is another thread currently writing to the index. Error: {ex}"); + + HttpContext.SetReasonPhrase("Could Not Rebuild"); return response; } - - private ActionResult ValidateIndex(string indexName, out IIndex? index) - { - index = null; - - if (_examineManager.TryGetIndex(indexName, out index)) - { - //return Ok! - return new OkResult(); - } - - var response = new BadRequestObjectResult($"No index found with name = {indexName}"); - HttpContext.SetReasonPhrase("Index Not Found"); - return response; - } - - private void Indexer_IndexOperationComplete(object? sender, EventArgs e) - { - var indexer = (IIndex?)sender; - - _logger.LogDebug("Logging operation completed for index {IndexName}", indexer?.Name); - - if (indexer is not null) - { - //ensure it's not listening anymore - indexer.IndexOperationComplete -= Indexer_IndexOperationComplete; - } - - _logger.LogInformation($"Rebuilding index '{indexer?.Name}' done."); - - var cacheKey = "temp_indexing_op_" + indexer?.Name; - _runtimeCache.Clear(cacheKey); - } + } + + private ExamineIndexModel CreateModel(IIndex index) + { + var indexName = index.Name; + + IIndexDiagnostics indexDiag = _indexDiagnosticsFactory.Create(index); + + Attempt isHealth = indexDiag.IsHealthy(); + + var properties = new Dictionary + { + ["DocumentCount"] = indexDiag.GetDocumentCount(), + ["FieldCount"] = indexDiag.GetFieldNames().Count() + }; + + foreach (KeyValuePair p in indexDiag.Metadata) + { + properties[p.Key] = p.Value; + } + + var indexerModel = new ExamineIndexModel + { + Name = indexName, + HealthStatus = isHealth.Success ? isHealth.Result ?? "Healthy" : isHealth.Result ?? "Unhealthy", + ProviderProperties = properties, + CanRebuild = _indexRebuilder.CanRebuild(index.Name) + }; + + return indexerModel; + } + + private ActionResult ValidateSearcher(string searcherName, out ISearcher searcher) + { + //try to get the searcher from the indexes + if (_examineManager.TryGetIndex(searcherName, out IIndex index)) + { + searcher = index.Searcher; + return new OkResult(); + } + + //if we didn't find anything try to find it by an explicitly declared searcher + if (_examineManager.TryGetSearcher(searcherName, out searcher)) + { + return new OkResult(); + } + + var response1 = new BadRequestObjectResult($"No searcher found with name = {searcherName}"); + HttpContext.SetReasonPhrase("Searcher Not Found"); + return response1; + } + + private ActionResult ValidatePopulator(IIndex index) + { + if (_indexRebuilder.CanRebuild(index.Name)) + { + return new OkResult(); + } + + var response = new BadRequestObjectResult( + $"The index {index.Name} cannot be rebuilt because it does not have an associated {typeof(IIndexPopulator)}"); + HttpContext.SetReasonPhrase("Index cannot be rebuilt"); + return response; + } + + private ActionResult ValidateIndex(string indexName, out IIndex? index) + { + index = null; + + if (_examineManager.TryGetIndex(indexName, out index)) + { + //return Ok! + return new OkResult(); + } + + var response = new BadRequestObjectResult($"No index found with name = {indexName}"); + HttpContext.SetReasonPhrase("Index Not Found"); + return response; + } + + private void Indexer_IndexOperationComplete(object? sender, EventArgs e) + { + var indexer = (IIndex?)sender; + + _logger.LogDebug("Logging operation completed for index {IndexName}", indexer?.Name); + + if (indexer is not null) + { + //ensure it's not listening anymore + indexer.IndexOperationComplete -= Indexer_IndexOperationComplete; + } + + _logger.LogInformation($"Rebuilding index '{indexer?.Name}' done."); + + var cacheKey = "temp_indexing_op_" + indexer?.Name; + _runtimeCache.Clear(cacheKey); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/HelpController.cs b/src/Umbraco.Web.BackOffice/Controllers/HelpController.cs index f7bdd519e1..6cb7f1f4bc 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/HelpController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/HelpController.cs @@ -1,112 +1,103 @@ -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; 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 +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class HelpController : UmbracoAuthorizedJsonController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class HelpController : UmbracoAuthorizedJsonController + private static HttpClient? _httpClient; + private readonly ILogger _logger; + private HelpPageSettings? _helpPageSettings; + + [Obsolete("Use constructor that takes IOptions")] + public HelpController(ILogger logger) + : this(logger, StaticServiceProvider.Instance.GetRequiredService>()) { - 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; + + public async Task> GetContextHelpForPage(string section, string tree, + string baseUrl = "https://our.umbraco.com") + { + if (IsAllowedUrl(baseUrl) is false) { - } - - [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 - { - - if (_httpClient == null) - _httpClient = new HttpClient(); - - //fetch dashboard json and parse to JObject - var json = await _httpClient.GetStringAsync(url); - var result = JsonConvert.DeserializeObject>(json); - if (result != null) - return result; - - } - catch (HttpRequestException rex) - { - _logger.LogInformation($"Check your network connection, exception: {rex.Message}"); - } + _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(); } - private bool IsAllowedUrl(string url) + var url = string.Format( + baseUrl + "/Umbraco/Documentation/Lessons/GetContextHelpDocs?sectionAlias={0}&treeAlias={1}", section, + tree); + + try { - if (_helpPageSettings?.HelpPageUrlAllowList is null || - _helpPageSettings.HelpPageUrlAllowList.Contains(url)) + if (_httpClient == null) { - return true; + _httpClient = new HttpClient(); } - return false; + //fetch dashboard json and parse to JObject + var json = await _httpClient.GetStringAsync(url); + List? result = JsonConvert.DeserializeObject>(json); + if (result != null) + { + return result; + } } + catch (HttpRequestException rex) + { + _logger.LogInformation($"Check your network connection, exception: {rex.Message}"); + } + + return new List(); } - [DataContract(Name = "HelpPage")] - public class HelpPage + private bool IsAllowedUrl(string url) { - [DataMember(Name = "name")] - public string? Name { get; set; } - - [DataMember(Name = "description")] - public string? Description { get; set; } - - [DataMember(Name = "url")] - public string? Url { get; set; } - - [DataMember(Name = "type")] - public string? Type { get; set; } + if (_helpPageSettings?.HelpPageUrlAllowList is null || + _helpPageSettings.HelpPageUrlAllowList.Contains(url)) + { + return true; + } + return false; } } + +[DataContract(Name = "HelpPage")] +public class HelpPage +{ + [DataMember(Name = "name")] public string? Name { get; set; } + + [DataMember(Name = "description")] public string? Description { get; set; } + + [DataMember(Name = "url")] public string? Url { get; set; } + + [DataMember(Name = "type")] public string? Type { get; set; } +} diff --git a/src/Umbraco.Web.BackOffice/Controllers/IconController.cs b/src/Umbraco.Web.BackOffice/Controllers/IconController.cs index 46a97ffd9b..8dd5936a6b 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/IconController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/IconController.cs @@ -1,6 +1,3 @@ -using Newtonsoft.Json; -using System; -using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -8,38 +5,29 @@ using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Controllers; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController("UmbracoApi")] +[IsBackOffice] +[UmbracoRequireHttps] +[MiddlewareFilter(typeof(UnhandledExceptionLoggerFilter))] +public class IconController : UmbracoApiController { - [PluginController("UmbracoApi")] - [IsBackOffice] - [UmbracoRequireHttps] - [MiddlewareFilter(typeof(UnhandledExceptionLoggerFilter))] - public class IconController : UmbracoApiController - { - private readonly IIconService _iconService; + private readonly IIconService _iconService; - public IconController(IIconService iconService) - { - _iconService = iconService; - } + public IconController(IIconService iconService) => _iconService = iconService; - /// - /// Gets an IconModel containing the icon name and SvgString according to an icon name found at the global icons path - /// - /// - /// - public IconModel? GetIcon(string iconName) - { - return _iconService.GetIcon(iconName); - } - - - /// - /// Gets a list of all svg icons found at at the global icons path. - /// - /// - public IReadOnlyDictionary? GetIcons() => _iconService.GetIcons(); - } + /// + /// Gets an IconModel containing the icon name and SvgString according to an icon name found at the global icons path + /// + /// + /// + public IconModel? GetIcon(string iconName) => _iconService.GetIcon(iconName); + /// + /// Gets a list of all svg icons found at at the global icons path. + /// + /// + public IReadOnlyDictionary? GetIcons() => _iconService.GetIcons(); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ImageUrlGeneratorController.cs b/src/Umbraco.Web.BackOffice/Controllers/ImageUrlGeneratorController.cs index f234c7bfd9..bcde978048 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ImageUrlGeneratorController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ImageUrlGeneratorController.cs @@ -1,33 +1,28 @@ +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Web.Common.Attributes; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// The API controller used for getting URLs for images with parameters +/// +/// +/// +/// This controller allows for retrieving URLs for processed images, such as resized, cropped, +/// or otherwise altered. These can be different based on the IImageUrlGenerator +/// implementation in use, and so the BackOffice could should not rely on hard-coded string +/// building to generate correct URLs +/// +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class ImageUrlGeneratorController : UmbracoAuthorizedJsonController { - /// - /// The API controller used for getting URLs for images with parameters - /// - /// - /// - /// This controller allows for retrieving URLs for processed images, such as resized, cropped, - /// or otherwise altered. These can be different based on the IImageUrlGenerator - /// implementation in use, and so the BackOffice could should not rely on hard-coded string - /// building to generate correct URLs - /// - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class ImageUrlGeneratorController : UmbracoAuthorizedJsonController - { - private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly IImageUrlGenerator _imageUrlGenerator; - public ImageUrlGeneratorController(IImageUrlGenerator imageUrlGenerator) => _imageUrlGenerator = imageUrlGenerator; + public ImageUrlGeneratorController(IImageUrlGenerator imageUrlGenerator) => _imageUrlGenerator = imageUrlGenerator; - public string? GetCropUrl(string mediaPath, int? width = null, int? height = null, ImageCropMode? imageCropMode = null) => _imageUrlGenerator.GetImageUrl(new ImageUrlGenerationOptions(mediaPath) - { - Width = width, - Height = height, - ImageCropMode = imageCropMode - }); - } + public string? GetCropUrl(string mediaPath, int? width = null, int? height = null, ImageCropMode? imageCropMode = null) => _imageUrlGenerator.GetImageUrl( + new ImageUrlGenerationOptions(mediaPath) { Width = width, Height = height, ImageCropMode = imageCropMode }); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs b/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs index 2ec30456ca..8f7901b2b4 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs @@ -1,154 +1,153 @@ -using System; -using System.IO; using System.Web; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// A controller used to return images for media +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class ImagesController : UmbracoAuthorizedApiController { - /// - /// A controller used to return images for media - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class ImagesController : UmbracoAuthorizedApiController + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly MediaFileManager _mediaFileManager; + + public ImagesController( + MediaFileManager mediaFileManager, + IImageUrlGenerator imageUrlGenerator) { - private readonly MediaFileManager _mediaFileManager; - private readonly IImageUrlGenerator _imageUrlGenerator; + _mediaFileManager = mediaFileManager; + _imageUrlGenerator = imageUrlGenerator; + } - public ImagesController( - MediaFileManager mediaFileManager, - IImageUrlGenerator imageUrlGenerator) + /// + /// Gets the big thumbnail image for the original image path + /// + /// + /// + /// + /// If there is no original image is found then this will return not found. + /// + public IActionResult GetBigThumbnail(string originalImagePath) => + string.IsNullOrWhiteSpace(originalImagePath) + ? Ok() + : GetResized(originalImagePath, 500); + + /// + /// Gets a resized image for the image at the given path + /// + /// + /// + /// + /// + /// If there is no media, image property or image file is found then this will return not found. + /// + public IActionResult GetResized(string imagePath, int width) + { + // We have to use HttpUtility to encode the path here, for non-ASCII characters + // We cannot use the WebUtility, as we only want to encode the path, and not the entire string + var encodedImagePath = HttpUtility.UrlPathEncode(imagePath); + + + var ext = Path.GetExtension(encodedImagePath); + + // check if imagePath is local to prevent open redirect + if (!Uri.IsWellFormedUriString(encodedImagePath, UriKind.Relative)) { - _mediaFileManager = mediaFileManager; - _imageUrlGenerator = imageUrlGenerator; + return Unauthorized(); } - /// - /// Gets the big thumbnail image for the original image path - /// - /// - /// - /// - /// If there is no original image is found then this will return not found. - /// - public IActionResult GetBigThumbnail(string originalImagePath) + // we need to check if it is an image by extension + if (_imageUrlGenerator.IsSupportedImageFormat(ext) == false) { - return string.IsNullOrWhiteSpace(originalImagePath) - ? Ok() - : GetResized(originalImagePath, 500); + return NotFound(); } - /// - /// Gets a resized image for the image at the given path - /// - /// - /// - /// - /// - /// If there is no media, image property or image file is found then this will return not found. - /// - public IActionResult GetResized(string imagePath, int width) + // redirect to ImageProcessor thumbnail with rnd generated from last modified time of original media file + DateTimeOffset? imageLastModified = null; + try { - // We have to use HttpUtility to encode the path here, for non-ASCII characters - // We cannot use the WebUtility, as we only want to encode the path, and not the entire string - var encodedImagePath = HttpUtility.UrlPathEncode(imagePath); - - - var ext = Path.GetExtension(encodedImagePath); - - // check if imagePath is local to prevent open redirect - if (!Uri.IsWellFormedUriString(encodedImagePath, UriKind.Relative)) - { - return Unauthorized(); - } - - // we need to check if it is an image by extension - if (_imageUrlGenerator.IsSupportedImageFormat(ext) == false) - { - return NotFound(); - } - - // redirect to ImageProcessor thumbnail with rnd generated from last modified time of original media file - DateTimeOffset? imageLastModified = null; - try - { - imageLastModified = _mediaFileManager.FileSystem.GetLastModified(imagePath); - } - catch (Exception) - { - // if we get an exception here it's probably because the image path being requested is an image that doesn't exist - // in the local media file system. This can happen if someone is storing an absolute path to an image online, which - // is perfectly legal but in that case the media file system isn't going to resolve it. - // so ignore and we won't set a last modified date. - } - - var rnd = imageLastModified.HasValue ? $"&rnd={imageLastModified:yyyyMMddHHmmss}" : null; - var imageUrl = _imageUrlGenerator.GetImageUrl(new ImageUrlGenerationOptions(encodedImagePath) - { - Width = width, - ImageCropMode = ImageCropMode.Max, - CacheBusterValue = rnd - }); - if (Url.IsLocalUrl(imageUrl)) - { - return new LocalRedirectResult(imageUrl, false); - } - else - { - return Unauthorized(); - } + imageLastModified = _mediaFileManager.FileSystem.GetLastModified(imagePath); + } + catch (Exception) + { + // if we get an exception here it's probably because the image path being requested is an image that doesn't exist + // in the local media file system. This can happen if someone is storing an absolute path to an image online, which + // is perfectly legal but in that case the media file system isn't going to resolve it. + // so ignore and we won't set a last modified date. } - /// - /// Gets a processed image for the image at the given path - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// If there is no media, image property or image file is found then this will return not found. - /// - public string? GetProcessedImageUrl(string imagePath, - int? width = null, - int? height = null, - decimal? focalPointLeft = null, - decimal? focalPointTop = null, - ImageCropMode mode = ImageCropMode.Max, - string cacheBusterValue = "", - decimal? cropX1 = null, - decimal? cropX2 = null, - decimal? cropY1 = null, - decimal? cropY2 = null - ) + var rnd = imageLastModified.HasValue ? $"&rnd={imageLastModified:yyyyMMddHHmmss}" : null; + var imageUrl = _imageUrlGenerator.GetImageUrl(new ImageUrlGenerationOptions(encodedImagePath) { - var options = new ImageUrlGenerationOptions(imagePath) - { - Width = width, - Height = height, - ImageCropMode = mode, - CacheBusterValue = cacheBusterValue - }; - - if (focalPointLeft.HasValue && focalPointTop.HasValue) - { - options.FocalPoint = new ImageUrlGenerationOptions.FocalPointPosition(focalPointLeft.Value, focalPointTop.Value); - } - else if (cropX1.HasValue && cropX2.HasValue && cropY1.HasValue && cropY2.HasValue) - { - options.Crop = new ImageUrlGenerationOptions.CropCoordinates(cropX1.Value, cropY1.Value, cropX2.Value, cropY2.Value); - } - - return _imageUrlGenerator.GetImageUrl(options); + Width = width, + ImageCropMode = ImageCropMode.Max, + CacheBusterValue = rnd + }); + if (Url.IsLocalUrl(imageUrl)) + { + return new LocalRedirectResult(imageUrl, false); } + + return Unauthorized(); + } + + /// + /// Gets a processed image for the image at the given path + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// If there is no media, image property or image file is found then this will return not found. + /// + public string? GetProcessedImageUrl( + string imagePath, + int? width = null, + int? height = null, + decimal? focalPointLeft = null, + decimal? focalPointTop = null, + ImageCropMode mode = ImageCropMode.Max, + string cacheBusterValue = "", + decimal? cropX1 = null, + decimal? cropX2 = null, + decimal? cropY1 = null, + decimal? cropY2 = null) + { + var options = new ImageUrlGenerationOptions(imagePath) + { + Width = width, + Height = height, + ImageCropMode = mode, + CacheBusterValue = cacheBusterValue + }; + + if (focalPointLeft.HasValue && focalPointTop.HasValue) + { + options.FocalPoint = + new ImageUrlGenerationOptions.FocalPointPosition(focalPointLeft.Value, focalPointTop.Value); + } + else if (cropX1.HasValue && cropX2.HasValue && cropY1.HasValue && cropY2.HasValue) + { + options.Crop = + new ImageUrlGenerationOptions.CropCoordinates(cropX1.Value, cropY1.Value, cropX2.Value, cropY2.Value); + } + + return _imageUrlGenerator.GetImageUrl(options); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/LanguageController.cs b/src/Umbraco.Web.BackOffice/Controllers/LanguageController.cs index 1ed6efe9c6..bb515b61fc 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/LanguageController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/LanguageController.cs @@ -3,219 +3,221 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; using Language = Umbraco.Cms.Core.Models.ContentEditing.Language; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// Backoffice controller supporting the dashboard for language administration. +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class LanguageController : UmbracoAuthorizedJsonController { - /// - /// Backoffice controller supporting the dashboard for language administration. - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class LanguageController : UmbracoAuthorizedJsonController + private readonly ILocalizationService _localizationService; + private readonly IUmbracoMapper _umbracoMapper; + + [ActivatorUtilitiesConstructor] + public LanguageController(ILocalizationService localizationService, IUmbracoMapper umbracoMapper) { - private readonly ILocalizationService _localizationService; - private readonly IUmbracoMapper _umbracoMapper; + _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + } - [ActivatorUtilitiesConstructor] - public LanguageController(ILocalizationService localizationService, IUmbracoMapper umbracoMapper) + [Obsolete("Use the constructor without global settings instead, scheduled for removal in V11.")] + public LanguageController(ILocalizationService localizationService, IUmbracoMapper umbracoMapper, + IOptionsSnapshot globalSettings) + : this(localizationService, umbracoMapper) + { + } + + /// + /// Returns all cultures available for creating languages. + /// + /// + [HttpGet] + public IDictionary GetAllCultures() + => CultureInfo.GetCultures(CultureTypes.AllCultures).DistinctBy(x => x.Name).OrderBy(x => x.EnglishName) + .ToDictionary(x => x.Name, x => x.EnglishName); + + /// + /// Returns all currently configured languages. + /// + /// + [HttpGet] + public IEnumerable? GetAllLanguages() + { + IEnumerable allLanguages = _localizationService.GetAllLanguages(); + + return _umbracoMapper.Map, IEnumerable>(allLanguages); + } + + [HttpGet] + public ActionResult GetLanguage(int id) + { + ILanguage? lang = _localizationService.GetLanguageById(id); + if (lang == null) { - _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + return NotFound(); } - [Obsolete("Use the constructor without global settings instead, scheduled for removal in V11.")] - public LanguageController(ILocalizationService localizationService, IUmbracoMapper umbracoMapper, IOptionsSnapshot globalSettings) - : this(localizationService, umbracoMapper) - { } + return _umbracoMapper.Map(lang); + } - /// - /// Returns all cultures available for creating languages. - /// - /// - [HttpGet] - public IDictionary GetAllCultures() - => CultureInfo.GetCultures(CultureTypes.AllCultures).DistinctBy(x => x.Name).OrderBy(x => x.EnglishName).ToDictionary(x => x.Name, x => x.EnglishName); - - /// - /// Returns all currently configured languages. - /// - /// - [HttpGet] - public IEnumerable? GetAllLanguages() + /// + /// Deletes a language with a given ID + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessLanguages)] + [HttpDelete] + [HttpPost] + public IActionResult DeleteLanguage(int id) + { + ILanguage? language = _localizationService.GetLanguageById(id); + if (language == null) { - var allLanguages = _localizationService.GetAllLanguages(); - - return _umbracoMapper.Map, IEnumerable>(allLanguages); + return NotFound(); } - [HttpGet] - public ActionResult GetLanguage(int id) + // the service would not let us do it, but test here nevertheless + if (language.IsDefault) { - var lang = _localizationService.GetLanguageById(id); - if (lang == null) + var message = $"Language '{language.IsoCode}' is currently set to 'default' and can not be deleted."; + return ValidationProblem(message); + } + + // service is happy deleting a language that's fallback for another language, + // will just remove it - so no need to check here + _localizationService.Delete(language); + + return Ok(); + } + + /// + /// Creates or saves a language + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessLanguages)] + [HttpPost] + public ActionResult SaveLanguage(Language language) + { + if (!ModelState.IsValid) + { + return ValidationProblem(ModelState); + } + + // this is prone to race conditions but the service will not let us proceed anyways + ILanguage? existingByCulture = _localizationService.GetLanguageByIsoCode(language.IsoCode); + + // the localization service might return the generic language even when queried for specific ones (e.g. "da" when queried for "da-DK") + // - we need to handle that explicitly + if (existingByCulture?.IsoCode != language.IsoCode) + { + existingByCulture = null; + } + + if (existingByCulture != null && language.Id != existingByCulture.Id) + { + // Someone is trying to create a language that already exist + ModelState.AddModelError("IsoCode", "The language " + language.IsoCode + " already exists"); + return ValidationProblem(ModelState); + } + + ILanguage? existingById = language.Id != default ? _localizationService.GetLanguageById(language.Id) : null; + if (existingById == null) + { + // Creating a new lang... + CultureInfo culture; + try { - return NotFound(); + culture = CultureInfo.GetCultureInfo(language.IsoCode!); } - - return _umbracoMapper.Map(lang); - } - - /// - /// Deletes a language with a given ID - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessLanguages)] - [HttpDelete] - [HttpPost] - public IActionResult DeleteLanguage(int id) - { - var language = _localizationService.GetLanguageById(id); - if (language == null) - { - return NotFound(); - } - - // the service would not let us do it, but test here nevertheless - if (language.IsDefault) - { - var message = $"Language '{language.IsoCode}' is currently set to 'default' and can not be deleted."; - return ValidationProblem(message); - } - - // service is happy deleting a language that's fallback for another language, - // will just remove it - so no need to check here - _localizationService.Delete(language); - - return Ok(); - } - - /// - /// Creates or saves a language - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessLanguages)] - [HttpPost] - public ActionResult SaveLanguage(Language language) - { - if (!ModelState.IsValid) + catch (CultureNotFoundException) { + ModelState.AddModelError("IsoCode", "No Culture found with name " + language.IsoCode); return ValidationProblem(ModelState); } - // this is prone to race conditions but the service will not let us proceed anyways - var existingByCulture = _localizationService.GetLanguageByIsoCode(language.IsoCode); - - // the localization service might return the generic language even when queried for specific ones (e.g. "da" when queried for "da-DK") - // - we need to handle that explicitly - if (existingByCulture?.IsoCode != language.IsoCode) + // create it (creating a new language cannot create a fallback cycle) + var newLang = new Core.Models.Language(culture.Name, language.Name ?? culture.EnglishName) { - existingByCulture = null; - } + IsDefault = language.IsDefault, + IsMandatory = language.IsMandatory, + FallbackLanguageId = language.FallbackLanguageId + }; - if (existingByCulture != null && language.Id != existingByCulture.Id) - { - // Someone is trying to create a language that already exist - ModelState.AddModelError("IsoCode", "The language " + language.IsoCode + " already exists"); - return ValidationProblem(ModelState); - } - - var existingById = language.Id != default ? _localizationService.GetLanguageById(language.Id) : null; - if (existingById == null) - { - // Creating a new lang... - CultureInfo culture; - try - { - culture = CultureInfo.GetCultureInfo(language.IsoCode!); - } - catch (CultureNotFoundException) - { - ModelState.AddModelError("IsoCode", "No Culture found with name " + language.IsoCode); - return ValidationProblem(ModelState); - } - - // create it (creating a new language cannot create a fallback cycle) - var newLang = new Core.Models.Language(culture.Name, language.Name ?? culture.EnglishName) - { - IsDefault = language.IsDefault, - IsMandatory = language.IsMandatory, - FallbackLanguageId = language.FallbackLanguageId - }; - - _localizationService.Save(newLang); - return _umbracoMapper.Map(newLang); - } - - existingById.IsoCode = language.IsoCode; - if (!string.IsNullOrEmpty(language.Name)) - { - existingById.CultureName = language.Name; - } - - // note that the service will prevent the default language from being "un-defaulted" - // but does not hurt to test here - though the UI should prevent it too - if (existingById.IsDefault && !language.IsDefault) - { - ModelState.AddModelError("IsDefault", "Cannot un-default the default language."); - return ValidationProblem(ModelState); - } - - existingById.IsDefault = language.IsDefault; - existingById.IsMandatory = language.IsMandatory; - existingById.FallbackLanguageId = language.FallbackLanguageId; - - // modifying an existing language can create a fallback, verify - // note that the service will check again, dealing with race conditions - if (existingById.FallbackLanguageId.HasValue) - { - var languages = _localizationService.GetAllLanguages().ToDictionary(x => x.Id, x => x); - if (!languages.ContainsKey(existingById.FallbackLanguageId.Value)) - { - ModelState.AddModelError("FallbackLanguage", "The selected fall back language does not exist."); - return ValidationProblem(ModelState); - } - - if (CreatesCycle(existingById, languages)) - { - ModelState.AddModelError("FallbackLanguage", $"The selected fall back language {languages[existingById.FallbackLanguageId.Value].IsoCode} would create a circular path."); - return ValidationProblem(ModelState); - } - } - - _localizationService.Save(existingById); - return _umbracoMapper.Map(existingById); + _localizationService.Save(newLang); + return _umbracoMapper.Map(newLang); } - // see LocalizationService - private bool CreatesCycle(ILanguage language, IDictionary languages) + existingById.IsoCode = language.IsoCode; + if (!string.IsNullOrEmpty(language.Name)) { - // a new language is not referenced yet, so cannot be part of a cycle - if (!language.HasIdentity) + existingById.CultureName = language.Name; + } + + // note that the service will prevent the default language from being "un-defaulted" + // but does not hurt to test here - though the UI should prevent it too + if (existingById.IsDefault && !language.IsDefault) + { + ModelState.AddModelError("IsDefault", "Cannot un-default the default language."); + return ValidationProblem(ModelState); + } + + existingById.IsDefault = language.IsDefault; + existingById.IsMandatory = language.IsMandatory; + existingById.FallbackLanguageId = language.FallbackLanguageId; + + // modifying an existing language can create a fallback, verify + // note that the service will check again, dealing with race conditions + if (existingById.FallbackLanguageId.HasValue) + { + var languages = _localizationService.GetAllLanguages().ToDictionary(x => x.Id, x => x); + if (!languages.ContainsKey(existingById.FallbackLanguageId.Value)) { - return false; + ModelState.AddModelError("FallbackLanguage", "The selected fall back language does not exist."); + return ValidationProblem(ModelState); } - var id = language.FallbackLanguageId; - while (true) // assuming languages does not already contains a cycle, this must end + if (CreatesCycle(existingById, languages)) { - if (!id.HasValue) - { - return false; // no fallback means no cycle - } - - if (id.Value == language.Id) - { - return true; // back to language = cycle! - } - - id = languages[id.Value].FallbackLanguageId; // else keep chaining + ModelState.AddModelError("FallbackLanguage", + $"The selected fall back language {languages[existingById.FallbackLanguageId.Value].IsoCode} would create a circular path."); + return ValidationProblem(ModelState); } } + + _localizationService.Save(existingById); + return _umbracoMapper.Map(existingById); + } + + // see LocalizationService + private bool CreatesCycle(ILanguage language, IDictionary languages) + { + // a new language is not referenced yet, so cannot be part of a cycle + if (!language.HasIdentity) + { + return false; + } + + var id = language.FallbackLanguageId; + while (true) // assuming languages does not already contains a cycle, this must end + { + if (!id.HasValue) + { + return false; // no fallback means no cycle + } + + if (id.Value == language.Id) + { + return true; // back to language = cycle! + } + + id = languages[id.Value].FallbackLanguageId; // else keep chaining + } } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/LogController.cs b/src/Umbraco.Web.BackOffice/Controllers/LogController.cs index c90012b0de..272890584f 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/LogController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/LogController.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; @@ -9,125 +6,135 @@ using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// The API controller used for getting log history +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class LogController : UmbracoAuthorizedJsonController { - /// - /// The API controller used for getting log history - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class LogController : UmbracoAuthorizedJsonController + private readonly AppCaches _appCaches; + private readonly IAuditService _auditService; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly MediaFileManager _mediaFileManager; + private readonly ISqlContext _sqlContext; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IUserService _userService; + + public LogController( + MediaFileManager mediaFileSystem, + IImageUrlGenerator imageUrlGenerator, + IAuditService auditService, + IUmbracoMapper umbracoMapper, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IUserService userService, + AppCaches appCaches, + ISqlContext sqlContext) { - private readonly MediaFileManager _mediaFileManager; - private readonly IImageUrlGenerator _imageUrlGenerator; - private readonly IAuditService _auditService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IUserService _userService; - private readonly AppCaches _appCaches; - private readonly ISqlContext _sqlContext; + _mediaFileManager = mediaFileSystem ?? throw new ArgumentNullException(nameof(mediaFileSystem)); + _imageUrlGenerator = imageUrlGenerator ?? throw new ArgumentNullException(nameof(imageUrlGenerator)); + _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _userService = userService ?? throw new ArgumentNullException(nameof(userService)); + _appCaches = appCaches ?? throw new ArgumentNullException(nameof(appCaches)); + _sqlContext = sqlContext ?? throw new ArgumentNullException(nameof(sqlContext)); + } - public LogController( - MediaFileManager mediaFileSystem, - IImageUrlGenerator imageUrlGenerator, - IAuditService auditService, - IUmbracoMapper umbracoMapper, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IUserService userService, - AppCaches appCaches, - ISqlContext sqlContext) - { - _mediaFileManager = mediaFileSystem ?? throw new ArgumentNullException(nameof(mediaFileSystem)); - _imageUrlGenerator = imageUrlGenerator ?? throw new ArgumentNullException(nameof(imageUrlGenerator)); - _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _userService = userService ?? throw new ArgumentNullException(nameof(userService)); - _appCaches = appCaches ?? throw new ArgumentNullException(nameof(appCaches)); - _sqlContext = sqlContext ?? throw new ArgumentNullException(nameof(sqlContext)); - } - - [Authorize(Policy = AuthorizationPolicies.SectionAccessContentOrMedia)] - public PagedResult GetPagedEntityLog(int id, - int pageNumber = 1, - int pageSize = 10, - Direction orderDirection = Direction.Descending, - DateTime? sinceDate = null) + [Authorize(Policy = AuthorizationPolicies.SectionAccessContentOrMedia)] + public PagedResult GetPagedEntityLog( + int id, + int pageNumber = 1, + int pageSize = 10, + Direction orderDirection = Direction.Descending, + DateTime? sinceDate = null) + { + if (pageSize <= 0 || pageNumber <= 0) { - if (pageSize <= 0 || pageNumber <= 0) + return new PagedResult(0, pageNumber, pageSize); + } + + IQuery? dateQuery = sinceDate.HasValue + ? _sqlContext.Query().Where(x => x.CreateDate >= sinceDate) + : null; + IEnumerable result = _auditService.GetPagedItemsByEntity( + id, + pageNumber - 1, + pageSize, + out long totalRecords, + orderDirection, + customFilter: dateQuery); + IEnumerable mapped = result.Select(item => _umbracoMapper.Map(item)).WhereNotNull(); + + var page = new PagedResult(totalRecords, pageNumber, pageSize) { Items = MapAvatarsAndNames(mapped) }; + + return page; + } + + public PagedResult GetPagedCurrentUserLog( + int pageNumber = 1, + int pageSize = 10, + Direction orderDirection = Direction.Descending, + DateTime? sinceDate = null) + { + if (pageSize <= 0 || pageNumber <= 0) + { + return new PagedResult(0, pageNumber, pageSize); + } + + IQuery? dateQuery = sinceDate.HasValue + ? _sqlContext.Query().Where(x => x.CreateDate >= sinceDate) + : null; + var userId = _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1; + IEnumerable result = _auditService.GetPagedItemsByUser( + userId, + pageNumber - 1, + pageSize, + out long totalRecords, + orderDirection, + customFilter: dateQuery); + IEnumerable mapped = _umbracoMapper.MapEnumerable(result).WhereNotNull(); + return new PagedResult(totalRecords, pageNumber, pageSize) { Items = MapAvatarsAndNames(mapped) }; + } + + public IEnumerable GetLog(AuditType logType, DateTime? sinceDate = null) + { + IEnumerable result = _auditService.GetLogs(Enum.Parse(logType.ToString()), sinceDate); + IEnumerable mapped = _umbracoMapper.MapEnumerable(result).WhereNotNull(); + return mapped; + } + + private IEnumerable MapAvatarsAndNames(IEnumerable items) + { + var mappedItems = items.ToList(); + var userIds = mappedItems.Select(x => x.UserId).ToArray(); + var userAvatars = _userService.GetUsersById(userIds).ToDictionary( + x => x.Id, x => x.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator)); + var userNames = _userService.GetUsersById(userIds).ToDictionary(x => x.Id, x => x.Name); + foreach (AuditLog item in mappedItems) + { + if (userAvatars.TryGetValue(item.UserId, out var avatars)) { - return new PagedResult(0, pageNumber, pageSize); + item.UserAvatars = avatars; } - long totalRecords; - var dateQuery = sinceDate.HasValue ? _sqlContext.Query().Where(x => x.CreateDate >= sinceDate) : null; - var result = _auditService.GetPagedItemsByEntity(id, pageNumber - 1, pageSize, out totalRecords, orderDirection, customFilter: dateQuery); - var mapped = result.Select(item => _umbracoMapper.Map(item)).WhereNotNull(); - - var page = new PagedResult(totalRecords, pageNumber, pageSize) + if (userNames.TryGetValue(item.UserId, out var name)) { - Items = MapAvatarsAndNames(mapped) - }; - - return page; - } - - public PagedResult GetPagedCurrentUserLog( - int pageNumber = 1, - int pageSize = 10, - Direction orderDirection = Direction.Descending, - DateTime? sinceDate = null) - { - if (pageSize <= 0 || pageNumber <= 0) - { - return new PagedResult(0, pageNumber, pageSize); + item.UserName = name; } - - long totalRecords; - var dateQuery = sinceDate.HasValue ? _sqlContext.Query().Where(x => x.CreateDate >= sinceDate) : null; - var userId = _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1; - var result = _auditService.GetPagedItemsByUser(userId, pageNumber - 1, pageSize, out totalRecords, orderDirection, customFilter:dateQuery); - var mapped = _umbracoMapper.MapEnumerable(result).WhereNotNull(); - return new PagedResult(totalRecords, pageNumber, pageSize) - { - Items = MapAvatarsAndNames(mapped) - }; } - public IEnumerable GetLog(AuditType logType, DateTime? sinceDate = null) - { - var result = _auditService.GetLogs(Enum.Parse(logType.ToString()), sinceDate); - var mapped = _umbracoMapper.MapEnumerable(result).WhereNotNull(); - return mapped; - } - - private IEnumerable MapAvatarsAndNames(IEnumerable items) - { - var mappedItems = items.ToList(); - var userIds = mappedItems.Select(x => x.UserId).ToArray(); - var userAvatars = Enumerable.ToDictionary(_userService.GetUsersById(userIds), x => x.Id, x => x.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator)); - var userNames = Enumerable.ToDictionary(_userService.GetUsersById(userIds), x => x.Id, x => x.Name); - foreach (var item in mappedItems) - { - if (userAvatars.TryGetValue(item.UserId, out var avatars)) - { - item.UserAvatars = avatars; - } - if (userNames.TryGetValue(item.UserId, out var name)) - { - item.UserName = name; - } - - - } - return mappedItems; - } + return mappedItems; } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/LogViewerController.cs b/src/Umbraco.Web.BackOffice/Controllers/LogViewerController.cs index 4e562fca60..4c3cce59a5 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/LogViewerController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/LogViewerController.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Collections.ObjectModel; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -11,154 +9,146 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.DependencyInjection; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// Backoffice controller supporting the dashboard for viewing logs with some simple graphs & filtering +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] +public class LogViewerController : BackOfficeNotificationsController { - /// - /// Backoffice controller supporting the dashboard for viewing logs with some simple graphs & filtering - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] + private readonly ILogLevelLoader _logLevelLoader; + private readonly ILogViewer _logViewer; - public class LogViewerController : BackOfficeNotificationsController + [Obsolete] + public LogViewerController(ILogViewer logViewer) + : this(logViewer, StaticServiceProvider.Instance.GetRequiredService()) { - private readonly ILogViewer _logViewer; - private readonly ILogLevelLoader _logLevelLoader; - - [Obsolete] - public LogViewerController(ILogViewer logViewer) - : this(logViewer, StaticServiceProvider.Instance.GetRequiredService()) - { - } - - [ActivatorUtilitiesConstructor] - public LogViewerController(ILogViewer logViewer, ILogLevelLoader logLevelLoader) - { - _logViewer = logViewer ?? throw new ArgumentNullException(nameof(logViewer)); - _logLevelLoader = logLevelLoader ?? throw new ArgumentNullException(nameof(logLevelLoader)); - } - - private bool CanViewLogs(LogTimePeriod logTimePeriod) - { - //Can the interface deal with Large Files - if (_logViewer.CanHandleLargeLogs) - return true; - - //Interface CheckCanOpenLogs - return _logViewer.CheckCanOpenLogs(logTimePeriod); - } - - [HttpGet] - public bool GetCanViewLogs([FromQuery] DateTime? startDate = null,[FromQuery] DateTime? endDate = null) - { - var logTimePeriod = GetTimePeriod(startDate, endDate); - return CanViewLogs(logTimePeriod); - } - - [HttpGet] - public ActionResult GetNumberOfErrors([FromQuery] DateTime? startDate = null,[FromQuery] DateTime? endDate = null) - { - var logTimePeriod = GetTimePeriod(startDate, endDate); - //We will need to stop the request if trying to do this on a 1GB file - if (CanViewLogs(logTimePeriod) == false) - { - return ValidationProblem("Unable to view logs, due to size"); - } - - return _logViewer.GetNumberOfErrors(logTimePeriod); - } - - [HttpGet] - public ActionResult GetLogLevelCounts([FromQuery] DateTime? startDate = null,[FromQuery] DateTime? endDate = null) - { - var logTimePeriod = GetTimePeriod(startDate, endDate); - //We will need to stop the request if trying to do this on a 1GB file - if (CanViewLogs(logTimePeriod) == false) - { - return ValidationProblem("Unable to view logs, due to size"); - } - - return _logViewer.GetLogLevelCounts(logTimePeriod); - } - - [HttpGet] - public ActionResult> GetMessageTemplates([FromQuery] DateTime? startDate = null,[FromQuery] DateTime? endDate = null) - { - var logTimePeriod = GetTimePeriod(startDate, endDate); - //We will need to stop the request if trying to do this on a 1GB file - if (CanViewLogs(logTimePeriod) == false) - { - return ValidationProblem("Unable to view logs, due to size"); - } - - return new ActionResult>(_logViewer.GetMessageTemplates(logTimePeriod)); - } - - [HttpGet] - public ActionResult> GetLogs(string orderDirection = "Descending", int pageNumber = 1, string? filterExpression = null, [FromQuery(Name = "logLevels[]")]string[]? logLevels = null, [FromQuery]DateTime? startDate = null, [FromQuery]DateTime? endDate = null) - { - var logTimePeriod = GetTimePeriod(startDate, endDate); - - //We will need to stop the request if trying to do this on a 1GB file - if (CanViewLogs(logTimePeriod) == false) - { - return ValidationProblem("Unable to view logs, due to size"); - } - - var direction = orderDirection == "Descending" ? Direction.Descending : Direction.Ascending; - - return _logViewer.GetLogs(logTimePeriod, filterExpression: filterExpression, pageNumber: pageNumber, orderDirection: direction, logLevels: logLevels); - } - - private static LogTimePeriod GetTimePeriod(DateTime? startDate, DateTime? endDate) - { - if (startDate == null || endDate == null) - { - var now = DateTime.Now; - if (startDate == null) - { - startDate = now.AddDays(-1); - } - - if (endDate == null) - { - endDate = now; - } - } - - return new LogTimePeriod(startDate.Value, endDate.Value); - } - - [HttpGet] - public IEnumerable? GetSavedSearches() - { - return _logViewer.GetSavedSearches(); - } - - [HttpPost] - public IEnumerable? PostSavedSearch(SavedLogSearch item) - { - return _logViewer.AddSavedSearch(item.Name, item.Query); - } - - [HttpPost] - public IEnumerable? DeleteSavedSearch(SavedLogSearch item) - { - return _logViewer.DeleteSavedSearch(item.Name, item.Query); - } - - [HttpGet] - public ReadOnlyDictionary GetLogLevels() - { - return _logLevelLoader.GetLogLevelsFromSinks(); - } - - [Obsolete("Please use GetLogLevels() instead. Scheduled for removal in V11.")] - [HttpGet] - public string GetLogLevel() - { - return _logViewer.GetLogLevel(); - } } + + [ActivatorUtilitiesConstructor] + public LogViewerController(ILogViewer logViewer, ILogLevelLoader logLevelLoader) + { + _logViewer = logViewer ?? throw new ArgumentNullException(nameof(logViewer)); + _logLevelLoader = logLevelLoader ?? throw new ArgumentNullException(nameof(logLevelLoader)); + } + + private bool CanViewLogs(LogTimePeriod logTimePeriod) + { + //Can the interface deal with Large Files + if (_logViewer.CanHandleLargeLogs) + { + return true; + } + + //Interface CheckCanOpenLogs + return _logViewer.CheckCanOpenLogs(logTimePeriod); + } + + [HttpGet] + public bool GetCanViewLogs([FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null) + { + LogTimePeriod logTimePeriod = GetTimePeriod(startDate, endDate); + return CanViewLogs(logTimePeriod); + } + + [HttpGet] + public ActionResult GetNumberOfErrors([FromQuery] DateTime? startDate = null, + [FromQuery] DateTime? endDate = null) + { + LogTimePeriod logTimePeriod = GetTimePeriod(startDate, endDate); + //We will need to stop the request if trying to do this on a 1GB file + if (CanViewLogs(logTimePeriod) == false) + { + return ValidationProblem("Unable to view logs, due to size"); + } + + return _logViewer.GetNumberOfErrors(logTimePeriod); + } + + [HttpGet] + public ActionResult GetLogLevelCounts([FromQuery] DateTime? startDate = null, + [FromQuery] DateTime? endDate = null) + { + LogTimePeriod logTimePeriod = GetTimePeriod(startDate, endDate); + //We will need to stop the request if trying to do this on a 1GB file + if (CanViewLogs(logTimePeriod) == false) + { + return ValidationProblem("Unable to view logs, due to size"); + } + + return _logViewer.GetLogLevelCounts(logTimePeriod); + } + + [HttpGet] + public ActionResult> GetMessageTemplates([FromQuery] DateTime? startDate = null, + [FromQuery] DateTime? endDate = null) + { + LogTimePeriod logTimePeriod = GetTimePeriod(startDate, endDate); + //We will need to stop the request if trying to do this on a 1GB file + if (CanViewLogs(logTimePeriod) == false) + { + return ValidationProblem("Unable to view logs, due to size"); + } + + return new ActionResult>(_logViewer.GetMessageTemplates(logTimePeriod)); + } + + [HttpGet] + public ActionResult> GetLogs(string orderDirection = "Descending", int pageNumber = 1, + string? filterExpression = null, [FromQuery(Name = "logLevels[]")] string[]? logLevels = null, + [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null) + { + LogTimePeriod logTimePeriod = GetTimePeriod(startDate, endDate); + + //We will need to stop the request if trying to do this on a 1GB file + if (CanViewLogs(logTimePeriod) == false) + { + return ValidationProblem("Unable to view logs, due to size"); + } + + Direction direction = orderDirection == "Descending" ? Direction.Descending : Direction.Ascending; + + return _logViewer.GetLogs(logTimePeriod, filterExpression: filterExpression, pageNumber: pageNumber, + orderDirection: direction, logLevels: logLevels); + } + + private static LogTimePeriod GetTimePeriod(DateTime? startDate, DateTime? endDate) + { + if (startDate == null || endDate == null) + { + DateTime now = DateTime.Now; + if (startDate == null) + { + startDate = now.AddDays(-1); + } + + if (endDate == null) + { + endDate = now; + } + } + + return new LogTimePeriod(startDate.Value, endDate.Value); + } + + [HttpGet] + public IEnumerable? GetSavedSearches() => _logViewer.GetSavedSearches(); + + [HttpPost] + public IEnumerable? PostSavedSearch(SavedLogSearch item) => + _logViewer.AddSavedSearch(item.Name, item.Query); + + [HttpPost] + public IEnumerable? DeleteSavedSearch(SavedLogSearch item) => + _logViewer.DeleteSavedSearch(item.Name, item.Query); + + [HttpGet] + public ReadOnlyDictionary GetLogLevels() => _logLevelLoader.GetLogLevelsFromSinks(); + + [Obsolete("Please use GetLogLevels() instead. Scheduled for removal in V11.")] + [HttpGet] + public string GetLogLevel() => _logViewer.GetLogLevel(); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MacroRenderingController.cs b/src/Umbraco.Web.BackOffice/Controllers/MacroRenderingController.cs index 34f3e1f632..a4cb568857 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MacroRenderingController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MacroRenderingController.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Text; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; @@ -19,167 +14,187 @@ using Umbraco.Cms.Core.Web; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// API controller to deal with Macro data +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class MacroRenderingController : UmbracoAuthorizedJsonController { - /// - /// API controller to deal with Macro data - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class MacroRenderingController : UmbracoAuthorizedJsonController + private readonly IUmbracoComponentRenderer _componentRenderer; + private readonly IMacroService _macroService; + private readonly IShortStringHelper _shortStringHelper; + private readonly ISiteDomainMapper _siteDomainHelper; + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IVariationContextAccessor _variationContextAccessor; + + + public MacroRenderingController( + IUmbracoMapper umbracoMapper, + IUmbracoComponentRenderer componentRenderer, + IVariationContextAccessor variationContextAccessor, + IMacroService macroService, + IUmbracoContextAccessor umbracoContextAccessor, + IShortStringHelper shortStringHelper, + ISiteDomainMapper siteDomainHelper) + { - private readonly IMacroService _macroService; - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly IShortStringHelper _shortStringHelper; - private readonly ISiteDomainMapper _siteDomainHelper; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IUmbracoComponentRenderer _componentRenderer; - private readonly IVariationContextAccessor _variationContextAccessor; - - - public MacroRenderingController( - IUmbracoMapper umbracoMapper, - IUmbracoComponentRenderer componentRenderer, - IVariationContextAccessor variationContextAccessor, - IMacroService macroService, - IUmbracoContextAccessor umbracoContextAccessor, - IShortStringHelper shortStringHelper, - ISiteDomainMapper siteDomainHelper) + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _componentRenderer = componentRenderer ?? throw new ArgumentNullException(nameof(componentRenderer)); + _variationContextAccessor = variationContextAccessor ?? + throw new ArgumentNullException(nameof(variationContextAccessor)); + _macroService = macroService ?? throw new ArgumentNullException(nameof(macroService)); + _umbracoContextAccessor = + umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _siteDomainHelper = siteDomainHelper ?? throw new ArgumentNullException(nameof(siteDomainHelper)); + } + /// + /// Gets the macro parameters to be filled in for a particular macro + /// + /// + /// + /// Note that ALL logged in users have access to this method because editors will need to insert macros into rte + /// (content/media/members) and it's used for + /// inserting into templates/views/etc... it doesn't expose any sensitive data. + /// + public ActionResult> GetMacroParameters(int macroId) + { + IMacro? macro = _macroService.GetById(macroId); + if (macro == null) { - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _componentRenderer = componentRenderer ?? throw new ArgumentNullException(nameof(componentRenderer)); - _variationContextAccessor = variationContextAccessor ?? throw new ArgumentNullException(nameof(variationContextAccessor)); - _macroService = macroService ?? throw new ArgumentNullException(nameof(macroService)); - _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _siteDomainHelper = siteDomainHelper ?? throw new ArgumentNullException(nameof(siteDomainHelper)); + return NotFound(); } - /// - /// Gets the macro parameters to be filled in for a particular macro - /// - /// - /// - /// Note that ALL logged in users have access to this method because editors will need to insert macros into rte (content/media/members) and it's used for - /// inserting into templates/views/etc... it doesn't expose any sensitive data. - /// - public ActionResult> GetMacroParameters(int macroId) - { - var macro = _macroService.GetById(macroId); - if (macro == null) - { - return NotFound(); - } + return new ActionResult>( + _umbracoMapper.Map>(macro)!.OrderBy(x => x.SortOrder)); + } - return new ActionResult>(_umbracoMapper.Map>(macro)!.OrderBy(x => x.SortOrder)); + /// + /// Gets a rendered macro as HTML for rendering in the rich text editor + /// + /// + /// + /// + /// To send a dictionary as a GET parameter the query should be structured like: + /// ?macroAlias=Test&pageId=3634¯oParams[0].key=myKey¯oParams[0].value=myVal¯oParams[1].key=anotherKey + /// ¯oParams[1].value=anotherVal + /// + /// + [HttpGet] + public async Task GetMacroResultAsHtmlForEditor(string macroAlias, int pageId, + [FromQuery] IDictionary macroParams) => + await GetMacroResultAsHtml(macroAlias, pageId, macroParams); + + /// + /// Gets a rendered macro as HTML for rendering in the rich text editor. + /// Using HTTP POST instead of GET allows for more parameters to be passed as it's not dependent on URL-length + /// limitations like GET. + /// The method using GET is kept to maintain backwards compatibility + /// + /// + /// + [HttpPost] + public async Task GetMacroResultAsHtmlForEditor(MacroParameterModel model) => + await GetMacroResultAsHtml(model.MacroAlias, model.PageId, model.MacroParams); + + private async Task GetMacroResultAsHtml(string? macroAlias, int pageId, + IDictionary? macroParams) + { + IMacro? m = macroAlias is null ? null : _macroService.GetByAlias(macroAlias); + if (m == null) + { + return NotFound(); } - /// - /// Gets a rendered macro as HTML for rendering in the rich text editor - /// - /// - /// - /// - /// To send a dictionary as a GET parameter the query should be structured like: - /// - /// ?macroAlias=Test&pageId=3634¯oParams[0].key=myKey¯oParams[0].value=myVal¯oParams[1].key=anotherKey¯oParams[1].value=anotherVal - /// - /// - /// - [HttpGet] - public async Task GetMacroResultAsHtmlForEditor(string macroAlias, int pageId, [FromQuery] IDictionary macroParams) + IUmbracoContext umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); + IPublishedContent? publishedContent = umbracoContext.Content?.GetById(true, pageId); + + //if it isn't supposed to be rendered in the editor then return an empty string + //currently we cannot render a macro if the page doesn't yet exist + if (pageId == -1 || publishedContent == null || m.DontRender) { - return await GetMacroResultAsHtml(macroAlias, pageId, macroParams); + //need to create a specific content result formatted as HTML since this controller has been configured + //with only json formatters. + return Content(string.Empty, "text/html", Encoding.UTF8); } - /// - /// Gets a rendered macro as HTML for rendering in the rich text editor. - /// Using HTTP POST instead of GET allows for more parameters to be passed as it's not dependent on URL-length limitations like GET. - /// The method using GET is kept to maintain backwards compatibility - /// - /// - /// - [HttpPost] - public async Task GetMacroResultAsHtmlForEditor(MacroParameterModel model) + + // When rendering the macro in the backoffice the default setting would be to use the Culture of the logged in user. + // Since a Macro might contain thing thats related to the culture of the "IPublishedContent" (ie Dictionary keys) we want + // to set the current culture to the culture related to the content item. This is hacky but it works. + + // fixme + // in a 1:1 situation we do not handle the language being edited + // so the macro renders in the wrong language + + var culture = DomainUtilities.GetCultureFromDomains(publishedContent.Id, publishedContent.Path, null, + umbracoContext, _siteDomainHelper); + + if (culture != null) { - return await GetMacroResultAsHtml(model.MacroAlias, model.PageId, model.MacroParams); + Thread.CurrentThread.CurrentCulture = + Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(culture); } - public class MacroParameterModel + // must have an active variation context! + _variationContextAccessor.VariationContext = new VariationContext(culture); + + using (umbracoContext.ForcedPreview(true)) { - public string? MacroAlias { get; set; } - public int PageId { get; set; } - public IDictionary? MacroParams { get; set; } - } - - private async Task GetMacroResultAsHtml(string? macroAlias, int pageId, IDictionary? macroParams) - { - var m = macroAlias is null ? null : _macroService.GetByAlias(macroAlias); - if (m == null) - return NotFound(); - - var umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); - var publishedContent = umbracoContext.Content?.GetById(true, pageId); - - //if it isn't supposed to be rendered in the editor then return an empty string - //currently we cannot render a macro if the page doesn't yet exist - if (pageId == -1 || publishedContent == null || m.DontRender) - { - //need to create a specific content result formatted as HTML since this controller has been configured - //with only json formatters. - return Content(string.Empty, "text/html", Encoding.UTF8); - } - - - // When rendering the macro in the backoffice the default setting would be to use the Culture of the logged in user. - // Since a Macro might contain thing thats related to the culture of the "IPublishedContent" (ie Dictionary keys) we want - // to set the current culture to the culture related to the content item. This is hacky but it works. - - // fixme - // in a 1:1 situation we do not handle the language being edited - // so the macro renders in the wrong language - - var culture = DomainUtilities.GetCultureFromDomains(publishedContent.Id, publishedContent.Path, null, umbracoContext, _siteDomainHelper); - - if (culture != null) - Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(culture); - - // must have an active variation context! - _variationContextAccessor.VariationContext = new VariationContext(culture); - - using (umbracoContext.ForcedPreview(true)) - { - //need to create a specific content result formatted as HTML since this controller has been configured - //with only json formatters. - return Content((await _componentRenderer.RenderMacroForContent(publishedContent, m.Alias, macroParams)).ToString() ?? string.Empty, "text/html", - Encoding.UTF8); - } - } - - [HttpPost] - public IActionResult CreatePartialViewMacroWithFile(CreatePartialViewMacroWithFileModel model) - { - if (model == null) throw new ArgumentNullException("model"); - if (string.IsNullOrWhiteSpace(model.Filename)) throw new ArgumentException("Filename cannot be null or whitespace", "model.Filename"); - if (string.IsNullOrWhiteSpace(model.VirtualPath)) throw new ArgumentException("VirtualPath cannot be null or whitespace", "model.VirtualPath"); - - var macroName = model.Filename.TrimEnd(".cshtml"); - - var macro = new Macro(_shortStringHelper) - { - Alias = macroName.ToSafeAlias(_shortStringHelper), - Name = macroName, - MacroSource = model.VirtualPath.EnsureStartsWith("~") - }; - - _macroService.Save(macro); // may throw - return Ok(); - } - - public class CreatePartialViewMacroWithFileModel - { - public string? Filename { get; set; } - public string? VirtualPath { get; set; } + //need to create a specific content result formatted as HTML since this controller has been configured + //with only json formatters. + return Content( + (await _componentRenderer.RenderMacroForContent(publishedContent, m.Alias, macroParams)).ToString() ?? + string.Empty, "text/html", + Encoding.UTF8); } } + + [HttpPost] + public IActionResult CreatePartialViewMacroWithFile(CreatePartialViewMacroWithFileModel model) + { + if (model == null) + { + throw new ArgumentNullException("model"); + } + + if (string.IsNullOrWhiteSpace(model.Filename)) + { + throw new ArgumentException("Filename cannot be null or whitespace", "model.Filename"); + } + + if (string.IsNullOrWhiteSpace(model.VirtualPath)) + { + throw new ArgumentException("VirtualPath cannot be null or whitespace", "model.VirtualPath"); + } + + var macroName = model.Filename.TrimEnd(".cshtml"); + + var macro = new Macro(_shortStringHelper) + { + Alias = macroName.ToSafeAlias(_shortStringHelper), + Name = macroName, + MacroSource = model.VirtualPath.EnsureStartsWith("~") + }; + + _macroService.Save(macro); // may throw + return Ok(); + } + + public class MacroParameterModel + { + public string? MacroAlias { get; set; } + public int PageId { get; set; } + public IDictionary? MacroParams { get; set; } + } + + public class CreatePartialViewMacroWithFileModel + { + public string? Filename { get; set; } + public string? VirtualPath { get; set; } + } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MacrosController.cs b/src/Umbraco.Web.BackOffice/Controllers/MacrosController.cs index af99d68548..34a28dd874 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MacrosController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MacrosController.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -19,411 +14,405 @@ using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// The API controller used for editing dictionary items +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessMacros)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +public class MacrosController : BackOfficeNotificationsController { + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly ILogger _logger; + private readonly IMacroService _macroService; + private readonly ParameterEditorCollection _parameterEditorCollection; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; + + public MacrosController( + ParameterEditorCollection parameterEditorCollection, + IMacroService macroService, + IShortStringHelper shortStringHelper, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILogger logger, + IHostingEnvironment hostingEnvironment, + IUmbracoMapper umbracoMapper) + { + _parameterEditorCollection = parameterEditorCollection ?? + throw new ArgumentNullException(nameof(parameterEditorCollection)); + _macroService = macroService ?? throw new ArgumentNullException(nameof(macroService)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + } + /// - /// The API controller used for editing dictionary items + /// Creates a new macro /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMacros)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - public class MacrosController : BackOfficeNotificationsController + /// + /// The name. + /// + /// + /// The id of the created macro + /// + [HttpPost] + public ActionResult Create(string name) { - private readonly ParameterEditorCollection _parameterEditorCollection; - private readonly IMacroService _macroService; - private readonly IShortStringHelper _shortStringHelper; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly ILogger _logger; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly IUmbracoMapper _umbracoMapper; - - public MacrosController( - ParameterEditorCollection parameterEditorCollection, - IMacroService macroService, - IShortStringHelper shortStringHelper, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - ILogger logger, - IHostingEnvironment hostingEnvironment, - IUmbracoMapper umbracoMapper) + if (string.IsNullOrWhiteSpace(name)) { - _parameterEditorCollection = parameterEditorCollection ?? throw new ArgumentNullException(nameof(parameterEditorCollection)); - _macroService = macroService ?? throw new ArgumentNullException(nameof(macroService)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + return ValidationProblem("Name can not be empty"); } + var alias = name.ToSafeAlias(_shortStringHelper); - /// - /// Creates a new macro - /// - /// - /// The name. - /// - /// - /// The id of the created macro - /// - [HttpPost] - public ActionResult Create(string name) + if (_macroService.GetByAlias(alias) != null) { - if (string.IsNullOrWhiteSpace(name)) - { - return ValidationProblem("Name can not be empty"); - } + return ValidationProblem("Macro with this alias already exists"); + } - var alias = name.ToSafeAlias(_shortStringHelper); + if (name == null || name.Length > 255) + { + return ValidationProblem("Name cannnot be more than 255 characters in length."); + } - if (_macroService.GetByAlias(alias) != null) + try + { + var macro = new Macro(_shortStringHelper) { Alias = alias, Name = name, MacroSource = string.Empty }; + + _macroService.Save(macro, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + return macro.Id; + } + catch (Exception exception) + { + const string errorMessage = "Error creating macro"; + _logger.LogError(exception, errorMessage); + return ValidationProblem(errorMessage); + } + } + + [HttpGet] + public ActionResult GetById(int id) + { + IMacro? macro = _macroService.GetById(id); + + if (macro == null) + { + return ValidationProblem($"Macro with id {id} does not exist"); + } + + MacroDisplay? macroDisplay = MapToDisplay(macro); + + return macroDisplay; + } + + [HttpGet] + public ActionResult GetById(Guid id) + { + IMacro? macro = _macroService.GetById(id); + + if (macro == null) + { + return ValidationProblem($"Macro with id {id} does not exist"); + } + + MacroDisplay? macroDisplay = MapToDisplay(macro); + + return macroDisplay; + } + + [HttpGet] + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) + { + return ValidationProblem($"Macro with id {id} does not exist"); + } + + IMacro? macro = _macroService.GetById(guidUdi.Guid); + if (macro == null) + { + return ValidationProblem($"Macro with id {id} does not exist"); + } + + MacroDisplay? macroDisplay = MapToDisplay(macro); + + return macroDisplay; + } + + [HttpPost] + public IActionResult DeleteById(int id) + { + IMacro? macro = _macroService.GetById(id); + + if (macro == null) + { + return ValidationProblem($"Macro with id {id} does not exist"); + } + + _macroService.Delete(macro); + + return Ok(); + } + + [HttpPost] + public ActionResult Save(MacroDisplay macroDisplay) + { + if (macroDisplay == null) + { + return ValidationProblem("No macro data found in request"); + } + + if (macroDisplay.Name == null || macroDisplay.Name.Length > 255) + { + return ValidationProblem("Name cannnot be more than 255 characters in length."); + } + + IMacro? macro = macroDisplay.Id is null + ? null + : _macroService.GetById(int.Parse(macroDisplay.Id.ToString()!, CultureInfo.InvariantCulture)); + + if (macro == null) + { + return ValidationProblem($"Macro with id {macroDisplay.Id} does not exist"); + } + + if (macroDisplay.Alias != macro.Alias) + { + IMacro? macroByAlias = _macroService.GetByAlias(macroDisplay.Alias); + + if (macroByAlias != null) { return ValidationProblem("Macro with this alias already exists"); } - - if (name == null || name.Length > 255) - { - return ValidationProblem("Name cannnot be more than 255 characters in length."); - } - - try - { - var macro = new Macro(_shortStringHelper) - { - Alias = alias, - Name = name, - MacroSource = string.Empty - }; - - _macroService.Save(macro, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - return macro.Id; - } - catch (Exception exception) - { - const string errorMessage = "Error creating macro"; - _logger.LogError(exception, errorMessage); - return ValidationProblem(errorMessage); - } } - [HttpGet] - public ActionResult GetById(int id) + macro.Alias = macroDisplay.Alias; + macro.Name = macroDisplay.Name; + macro.CacheByMember = macroDisplay.CacheByUser; + macro.CacheByPage = macroDisplay.CacheByPage; + macro.CacheDuration = macroDisplay.CachePeriod; + macro.DontRender = !macroDisplay.RenderInEditor; + macro.UseInEditor = macroDisplay.UseInEditor; + macro.MacroSource = macroDisplay.View; + macro.Properties.ReplaceAll( + macroDisplay.Parameters.Select((x, i) => new MacroProperty(x.Key, x.Label, i, x.Editor))); + + try { - var macro = _macroService.GetById(id); + _macroService.Save(macro, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - if (macro == null) - { - return ValidationProblem($"Macro with id {id} does not exist"); - } + macroDisplay.Notifications.Clear(); - var macroDisplay = MapToDisplay(macro); + macroDisplay.Notifications.Add(new BackOfficeNotification("Success", "Macro saved", NotificationStyle.Success)); return macroDisplay; } - - [HttpGet] - public ActionResult GetById(Guid id) + catch (Exception exception) { - var macro = _macroService.GetById(id); - - if (macro == null) - { - return ValidationProblem($"Macro with id {id} does not exist"); - } - - var macroDisplay = MapToDisplay(macro); - - return macroDisplay; - } - - [HttpGet] - public ActionResult GetById(Udi id) - { - var guidUdi = id as GuidUdi; - if (guidUdi == null) - return ValidationProblem($"Macro with id {id} does not exist"); - - var macro = _macroService.GetById(guidUdi.Guid); - if (macro == null) - { - return ValidationProblem($"Macro with id {id} does not exist"); - } - - var macroDisplay = MapToDisplay(macro); - - return macroDisplay; - } - - [HttpPost] - public IActionResult DeleteById(int id) - { - var macro = _macroService.GetById(id); - - if (macro == null) - { - return ValidationProblem($"Macro with id {id} does not exist"); - } - - _macroService.Delete(macro); - - return Ok(); - } - - [HttpPost] - public ActionResult Save(MacroDisplay macroDisplay) - { - if (macroDisplay == null) - { - return ValidationProblem("No macro data found in request"); - } - - if (macroDisplay.Name == null || macroDisplay.Name.Length > 255) - { - return ValidationProblem("Name cannnot be more than 255 characters in length."); - } - - var macro = macroDisplay.Id is null ? null : _macroService.GetById(int.Parse(macroDisplay.Id.ToString()!, CultureInfo.InvariantCulture)); - - if (macro == null) - { - return ValidationProblem($"Macro with id {macroDisplay.Id} does not exist"); - } - - if (macroDisplay.Alias != macro.Alias) - { - var macroByAlias = _macroService.GetByAlias(macroDisplay.Alias); - - if (macroByAlias != null) - { - return ValidationProblem("Macro with this alias already exists"); - } - } - - macro.Alias = macroDisplay.Alias; - macro.Name = macroDisplay.Name; - macro.CacheByMember = macroDisplay.CacheByUser; - macro.CacheByPage = macroDisplay.CacheByPage; - macro.CacheDuration = macroDisplay.CachePeriod; - macro.DontRender = !macroDisplay.RenderInEditor; - macro.UseInEditor = macroDisplay.UseInEditor; - macro.MacroSource = macroDisplay.View; - macro.Properties.ReplaceAll(macroDisplay.Parameters.Select((x,i) => new MacroProperty(x.Key, x.Label, i, x.Editor))); - - try - { - _macroService.Save(macro, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - macroDisplay.Notifications.Clear(); - - macroDisplay.Notifications.Add(new BackOfficeNotification("Success", "Macro saved", NotificationStyle.Success)); - - return macroDisplay; - } - catch (Exception exception) - { - const string errorMessage = "Error creating macro"; - _logger.LogError(exception, errorMessage); - return ValidationProblem(errorMessage); - } - } - - /// - /// Gets a list of available macro partials - /// - /// - /// The . - /// - public IEnumerable GetPartialViews() - { - var views = new List(); - - views.AddRange(this.FindPartialViewsFiles()); - - return views; - } - - /// - /// Gets the available parameter editors - /// - /// - /// The . - /// - public ParameterEditorCollection GetParameterEditors() - { - return _parameterEditorCollection; - } - - /// - /// Gets the available parameter editors grouped by their group. - /// - /// - /// The . - /// - public IDictionary> GetGroupedParameterEditors() - { - var parameterEditors = _parameterEditorCollection.ToArray(); - - var grouped = parameterEditors - .GroupBy(x => x.Group.IsNullOrWhiteSpace() ? "" : x.Group.ToLower()) - .OrderBy(x => x.Key) - .ToDictionary(group => group.Key, group => group.OrderBy(d => d.Name).AsEnumerable()); - - return grouped; - } - - /// - /// Get parameter editor by alias. - /// - /// - /// The . - /// - public IDataEditor? GetParameterEditorByAlias(string alias) - { - var parameterEditors = _parameterEditorCollection.ToArray(); - - var parameterEditor = parameterEditors.FirstOrDefault(x => x.Alias.InvariantEquals(alias)); - - return parameterEditor; - } - - - - /// - /// Finds all the macro partials - /// - /// - /// The . - /// - private IEnumerable FindPartialViewsFiles() - { - var files = new List(); - - files.AddRange(this.FindPartialViewFilesInViewsFolder()); - files.AddRange(this.FindPartialViewFilesInPluginFolders()); - - return files; - } - - /// - /// Finds all macro partials in the views folder - /// - /// - /// The . - /// - private IEnumerable FindPartialViewFilesInViewsFolder() - { - // TODO: This is inconsistent. We have FileSystems.MacroPartialsFileSystem but we basically don't use - // that at all except to render the tree. In the future we may want to use it. This also means that - // we are storing the virtual path of the macro like ~/Views/MacroPartials/Login.cshtml instead of the - // relative path which would work with the FileSystems.MacroPartialsFileSystem, but these are incompatible. - // At some point this should all be made consistent and probably just use FileSystems.MacroPartialsFileSystem. - - var partialsDir = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.MacroPartials); - - return this.FindPartialViewFilesInFolder( - partialsDir, - partialsDir, - Constants.SystemDirectories.MacroPartials); - } - - /// - /// Finds partial view files in app plugin folders. - /// - /// - /// - private IEnumerable FindPartialViewFilesInPluginFolders() - { - var files = new List(); - - var appPluginsFolder = new DirectoryInfo(_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.AppPlugins)); - - if (!appPluginsFolder.Exists) - { - return files; - } - - foreach (var directory in appPluginsFolder.GetDirectories()) - { - var viewsFolder = directory.GetDirectories("Views"); - if (viewsFolder.Any()) - { - var macroPartials = viewsFolder.First().GetDirectories("MacroPartials"); - if (macroPartials.Any()) - { - files.AddRange(this.FindPartialViewFilesInFolder(macroPartials.First().FullName, macroPartials.First().FullName, Constants.SystemDirectories.AppPlugins + "/" + directory.Name + "/Views/MacroPartials")); - } - } - } - - return files; - } - - /// - /// Finds all partial views in a folder and subfolders - /// - /// - /// The org path. - /// - /// - /// The path. - /// - /// - /// The prefix virtual path. - /// - /// - /// The . - /// - private IEnumerable FindPartialViewFilesInFolder(string orgPath, string path, string prefixVirtualPath) - { - var files = new List(); - var dirInfo = new DirectoryInfo(path); - - if (dirInfo.Exists) - { - foreach (var dir in dirInfo.GetDirectories()) - { - files.AddRange(this.FindPartialViewFilesInFolder(orgPath, path + "/" + dir.Name, prefixVirtualPath)); - } - - var fileInfo = dirInfo.GetFiles("*.*"); - - files.AddRange( - fileInfo.Select(file => - prefixVirtualPath.TrimEnd(Constants.CharArrays.ForwardSlash) + "/" + (path.Replace(orgPath, string.Empty).Trim(Constants.CharArrays.ForwardSlash) + "/" + file.Name).Trim(Constants.CharArrays.ForwardSlash))); - - } - - return files; - } - - /// - /// Used to map an instance to a - /// - /// - /// - private MacroDisplay? MapToDisplay(IMacro macro) - { - var display = _umbracoMapper.Map(macro); - - var parameters = macro.Properties.Values - .OrderBy(x => x.SortOrder) - .Select(x => new MacroParameterDisplay() - { - Editor = x.EditorAlias, - Key = x.Alias, - Label = x.Name, - Id = x.Id - }); - - if (display is not null) - { - display.Parameters = parameters; - } - - return display; + const string errorMessage = "Error creating macro"; + _logger.LogError(exception, errorMessage); + return ValidationProblem(errorMessage); } } + + /// + /// Gets a list of available macro partials + /// + /// + /// The . + /// + public IEnumerable GetPartialViews() + { + var views = new List(); + + views.AddRange(FindPartialViewsFiles()); + + return views; + } + + /// + /// Gets the available parameter editors + /// + /// + /// The . + /// + public ParameterEditorCollection GetParameterEditors() => _parameterEditorCollection; + + /// + /// Gets the available parameter editors grouped by their group. + /// + /// + /// The . + /// + public IDictionary> GetGroupedParameterEditors() + { + IDataEditor[] parameterEditors = _parameterEditorCollection.ToArray(); + + var grouped = parameterEditors + .GroupBy(x => x.Group.IsNullOrWhiteSpace() ? string.Empty : x.Group.ToLower()) + .OrderBy(x => x.Key) + .ToDictionary(group => group.Key, group => group.OrderBy(d => d.Name).AsEnumerable()); + + return grouped; + } + + /// + /// Get parameter editor by alias. + /// + /// + /// The . + /// + public IDataEditor? GetParameterEditorByAlias(string alias) + { + IDataEditor[] parameterEditors = _parameterEditorCollection.ToArray(); + + IDataEditor? parameterEditor = parameterEditors.FirstOrDefault(x => x.Alias.InvariantEquals(alias)); + + return parameterEditor; + } + + + /// + /// Finds all the macro partials + /// + /// + /// The . + /// + private IEnumerable FindPartialViewsFiles() + { + var files = new List(); + + files.AddRange(FindPartialViewFilesInViewsFolder()); + files.AddRange(FindPartialViewFilesInPluginFolders()); + + return files; + } + + /// + /// Finds all macro partials in the views folder + /// + /// + /// The . + /// + private IEnumerable FindPartialViewFilesInViewsFolder() + { + // TODO: This is inconsistent. We have FileSystems.MacroPartialsFileSystem but we basically don't use + // that at all except to render the tree. In the future we may want to use it. This also means that + // we are storing the virtual path of the macro like ~/Views/MacroPartials/Login.cshtml instead of the + // relative path which would work with the FileSystems.MacroPartialsFileSystem, but these are incompatible. + // At some point this should all be made consistent and probably just use FileSystems.MacroPartialsFileSystem. + + var partialsDir = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.MacroPartials); + + return FindPartialViewFilesInFolder( + partialsDir, + partialsDir, + Constants.SystemDirectories.MacroPartials); + } + + /// + /// Finds partial view files in app plugin folders. + /// + /// + /// + private IEnumerable FindPartialViewFilesInPluginFolders() + { + var files = new List(); + + var appPluginsFolder = + new DirectoryInfo(_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.AppPlugins)); + + if (!appPluginsFolder.Exists) + { + return files; + } + + foreach (DirectoryInfo directory in appPluginsFolder.GetDirectories()) + { + DirectoryInfo[] viewsFolder = directory.GetDirectories("Views"); + if (viewsFolder.Any()) + { + DirectoryInfo[] macroPartials = viewsFolder.First().GetDirectories("MacroPartials"); + if (macroPartials.Any()) + { + files.AddRange(FindPartialViewFilesInFolder( + macroPartials.First().FullName, + macroPartials.First().FullName, + Constants.SystemDirectories.AppPlugins + "/" + directory.Name + "/Views/MacroPartials")); + } + } + } + + return files; + } + + /// + /// Finds all partial views in a folder and subfolders + /// + /// + /// The org path. + /// + /// + /// The path. + /// + /// + /// The prefix virtual path. + /// + /// + /// The . + /// + private IEnumerable FindPartialViewFilesInFolder(string orgPath, string path, string prefixVirtualPath) + { + var files = new List(); + var dirInfo = new DirectoryInfo(path); + + if (dirInfo.Exists) + { + foreach (DirectoryInfo dir in dirInfo.GetDirectories()) + { + files.AddRange(FindPartialViewFilesInFolder(orgPath, path + "/" + dir.Name, prefixVirtualPath)); + } + + FileInfo[] fileInfo = dirInfo.GetFiles("*.*"); + + files.AddRange( + fileInfo.Select(file => + prefixVirtualPath.TrimEnd(Constants.CharArrays.ForwardSlash) + "/" + + (path.Replace(orgPath, string.Empty).Trim(Constants.CharArrays.ForwardSlash) + "/" + file.Name) + .Trim(Constants.CharArrays.ForwardSlash))); + } + + return files; + } + + /// + /// Used to map an instance to a + /// + /// + /// + private MacroDisplay? MapToDisplay(IMacro macro) + { + MacroDisplay? display = _umbracoMapper.Map(macro); + + IEnumerable parameters = macro.Properties.Values + .OrderBy(x => x.SortOrder) + .Select(x => new MacroParameterDisplay { Editor = x.EditorAlias, Key = x.Alias, Label = x.Name, Id = x.Id }); + + if (display is not null) + { + display.Parameters = parameters; + } + + return display; + } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs index ce0bb9846b..50c54f420f 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs @@ -1,17 +1,13 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.IO; -using System.Linq; using System.Net.Mime; using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; @@ -24,6 +20,7 @@ using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Validation; using Umbraco.Cms.Core.Persistence.Querying; @@ -40,1088 +37,1145 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// This controller is decorated with the UmbracoApplicationAuthorizeAttribute which means that any user requesting +/// access to ALL of the methods on this controller will need access to the media application. +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessMedia)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +[ParameterSwapControllerActionSelector(nameof(GetChildren), "id", typeof(int), typeof(Guid), typeof(Udi))] +public class MediaController : ContentControllerBase { - /// - /// This controller is decorated with the UmbracoApplicationAuthorizeAttribute which means that any user requesting - /// access to ALL of the methods on this controller will need access to the media application. - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessMedia)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - [ParameterSwapControllerActionSelector(nameof(GetChildren), "id", typeof(int), typeof(Guid), typeof(Udi))] - public class MediaController : ContentControllerBase + private readonly AppCaches _appCaches; + private readonly IAuthorizationService _authorizationService; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly ContentSettings _contentSettings; + private readonly IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider; + private readonly IDataTypeService _dataTypeService; + private readonly IEntityService _entityService; + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly ILocalizedTextService _localizedTextService; + private readonly ILogger _logger; + private readonly IMediaService _mediaService; + private readonly IMediaTypeService _mediaTypeService; + private readonly IRelationService _relationService; + private readonly IShortStringHelper _shortStringHelper; + private readonly ISqlContext _sqlContext; + private readonly IUmbracoMapper _umbracoMapper; + + public MediaController( + ICultureDictionary cultureDictionary, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper, + IEventMessagesFactory eventMessages, + ILocalizedTextService localizedTextService, + IOptionsSnapshot contentSettings, + IMediaTypeService mediaTypeService, + IMediaService mediaService, + IEntityService entityService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IUmbracoMapper umbracoMapper, + IDataTypeService dataTypeService, + ISqlContext sqlContext, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + IRelationService relationService, + PropertyEditorCollection propertyEditors, + MediaFileManager mediaFileManager, + MediaUrlGeneratorCollection mediaUrlGenerators, + IHostingEnvironment hostingEnvironment, + IImageUrlGenerator imageUrlGenerator, + IJsonSerializer serializer, + IAuthorizationService authorizationService, + AppCaches appCaches) + : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, serializer) { - private readonly IShortStringHelper _shortStringHelper; - private readonly ContentSettings _contentSettings; - private readonly IMediaTypeService _mediaTypeService; - private readonly IMediaService _mediaService; - private readonly IEntityService _entityService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IDataTypeService _dataTypeService; - private readonly ILocalizedTextService _localizedTextService; - private readonly ISqlContext _sqlContext; - private readonly IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider; - private readonly IRelationService _relationService; - private readonly IImageUrlGenerator _imageUrlGenerator; - private readonly IAuthorizationService _authorizationService; - private readonly AppCaches _appCaches; - private readonly ILogger _logger; + _shortStringHelper = shortStringHelper; + _contentSettings = contentSettings.Value; + _mediaTypeService = mediaTypeService; + _mediaService = mediaService; + _entityService = entityService; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _umbracoMapper = umbracoMapper; + _dataTypeService = dataTypeService; + _localizedTextService = localizedTextService; + _sqlContext = sqlContext; + _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider; + _relationService = relationService; + _propertyEditors = propertyEditors; + _mediaFileManager = mediaFileManager; + _mediaUrlGenerators = mediaUrlGenerators; + _hostingEnvironment = hostingEnvironment; + _logger = loggerFactory.CreateLogger(); + _imageUrlGenerator = imageUrlGenerator; + _authorizationService = authorizationService; + _appCaches = appCaches; + } - public MediaController( - ICultureDictionary cultureDictionary, - ILoggerFactory loggerFactory, - IShortStringHelper shortStringHelper, - IEventMessagesFactory eventMessages, - ILocalizedTextService localizedTextService, - IOptionsSnapshot contentSettings, - IMediaTypeService mediaTypeService, - IMediaService mediaService, - IEntityService entityService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IUmbracoMapper umbracoMapper, - IDataTypeService dataTypeService, - ISqlContext sqlContext, - IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, - IRelationService relationService, - PropertyEditorCollection propertyEditors, - MediaFileManager mediaFileManager, - MediaUrlGeneratorCollection mediaUrlGenerators, - IHostingEnvironment hostingEnvironment, - IImageUrlGenerator imageUrlGenerator, - IJsonSerializer serializer, - IAuthorizationService authorizationService, - AppCaches appCaches) - : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, serializer) + /// + /// Gets an empty content item for the + /// + /// + /// + /// + [OutgoingEditorModelEvent] + public ActionResult GetEmpty(string contentTypeAlias, int parentId) + { + IMediaType? contentType = _mediaTypeService.Get(contentTypeAlias); + if (contentType == null) { - _shortStringHelper = shortStringHelper; - _contentSettings = contentSettings.Value; - _mediaTypeService = mediaTypeService; - _mediaService = mediaService; - _entityService = entityService; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _umbracoMapper = umbracoMapper; - _dataTypeService = dataTypeService; - _localizedTextService = localizedTextService; - _sqlContext = sqlContext; - _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider; - _relationService = relationService; - _propertyEditors = propertyEditors; - _mediaFileManager = mediaFileManager; - _mediaUrlGenerators = mediaUrlGenerators; - _hostingEnvironment = hostingEnvironment; - _logger = loggerFactory.CreateLogger(); - _imageUrlGenerator = imageUrlGenerator; - _authorizationService = authorizationService; - _appCaches = appCaches; - } - - /// - /// Gets an empty content item for the - /// - /// - /// - /// - [OutgoingEditorModelEvent] - public ActionResult GetEmpty(string contentTypeAlias, int parentId) - { - var contentType = _mediaTypeService.Get(contentTypeAlias); - if (contentType == null) - { - return NotFound(); - } - - var emptyContent = _mediaService.CreateMedia("", parentId, contentType.Alias, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - var mapped = _umbracoMapper.Map(emptyContent); - - if (mapped is not null) - { - //remove the listview app if it exists - mapped.ContentApps = mapped.ContentApps.Where(x => x.Alias != "umbListView").ToList(); - } - - return mapped; - } - - /// - /// Returns an item to be used to display the recycle bin for media - /// - /// - public MediaItemDisplay GetRecycleBin() - { - var apps = new List(); - apps.Add(ListViewContentAppFactory.CreateContentApp(_dataTypeService, _propertyEditors, "recycleBin", "media", Constants.DataTypes.DefaultMediaListView)); - apps[0].Active = true; - var display = new MediaItemDisplay - { - Id = Constants.System.RecycleBinMedia, - Alias = "recycleBin", - ParentId = -1, - Name = _localizedTextService.Localize("general", "recycleBin"), - ContentTypeAlias = "recycleBin", - CreateDate = DateTime.Now, - IsContainer = true, - Path = "-1," + Constants.System.RecycleBinMedia, - ContentApps = apps - }; - - return display; - } - - /// - /// Gets the media item by id - /// - /// - /// - [OutgoingEditorModelEvent] - [Authorize(Policy = AuthorizationPolicies.MediaPermissionPathById)] - public MediaItemDisplay? GetById(int id) - { - var foundMedia = GetObjectFromRequest(() => _mediaService.GetById(id)); - - if (foundMedia == null) - { - HandleContentNotFound(id); - //HandleContentNotFound will throw an exception - return null; - } - return _umbracoMapper.Map(foundMedia); - } - - /// - /// Gets the media item by id - /// - /// - /// - [OutgoingEditorModelEvent] - [Authorize(Policy = AuthorizationPolicies.MediaPermissionPathById)] - public MediaItemDisplay? GetById(Guid id) - { - var foundMedia = GetObjectFromRequest(() => _mediaService.GetById(id)); - - if (foundMedia == null) - { - HandleContentNotFound(id); - //HandleContentNotFound will throw an exception - return null; - } - return _umbracoMapper.Map(foundMedia); - } - - /// - /// Gets the media item by id - /// - /// - /// - [OutgoingEditorModelEvent] - [Authorize(Policy = AuthorizationPolicies.MediaPermissionPathById)] - public ActionResult GetById(Udi id) - { - var guidUdi = id as GuidUdi; - if (guidUdi != null) - { - return GetById(guidUdi.Guid); - } - return NotFound(); } - /// - /// Return media for the specified ids - /// - /// - /// - [FilterAllowedOutgoingMedia(typeof(IEnumerable))] - public IEnumerable GetByIds([FromQuery] int[] ids) + IMedia emptyContent = _mediaService.CreateMedia("", parentId, contentType.Alias, + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + MediaItemDisplay? mapped = _umbracoMapper.Map(emptyContent); + + if (mapped is not null) { - var foundMedia = _mediaService.GetByIds(ids); - return foundMedia.Select(media => _umbracoMapper.Map(media)); + //remove the listview app if it exists + mapped.ContentApps = mapped.ContentApps.Where(x => x.Alias != "umbListView").ToList(); } - /// - /// Returns a paged result of media items known to be of a "Folder" type - /// - /// - /// - /// - /// - public PagedResult> GetChildFolders(int id, int pageNumber = 1, int pageSize = 1000) + return mapped; + } + + /// + /// Returns an item to be used to display the recycle bin for media + /// + /// + public MediaItemDisplay GetRecycleBin() + { + var apps = new List { - //Suggested convention for folder mediatypes - we can make this more or less complicated as long as we document it... - //if you create a media type, which has an alias that ends with ...Folder then its a folder: ex: "secureFolder", "bannerFolder", "Folder" - var folderTypes = _mediaTypeService - .GetAll() - .Where(x => x.Alias.EndsWith("Folder")) - .Select(x => x.Id) - .ToArray(); + ListViewContentAppFactory.CreateContentApp(_dataTypeService, _propertyEditors, "recycleBin", "media", + Constants.DataTypes.DefaultMediaListView) + }; + apps[0].Active = true; + var display = new MediaItemDisplay + { + Id = Constants.System.RecycleBinMedia, + Alias = "recycleBin", + ParentId = -1, + Name = _localizedTextService.Localize("general", "recycleBin"), + ContentTypeAlias = "recycleBin", + CreateDate = DateTime.Now, + IsContainer = true, + Path = "-1," + Constants.System.RecycleBinMedia, + ContentApps = apps + }; - if (folderTypes.Length == 0) - { - return new PagedResult>(0, pageNumber, pageSize); - } + return display; + } - long total; - var children = _mediaService.GetPagedChildren(id, pageNumber - 1, pageSize, out total, - //lookup these content types - _sqlContext.Query().Where(x => folderTypes.Contains(x.ContentTypeId)), - Ordering.By("Name", Direction.Ascending)); + /// + /// Gets the media item by id + /// + /// + /// + [OutgoingEditorModelEvent] + [Authorize(Policy = AuthorizationPolicies.MediaPermissionPathById)] + public MediaItemDisplay? GetById(int id) + { + IMedia? foundMedia = GetObjectFromRequest(() => _mediaService.GetById(id)); - return new PagedResult>(total, pageNumber, pageSize) - { - Items = children.Select(_umbracoMapper.Map>).WhereNotNull() - }; + if (foundMedia == null) + { + HandleContentNotFound(id); + //HandleContentNotFound will throw an exception + return null; } - /// - /// Returns the root media objects - /// - [FilterAllowedOutgoingMedia(typeof(IEnumerable>))] - public IEnumerable> GetRootMedia() - { - // TODO: Add permissions check! + return _umbracoMapper.Map(foundMedia); + } - return _mediaService.GetRootMedia()? - .Select(_umbracoMapper.Map>).WhereNotNull() ?? Enumerable.Empty>(); + /// + /// Gets the media item by id + /// + /// + /// + [OutgoingEditorModelEvent] + [Authorize(Policy = AuthorizationPolicies.MediaPermissionPathById)] + public MediaItemDisplay? GetById(Guid id) + { + IMedia? foundMedia = GetObjectFromRequest(() => _mediaService.GetById(id)); + + if (foundMedia == null) + { + HandleContentNotFound(id); + //HandleContentNotFound will throw an exception + return null; } - #region GetChildren + return _umbracoMapper.Map(foundMedia); + } - private int[]? _userStartNodes; - private readonly PropertyEditorCollection _propertyEditors; - private readonly MediaFileManager _mediaFileManager; - private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; - private readonly IHostingEnvironment _hostingEnvironment; - - - protected int[] UserStartNodes + /// + /// Gets the media item by id + /// + /// + /// + [OutgoingEditorModelEvent] + [Authorize(Policy = AuthorizationPolicies.MediaPermissionPathById)] + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi != null) { - get { return _userStartNodes ??= _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds(_entityService, _appCaches) ?? Array.Empty(); } + return GetById(guidUdi.Guid); } - /// - /// Returns the child media objects - using the entity INT id - /// - [FilterAllowedOutgoingMedia(typeof(IEnumerable>), "Items")] - public PagedResult> GetChildren(int id, - int pageNumber = 0, - int pageSize = 0, - string orderBy = "SortOrder", - Direction orderDirection = Direction.Ascending, - bool orderBySystemField = true, - string filter = "") + return NotFound(); + } + + /// + /// Return media for the specified ids + /// + /// + /// + [FilterAllowedOutgoingMedia(typeof(IEnumerable))] + public IEnumerable GetByIds([FromQuery] int[] ids) + { + IEnumerable foundMedia = _mediaService.GetByIds(ids); + return foundMedia.Select(media => _umbracoMapper.Map(media)); + } + + /// + /// Returns a paged result of media items known to be of a "Folder" type + /// + /// + /// + /// + /// + public PagedResult> GetChildFolders(int id, int pageNumber = 1, + int pageSize = 1000) + { + //Suggested convention for folder mediatypes - we can make this more or less complicated as long as we document it... + //if you create a media type, which has an alias that ends with ...Folder then its a folder: ex: "secureFolder", "bannerFolder", "Folder" + var folderTypes = _mediaTypeService + .GetAll() + .Where(x => x.Alias.EndsWith("Folder")) + .Select(x => x.Id) + .ToArray(); + + if (folderTypes.Length == 0) { - //if a request is made for the root node data but the user's start node is not the default, then - // we need to return their start nodes - if (id == Constants.System.Root && UserStartNodes.Length > 0 && UserStartNodes.Contains(Constants.System.Root) == false) - { - if (pageNumber > 0) - return new PagedResult>(0, 0, 0); - var nodes = _mediaService.GetByIds(UserStartNodes).ToArray(); - if (nodes.Length == 0) - return new PagedResult>(0, 0, 0); - if (pageSize < nodes.Length) - pageSize = nodes.Length; // bah - var pr = new PagedResult>(nodes.Length, pageNumber, pageSize) - { - Items = nodes.Select(_umbracoMapper.Map>).WhereNotNull() - }; - return pr; - } - - // else proceed as usual - - long totalChildren; - List children; - if (pageNumber > 0 && pageSize > 0) - { - IQuery? queryFilter = null; - if (filter.IsNullOrWhiteSpace() == false) - { - //add the default text filter - queryFilter = _sqlContext.Query() - .Where(x => x.Name != null) - .Where(x => x.Name!.Contains(filter)); - } - - children = _mediaService - .GetPagedChildren( - id, (pageNumber - 1), pageSize, - out totalChildren, - queryFilter, - Ordering.By(orderBy, orderDirection, isCustomField: !orderBySystemField)).ToList(); - } - else - { - //better to not use this without paging where possible, currently only the sort dialog does - children = _mediaService.GetPagedChildren(id, 0, int.MaxValue, out var total).ToList(); - totalChildren = children.Count; - } - - if (totalChildren == 0) - { - return new PagedResult>(0, 0, 0); - } - - var pagedResult = new PagedResult>(totalChildren, pageNumber, pageSize); - pagedResult.Items = children - .Select(_umbracoMapper.Map>).WhereNotNull(); - - return pagedResult; + return new PagedResult>(0, pageNumber, pageSize); } - /// - /// Returns the child media objects - using the entity GUID id - /// - /// - /// - /// - /// - /// - /// - /// - /// - [FilterAllowedOutgoingMedia(typeof(IEnumerable>), "Items")] - public ActionResult>> GetChildren(Guid id, - int pageNumber = 0, - int pageSize = 0, - string orderBy = "SortOrder", - Direction orderDirection = Direction.Ascending, - bool orderBySystemField = true, - string filter = "") - { - var entity = _entityService.Get(id); - if (entity != null) - { - return GetChildren(entity.Id, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter); - } + IEnumerable children = _mediaService.GetPagedChildren(id, pageNumber - 1, pageSize, out long total, + //lookup these content types + _sqlContext.Query().Where(x => folderTypes.Contains(x.ContentTypeId)), + Ordering.By("Name")); - return NotFound(); + return new PagedResult>(total, pageNumber, pageSize) + { + Items = children.Select(_umbracoMapper.Map>) + .WhereNotNull() + }; + } + + /// + /// Returns the root media objects + /// + [FilterAllowedOutgoingMedia(typeof(IEnumerable>))] + public IEnumerable> GetRootMedia() => + // TODO: Add permissions check! + _mediaService.GetRootMedia()? + .Select(_umbracoMapper.Map>).WhereNotNull() ?? + Enumerable.Empty>(); + + /// + /// Moves an item to the recycle bin, if it is already there then it will permanently delete it + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.MediaPermissionPathById)] + [HttpPost] + public IActionResult DeleteById(int id) + { + IMedia? foundMedia = GetObjectFromRequest(() => _mediaService.GetById(id)); + + if (foundMedia == null) + { + return HandleContentNotFound(id); } - /// - /// Returns the child media objects - using the entity UDI id - /// - /// - /// - /// - /// - /// - /// - /// - /// - [FilterAllowedOutgoingMedia(typeof(IEnumerable>), "Items")] - public ActionResult>> GetChildren(Udi id, - int pageNumber = 0, - int pageSize = 0, - string orderBy = "SortOrder", - Direction orderDirection = Direction.Ascending, - bool orderBySystemField = true, - string filter = "") + //if the current item is in the recycle bin + if (foundMedia.Trashed == false) { - var guidUdi = id as GuidUdi; - if (guidUdi != null) - { - var entity = _entityService.Get(guidUdi.Guid); - if (entity != null) - { - return GetChildren(entity.Id, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter); - } - } - - return NotFound(); - } - - #endregion - - /// - /// Moves an item to the recycle bin, if it is already there then it will permanently delete it - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.MediaPermissionPathById)] - [HttpPost] - public IActionResult DeleteById(int id) - { - var foundMedia = GetObjectFromRequest(() => _mediaService.GetById(id)); - - if (foundMedia == null) - { - return HandleContentNotFound(id); - } - - //if the current item is in the recycle bin - if (foundMedia.Trashed == false) - { - var moveResult = _mediaService.MoveToRecycleBin(foundMedia, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - if (moveResult == false) - { - return ValidationProblem(); - } - } - else - { - var deleteResult = _mediaService.Delete(foundMedia, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - if (deleteResult == false) - { - return ValidationProblem(); - } - } - - return Ok(); - } - - /// - /// Change the sort order for media - /// - /// - /// - public async Task PostMove(MoveOrCopy move) - { - // Authorize... - var requirement = new MediaPermissionsResourceRequirement(); - var authorizationResult = await _authorizationService.AuthorizeAsync(User, new MediaPermissionsResource(_mediaService.GetById(move.Id)), requirement); - if (!authorizationResult.Succeeded) - { - return Forbid(); - } - - var toMoveResult = ValidateMoveOrCopy(move); - var toMove = toMoveResult.Value; - if (toMove is null && toMoveResult is IConvertToActionResult convertToActionResult) - { - return convertToActionResult.Convert(); - } - - var destinationParentID = move.ParentId; - var sourceParentID = toMove?.ParentId; - - var moveResult = toMove is null ? false : _mediaService.Move(toMove, move.ParentId, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - if (sourceParentID == destinationParentID) - { - return ValidationProblem(new SimpleNotificationModel(new BackOfficeNotification("", _localizedTextService.Localize("media", "moveToSameFolderFailed"), NotificationStyle.Error))); - } + Attempt moveResult = _mediaService.MoveToRecycleBin(foundMedia, + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); if (moveResult == false) { return ValidationProblem(); } - else + } + else + { + Attempt deleteResult = _mediaService.Delete(foundMedia, + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + if (deleteResult == false) { - return Content(toMove!.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); + return ValidationProblem(); } } - /// - /// Saves content - /// - /// - [FileUploadCleanupFilter] - [MediaItemSaveValidation] - [OutgoingEditorModelEvent] - public ActionResult? PostSave( - [ModelBinder(typeof(MediaItemBinder))] - MediaItemSave contentItem) + return Ok(); + } + + /// + /// Change the sort order for media + /// + /// + /// + public async Task PostMove(MoveOrCopy move) + { + // Authorize... + var requirement = new MediaPermissionsResourceRequirement(); + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeAsync(User, + new MediaPermissionsResource(_mediaService.GetById(move.Id)), requirement); + if (!authorizationResult.Succeeded) { - //Recent versions of IE/Edge may send in the full client side file path instead of just the file name. - //To ensure similar behavior across all browsers no matter what they do - we strip the FileName property of all - //uploaded files to being *only* the actual file name (as it should be). - if (contentItem.UploadedFiles != null && contentItem.UploadedFiles.Any()) - { - foreach (var file in contentItem.UploadedFiles) - { - file.FileName = Path.GetFileName(file.FileName); - } - } - - //If we've reached here it means: - // * Our model has been bound - // * and validated - // * any file attachments have been saved to their temporary location for us to use - // * we have a reference to the DTO object and the persisted object - // * Permissions are valid - - //Don't update the name if it is empty - if (contentItem.Name.IsNullOrWhiteSpace() == false && contentItem.PersistedContent is not null) - { - contentItem.PersistedContent.Name = contentItem.Name; - } - - MapPropertyValuesForPersistence( - contentItem, - contentItem.PropertyCollectionDto, - (save, property) => property?.GetValue(), //get prop val - (save, property, v) => property?.SetValue(v), //set prop val - null); // media are all invariant - - //we will continue to save if model state is invalid, however we cannot save if critical data is missing. - //TODO: Allowing media to be saved when it is invalid is odd - media doesn't have a publish phase so suddenly invalid data is allowed to be 'live' - if (!ModelState.IsValid) - { - //check for critical data validation issues, we can't continue saving if this data is invalid - if (!RequiredForPersistenceAttribute.HasRequiredValuesForPersistence(contentItem)) - { - //ok, so the absolute mandatory data is invalid and it's new, we cannot actually continue! - // add the model state to the outgoing object and throw validation response - MediaItemDisplay? forDisplay = _umbracoMapper.Map(contentItem.PersistedContent); - return ValidationProblem(forDisplay, ModelState); - } - } - - if (contentItem.PersistedContent is null) - { - return null; - } - - //save the item - var saveStatus = _mediaService.Save(contentItem.PersistedContent, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - //return the updated model - var display = _umbracoMapper.Map(contentItem.PersistedContent); - - //lastly, if it is not valid, add the model state to the outgoing object and throw a 403 - if (!ModelState.IsValid) - { - return ValidationProblem(display, ModelState, StatusCodes.Status403Forbidden); - } - - //put the correct msgs in - switch (contentItem.Action) - { - case ContentSaveAction.Save: - case ContentSaveAction.SaveNew: - if (saveStatus.Success) - { - display?.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles", "editMediaSaved"), - _localizedTextService.Localize("speechBubbles", "editMediaSavedText")); - } - else - { - AddCancelMessage(display); - - //If the item is new and the operation was cancelled, we need to return a different - // status code so the UI can handle it since it won't be able to redirect since there - // is no Id to redirect to! - if (saveStatus.Result?.Result == OperationResultType.FailedCancelledByEvent && IsCreatingAction(contentItem.Action)) - { - return ValidationProblem(display); - } - } - - break; - } - - return display; + return Forbid(); } - /// - /// Empties the recycle bin - /// - /// - [HttpDelete] - [HttpPost] - public IActionResult EmptyRecycleBin() + ActionResult toMoveResult = ValidateMoveOrCopy(move); + IMedia? toMove = toMoveResult.Value; + if (toMove is null && toMoveResult is IConvertToActionResult convertToActionResult) { - _mediaService.EmptyRecycleBin(_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - return Ok(_localizedTextService.Localize("defaultdialogs", "recycleBinIsEmpty")); + return convertToActionResult.Convert(); } - /// - /// Change the sort order for media - /// - /// - /// - public async Task PostSort(ContentSortOrder sorted) + var destinationParentID = move.ParentId; + var sourceParentID = toMove?.ParentId; + + var moveResult = toMove is null + ? false + : _mediaService.Move(toMove, move.ParentId, + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + if (sourceParentID == destinationParentID) { - if (sorted == null) - { - return NotFound(); - } + return ValidationProblem(new SimpleNotificationModel(new BackOfficeNotification("", + _localizedTextService.Localize("media", "moveToSameFolderFailed"), NotificationStyle.Error))); + } - //if there's nothing to sort just return ok - if (sorted.IdSortOrder?.Length == 0) - { - return Ok(); - } + if (moveResult == false) + { + return ValidationProblem(); + } - // Authorize... - var requirement = new MediaPermissionsResourceRequirement(); - var resource = new MediaPermissionsResource(sorted.ParentId); - var authorizationResult = await _authorizationService.AuthorizeAsync(User, resource, requirement); - if (!authorizationResult.Succeeded) - { - return Forbid(); - } + return Content(toMove!.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); + } - var sortedMedia = new List(); - try + /// + /// Saves content + /// + /// + [FileUploadCleanupFilter] + [MediaItemSaveValidation] + [OutgoingEditorModelEvent] + public ActionResult? PostSave( + [ModelBinder(typeof(MediaItemBinder))] MediaItemSave contentItem) + { + //Recent versions of IE/Edge may send in the full client side file path instead of just the file name. + //To ensure similar behavior across all browsers no matter what they do - we strip the FileName property of all + //uploaded files to being *only* the actual file name (as it should be). + if (contentItem.UploadedFiles != null && contentItem.UploadedFiles.Any()) + { + foreach (ContentPropertyFile file in contentItem.UploadedFiles) { - sortedMedia.AddRange(sorted.IdSortOrder?.Select(_mediaService.GetById).WhereNotNull() ?? Enumerable.Empty()); - - // Save Media with new sort order and update content xml in db accordingly - if (_mediaService.Sort(sortedMedia) == false) - { - _logger.LogWarning("Media sorting failed, this was probably caused by an event being cancelled"); - return ValidationProblem("Media sorting failed, this was probably caused by an event being cancelled"); - } - return Ok(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not update media sort order"); - throw; + file.FileName = Path.GetFileName(file.FileName); } } - public async Task> PostAddFolder(PostedFolder folder) + //If we've reached here it means: + // * Our model has been bound + // * and validated + // * any file attachments have been saved to their temporary location for us to use + // * we have a reference to the DTO object and the persisted object + // * Permissions are valid + + //Don't update the name if it is empty + if (contentItem.Name.IsNullOrWhiteSpace() == false && contentItem.PersistedContent is not null) { - var parentIdResult = await GetParentIdAsIntAsync(folder.ParentId, validatePermissions: true); - if (!(parentIdResult?.Result is null)) - { - return new ActionResult(parentIdResult.Result); - } - - var parentId = parentIdResult?.Value; - if (!parentId.HasValue) - { - return NotFound("The passed id doesn't exist"); - } - - var isFolderAllowed = IsFolderCreationAllowedHere(parentId.Value); - if (isFolderAllowed == false) - { - return ValidationProblem(_localizedTextService.Localize("speechBubbles", "folderCreationNotAllowed")); - } - - var f = _mediaService.CreateMedia(folder.Name, parentId.Value, Constants.Conventions.MediaTypes.Folder); - _mediaService.Save(f, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - return _umbracoMapper.Map(f); + contentItem.PersistedContent.Name = contentItem.Name; } - /// - /// Used to submit a media file - /// - /// - /// - /// We cannot validate this request with attributes (nicely) due to the nature of the multi-part for data. - /// - public async Task PostAddFile([FromForm] string path, [FromForm] string currentFolder, [FromForm] string contentTypeAlias, List file) + MapPropertyValuesForPersistence( + contentItem, + contentItem.PropertyCollectionDto, + (save, property) => property?.GetValue(), //get prop val + (save, property, v) => property?.SetValue(v), //set prop val + null); // media are all invariant + + //we will continue to save if model state is invalid, however we cannot save if critical data is missing. + //TODO: Allowing media to be saved when it is invalid is odd - media doesn't have a publish phase so suddenly invalid data is allowed to be 'live' + if (!ModelState.IsValid) { - var root = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); - //ensure it exists - Directory.CreateDirectory(root); - - //must have a file - if (file.Count == 0) + //check for critical data validation issues, we can't continue saving if this data is invalid + if (!RequiredForPersistenceAttribute.HasRequiredValuesForPersistence(contentItem)) { - return NotFound(); + //ok, so the absolute mandatory data is invalid and it's new, we cannot actually continue! + // add the model state to the outgoing object and throw validation response + MediaItemDisplay? forDisplay = _umbracoMapper.Map(contentItem.PersistedContent); + return ValidationProblem(forDisplay, ModelState); } - - //get the string json from the request - var parentIdResult = await GetParentIdAsIntAsync(currentFolder, validatePermissions: true); - if (!(parentIdResult?.Result is null)) - { - return parentIdResult.Result; - } - - var parentId = parentIdResult?.Value; - if (!parentId.HasValue) - { - return NotFound("The passed id doesn't exist"); - } - - var tempFiles = new PostedFiles(); - - //in case we pass a path with a folder in it, we will create it and upload media to it. - if (!string.IsNullOrEmpty(path)) - { - if (!IsFolderCreationAllowedHere(parentId.Value)) - { - AddCancelMessage(tempFiles, _localizedTextService.Localize("speechBubbles", "folderUploadNotAllowed")); - return Ok(tempFiles); - } - - var folders = path.Split(Constants.CharArrays.ForwardSlash); - - for (int i = 0; i < folders.Length - 1; i++) - { - var folderName = folders[i]; - IMedia? folderMediaItem; - - //if uploading directly to media root and not a subfolder - if (parentId == Constants.System.Root) - { - //look for matching folder - folderMediaItem = - _mediaService.GetRootMedia()?.FirstOrDefault(x => x.Name == folderName && x.ContentType.Alias == Constants.Conventions.MediaTypes.Folder); - if (folderMediaItem == null) - { - //if null, create a folder - folderMediaItem = _mediaService.CreateMedia(folderName, -1, Constants.Conventions.MediaTypes.Folder); - _mediaService.Save(folderMediaItem); - } - } - else - { - //get current parent - var mediaRoot = _mediaService.GetById(parentId.Value); - - //if the media root is null, something went wrong, we'll abort - if (mediaRoot == null) - return Problem( - "The folder: " + folderName + " could not be used for storing images, its ID: " + parentId + - " returned null"); - - //look for matching folder - folderMediaItem = FindInChildren(mediaRoot.Id, folderName, Constants.Conventions.MediaTypes.Folder); - - if (folderMediaItem == null) - { - //if null, create a folder - folderMediaItem = _mediaService.CreateMedia(folderName, mediaRoot, Constants.Conventions.MediaTypes.Folder); - _mediaService.Save(folderMediaItem); - } - } - - //set the media root to the folder id so uploaded files will end there. - parentId = folderMediaItem.Id; - } - } - - var mediaTypeAlias = string.Empty; - var allMediaTypes = _mediaTypeService.GetAll().ToList(); - var allowedContentTypes = new HashSet(); - - if (parentId != Constants.System.Root) - { - var mediaFolderItem = _mediaService.GetById(parentId.Value); - var mediaFolderType = allMediaTypes.FirstOrDefault(x => x.Alias == mediaFolderItem?.ContentType.Alias); - - if (mediaFolderType != null) - { - IMediaType? mediaTypeItem = null; - - if (mediaFolderType.AllowedContentTypes is not null) - { - foreach (ContentTypeSort allowedContentType in mediaFolderType.AllowedContentTypes) - { - IMediaType? checkMediaTypeItem = allMediaTypes.FirstOrDefault(x => x.Id == allowedContentType.Id.Value); - if (checkMediaTypeItem is not null) - { - allowedContentTypes.Add(checkMediaTypeItem); - } - - var fileProperty = checkMediaTypeItem?.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == Constants.Conventions.Media.File); - if (fileProperty != null) - { - mediaTypeItem = checkMediaTypeItem; - } - } - } - - //Only set the permission-based mediaType if we only allow 1 specific file under this parent. - if (allowedContentTypes.Count == 1 && mediaTypeItem != null) - { - mediaTypeAlias = mediaTypeItem.Alias; - } - } - } - else - { - var typesAllowedAtRoot = allMediaTypes.Where(x => x.AllowedAsRoot).ToList(); - allowedContentTypes.UnionWith(typesAllowedAtRoot); - } - - //get the files - foreach (var formFile in file) - { - var fileName = formFile.FileName.Trim(Constants.CharArrays.DoubleQuote).TrimEnd(); - var safeFileName = fileName.ToSafeFileName(ShortStringHelper); - var ext = safeFileName.Substring(safeFileName.LastIndexOf('.') + 1).ToLowerInvariant(); - - if (!_contentSettings.IsFileAllowedForUpload(ext)) - { - tempFiles.Notifications.Add(new BackOfficeNotification( - _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), - _localizedTextService.Localize("media", "disallowedFileType"), - NotificationStyle.Warning)); - continue; - } - - if (string.IsNullOrEmpty(mediaTypeAlias)) - { - mediaTypeAlias = Constants.Conventions.MediaTypes.File; - - if (contentTypeAlias == Constants.Conventions.MediaTypes.AutoSelect) - { - // Look up MediaTypes - foreach (var mediaTypeItem in allMediaTypes) - { - var fileProperty = mediaTypeItem.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == Constants.Conventions.Media.File); - if (fileProperty == null) - { - continue; - } - - var dataTypeKey = fileProperty.DataTypeKey; - var dataType = _dataTypeService.GetDataType(dataTypeKey); - - if (dataType == null || dataType.Configuration is not IFileExtensionsConfig fileExtensionsConfig) - { - continue; - } - - var fileExtensions = fileExtensionsConfig.FileExtensions; - if (fileExtensions == null || fileExtensions.All(x => x.Value != ext)) - { - continue; - } - - mediaTypeAlias = mediaTypeItem.Alias; - break; - } - - // If media type is still File then let's check if it's an image. - if (mediaTypeAlias == Constants.Conventions.MediaTypes.File && _imageUrlGenerator.IsSupportedImageFormat(ext)) - { - mediaTypeAlias = Constants.Conventions.MediaTypes.Image; - } - } - else - { - mediaTypeAlias = contentTypeAlias; - } - } - - if (allowedContentTypes.Any(x => x.Alias == mediaTypeAlias) == false) - { - tempFiles.Notifications.Add(new BackOfficeNotification( - _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), - _localizedTextService.Localize("media", "disallowedMediaType", new[] { mediaTypeAlias }), - NotificationStyle.Warning)); - continue; - } - - var mediaItemName = fileName.ToFriendlyName(); - - var createdMediaItem = _mediaService.CreateMedia(mediaItemName, parentId.Value, mediaTypeAlias, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - await using (var stream = formFile.OpenReadStream()) - { - createdMediaItem.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, fileName, stream); - } - - var saveResult = _mediaService.Save(createdMediaItem, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - if (saveResult == false) - { - AddCancelMessage(tempFiles, _localizedTextService.Localize("speechBubbles", "operationCancelledText") + " -- " + mediaItemName); - } - } - - //Different response if this is a 'blueimp' request - if (HttpContext.Request.Query.Any(x => x.Key == "origin")) - { - var origin = HttpContext.Request.Query.First(x => x.Key == "origin"); - if (origin.Value == "blueimp") - { - return new JsonResult(tempFiles); //Don't output the angular xsrf stuff, blue imp doesn't like that - } - } - - return Ok(tempFiles); } - private bool IsFolderCreationAllowedHere(int parentId) + if (contentItem.PersistedContent is null) { - var allMediaTypes = _mediaTypeService.GetAll().ToList(); - var isFolderAllowed = false; - if (parentId == Constants.System.Root) - { - var typesAllowedAtRoot = allMediaTypes.Where(ct => ct.AllowedAsRoot).ToList(); - isFolderAllowed = typesAllowedAtRoot.Any(x => x.Alias == Constants.Conventions.MediaTypes.Folder); - } - else - { - var parentMediaType = _mediaService.GetById(parentId); - var mediaFolderType = allMediaTypes.FirstOrDefault(x => x.Alias == parentMediaType?.ContentType.Alias); - if (mediaFolderType != null) - { - isFolderAllowed = - mediaFolderType.AllowedContentTypes?.Any(x => x.Alias == Constants.Conventions.MediaTypes.Folder) ?? false; - } - } - - return isFolderAllowed; - } - - private IMedia? FindInChildren(int mediaId, string nameToFind, string contentTypeAlias) - { - const int pageSize = 500; - var page = 0; - var total = long.MaxValue; - while (page * pageSize < total) - { - var children = _mediaService.GetPagedChildren(mediaId, page++, pageSize, out total, - _sqlContext.Query().Where(x => x.Name == nameToFind)); - var match = children.FirstOrDefault(c => c.ContentType.Alias == contentTypeAlias); - if (match != null) - { - return match; - } - } return null; } - /// - /// Given a parent id which could be a GUID, UDI or an INT, this will resolve the INT - /// - /// - /// - /// If true, this will check if the current user has access to the resolved integer parent id - /// and if that check fails an unauthorized exception will occur - /// - /// - private async Task?> GetParentIdAsIntAsync(string? parentId, bool validatePermissions) - { - int intParentId; + //save the item + Attempt saveStatus = _mediaService.Save(contentItem.PersistedContent, + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - // test for udi - if (UdiParser.TryParse(parentId, out GuidUdi? parentUdi)) + //return the updated model + MediaItemDisplay? display = _umbracoMapper.Map(contentItem.PersistedContent); + + //lastly, if it is not valid, add the model state to the outgoing object and throw a 403 + if (!ModelState.IsValid) + { + return ValidationProblem(display, ModelState, StatusCodes.Status403Forbidden); + } + + //put the correct msgs in + switch (contentItem.Action) + { + case ContentSaveAction.Save: + case ContentSaveAction.SaveNew: + if (saveStatus.Success) + { + display?.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "editMediaSaved"), + _localizedTextService.Localize("speechBubbles", "editMediaSavedText")); + } + else + { + AddCancelMessage(display); + + //If the item is new and the operation was cancelled, we need to return a different + // status code so the UI can handle it since it won't be able to redirect since there + // is no Id to redirect to! + if (saveStatus.Result?.Result == OperationResultType.FailedCancelledByEvent && + IsCreatingAction(contentItem.Action)) + { + return ValidationProblem(display); + } + } + + break; + } + + return display; + } + + /// + /// Empties the recycle bin + /// + /// + [HttpDelete] + [HttpPost] + public IActionResult EmptyRecycleBin() + { + _mediaService.EmptyRecycleBin(_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + return Ok(_localizedTextService.Localize("defaultdialogs", "recycleBinIsEmpty")); + } + + /// + /// Change the sort order for media + /// + /// + /// + public async Task PostSort(ContentSortOrder sorted) + { + if (sorted == null) + { + return NotFound(); + } + + //if there's nothing to sort just return ok + if (sorted.IdSortOrder?.Length == 0) + { + return Ok(); + } + + // Authorize... + var requirement = new MediaPermissionsResourceRequirement(); + var resource = new MediaPermissionsResource(sorted.ParentId); + AuthorizationResult authorizationResult = + await _authorizationService.AuthorizeAsync(User, resource, requirement); + if (!authorizationResult.Succeeded) + { + return Forbid(); + } + + var sortedMedia = new List(); + try + { + sortedMedia.AddRange(sorted.IdSortOrder?.Select(_mediaService.GetById).WhereNotNull() ?? + Enumerable.Empty()); + + // Save Media with new sort order and update content xml in db accordingly + if (_mediaService.Sort(sortedMedia) == false) { - parentId = parentUdi?.Guid.ToString(); + _logger.LogWarning("Media sorting failed, this was probably caused by an event being cancelled"); + return ValidationProblem("Media sorting failed, this was probably caused by an event being cancelled"); } - //if it's not an INT then we'll check for GUID - if (int.TryParse(parentId, NumberStyles.Integer, CultureInfo.InvariantCulture, out intParentId) == false) + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not update media sort order"); + throw; + } + } + + public async Task> PostAddFolder(PostedFolder folder) + { + ActionResult? parentIdResult = await GetParentIdAsIntAsync(folder.ParentId, true); + if (!(parentIdResult?.Result is null)) + { + return new ActionResult(parentIdResult.Result); + } + + var parentId = parentIdResult?.Value; + if (!parentId.HasValue) + { + return NotFound("The passed id doesn't exist"); + } + + var isFolderAllowed = IsFolderCreationAllowedHere(parentId.Value); + if (isFolderAllowed == false) + { + return ValidationProblem(_localizedTextService.Localize("speechBubbles", "folderCreationNotAllowed")); + } + + IMedia f = _mediaService.CreateMedia(folder.Name, parentId.Value, Constants.Conventions.MediaTypes.Folder); + _mediaService.Save(f, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + return _umbracoMapper.Map(f); + } + + /// + /// Used to submit a media file + /// + /// + /// + /// We cannot validate this request with attributes (nicely) due to the nature of the multi-part for data. + /// + public async Task PostAddFile([FromForm] string path, [FromForm] string currentFolder, + [FromForm] string contentTypeAlias, List file) + { + var root = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); + //ensure it exists + Directory.CreateDirectory(root); + + //must have a file + if (file.Count == 0) + { + return NotFound(); + } + + //get the string json from the request + ActionResult? parentIdResult = await GetParentIdAsIntAsync(currentFolder, true); + if (!(parentIdResult?.Result is null)) + { + return parentIdResult.Result; + } + + var parentId = parentIdResult?.Value; + if (!parentId.HasValue) + { + return NotFound("The passed id doesn't exist"); + } + + var tempFiles = new PostedFiles(); + + //in case we pass a path with a folder in it, we will create it and upload media to it. + if (!string.IsNullOrEmpty(path)) + { + if (!IsFolderCreationAllowedHere(parentId.Value)) { - // if a guid then try to look up the entity - Guid idGuid; - if (Guid.TryParse(parentId, out idGuid)) + AddCancelMessage(tempFiles, _localizedTextService.Localize("speechBubbles", "folderUploadNotAllowed")); + return Ok(tempFiles); + } + + var folders = path.Split(Constants.CharArrays.ForwardSlash); + + for (var i = 0; i < folders.Length - 1; i++) + { + var folderName = folders[i]; + IMedia? folderMediaItem; + + //if uploading directly to media root and not a subfolder + if (parentId == Constants.System.Root) { - var entity = _entityService.Get(idGuid); - if (entity != null) + //look for matching folder + folderMediaItem = + _mediaService.GetRootMedia()?.FirstOrDefault(x => + x.Name == folderName && x.ContentType.Alias == Constants.Conventions.MediaTypes.Folder); + if (folderMediaItem == null) { - intParentId = entity.Id; - } - else - { - return null; + //if null, create a folder + folderMediaItem = + _mediaService.CreateMedia(folderName, -1, Constants.Conventions.MediaTypes.Folder); + _mediaService.Save(folderMediaItem); } } else { - return ValidationProblem("The request was not formatted correctly, the parentId is not an integer, Guid or UDI"); - } - } + //get current parent + IMedia? mediaRoot = _mediaService.GetById(parentId.Value); - // Authorize... - //ensure the user has access to this folder by parent id! - if (validatePermissions) - { - var requirement = new MediaPermissionsResourceRequirement(); - var authorizationResult = await _authorizationService.AuthorizeAsync(User, new MediaPermissionsResource(_mediaService.GetById(intParentId)), requirement); - if (!authorizationResult.Succeeded) - { - return ValidationProblem( - new SimpleNotificationModel(new BackOfficeNotification( - _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), - _localizedTextService.Localize("speechBubbles", "invalidUserPermissionsText"), - NotificationStyle.Warning)), - StatusCodes.Status403Forbidden); - } - } + //if the media root is null, something went wrong, we'll abort + if (mediaRoot == null) + { + return Problem( + "The folder: " + folderName + " could not be used for storing images, its ID: " + parentId + + " returned null"); + } - return intParentId; + //look for matching folder + folderMediaItem = FindInChildren(mediaRoot.Id, folderName, Constants.Conventions.MediaTypes.Folder); + + if (folderMediaItem == null) + { + //if null, create a folder + folderMediaItem = _mediaService.CreateMedia(folderName, mediaRoot, + Constants.Conventions.MediaTypes.Folder); + _mediaService.Save(folderMediaItem); + } + } + + //set the media root to the folder id so uploaded files will end there. + parentId = folderMediaItem.Id; + } } - /// - /// Ensures the item can be moved/copied to the new location - /// - /// - /// - private ActionResult ValidateMoveOrCopy(MoveOrCopy model) + var mediaTypeAlias = string.Empty; + var allMediaTypes = _mediaTypeService.GetAll().ToList(); + var allowedContentTypes = new HashSet(); + + if (parentId != Constants.System.Root) { - if (model == null) - { - return NotFound(); - } + IMedia? mediaFolderItem = _mediaService.GetById(parentId.Value); + IMediaType? mediaFolderType = + allMediaTypes.FirstOrDefault(x => x.Alias == mediaFolderItem?.ContentType.Alias); + if (mediaFolderType != null) + { + IMediaType? mediaTypeItem = null; - var toMove = _mediaService.GetById(model.Id); - if (toMove == null) - { - return NotFound(); - } - if (model.ParentId < 0) - { - //cannot move if the content item is not allowed at the root unless there are - //none allowed at root (in which case all should be allowed at root) - var mediaTypeService = _mediaTypeService; - if (toMove.ContentType.AllowedAsRoot == false && mediaTypeService.GetAll().Any(ct => ct.AllowedAsRoot)) + if (mediaFolderType.AllowedContentTypes is not null) { - var notificationModel = new SimpleNotificationModel(); - notificationModel.AddErrorNotification(_localizedTextService.Localize("moveOrCopy", "notAllowedAtRoot"), ""); - return ValidationProblem(notificationModel); + foreach (ContentTypeSort allowedContentType in mediaFolderType.AllowedContentTypes) + { + IMediaType? checkMediaTypeItem = + allMediaTypes.FirstOrDefault(x => x.Id == allowedContentType.Id.Value); + if (checkMediaTypeItem is not null) + { + allowedContentTypes.Add(checkMediaTypeItem); + } + + IPropertyType? fileProperty = + checkMediaTypeItem?.CompositionPropertyTypes.FirstOrDefault(x => + x.Alias == Constants.Conventions.Media.File); + if (fileProperty != null) + { + mediaTypeItem = checkMediaTypeItem; + } + } + } + + //Only set the permission-based mediaType if we only allow 1 specific file under this parent. + if (allowedContentTypes.Count == 1 && mediaTypeItem != null) + { + mediaTypeAlias = mediaTypeItem.Alias; + } + } + } + else + { + var typesAllowedAtRoot = allMediaTypes.Where(x => x.AllowedAsRoot).ToList(); + allowedContentTypes.UnionWith(typesAllowedAtRoot); + } + + //get the files + foreach (IFormFile formFile in file) + { + var fileName = formFile.FileName.Trim(Constants.CharArrays.DoubleQuote).TrimEnd(); + var safeFileName = fileName.ToSafeFileName(ShortStringHelper); + var ext = safeFileName[(safeFileName.LastIndexOf('.') + 1)..].ToLowerInvariant(); + + if (!_contentSettings.IsFileAllowedForUpload(ext)) + { + tempFiles.Notifications.Add(new BackOfficeNotification( + _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), + _localizedTextService.Localize("media", "disallowedFileType"), + NotificationStyle.Warning)); + continue; + } + + if (string.IsNullOrEmpty(mediaTypeAlias)) + { + mediaTypeAlias = Constants.Conventions.MediaTypes.File; + + if (contentTypeAlias == Constants.Conventions.MediaTypes.AutoSelect) + { + // Look up MediaTypes + foreach (IMediaType mediaTypeItem in allMediaTypes) + { + IPropertyType? fileProperty = + mediaTypeItem.CompositionPropertyTypes.FirstOrDefault(x => + x.Alias == Constants.Conventions.Media.File); + if (fileProperty == null) + { + continue; + } + + Guid dataTypeKey = fileProperty.DataTypeKey; + IDataType? dataType = _dataTypeService.GetDataType(dataTypeKey); + + if (dataType == null || + dataType.Configuration is not IFileExtensionsConfig fileExtensionsConfig) + { + continue; + } + + List? fileExtensions = fileExtensionsConfig.FileExtensions; + if (fileExtensions == null || fileExtensions.All(x => x.Value != ext)) + { + continue; + } + + mediaTypeAlias = mediaTypeItem.Alias; + break; + } + + // If media type is still File then let's check if it's an image. + if (mediaTypeAlias == Constants.Conventions.MediaTypes.File && + _imageUrlGenerator.IsSupportedImageFormat(ext)) + { + mediaTypeAlias = Constants.Conventions.MediaTypes.Image; + } + } + else + { + mediaTypeAlias = contentTypeAlias; + } + } + + if (allowedContentTypes.Any(x => x.Alias == mediaTypeAlias) == false) + { + tempFiles.Notifications.Add(new BackOfficeNotification( + _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), + _localizedTextService.Localize("media", "disallowedMediaType", new[] { mediaTypeAlias }), + NotificationStyle.Warning)); + continue; + } + + var mediaItemName = fileName.ToFriendlyName(); + + IMedia createdMediaItem = _mediaService.CreateMedia(mediaItemName, parentId.Value, mediaTypeAlias, + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + await using (Stream stream = formFile.OpenReadStream()) + { + createdMediaItem.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, + _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, fileName, stream); + } + + Attempt saveResult = _mediaService.Save(createdMediaItem, + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + if (saveResult == false) + { + AddCancelMessage(tempFiles, + _localizedTextService.Localize("speechBubbles", "operationCancelledText") + " -- " + mediaItemName); + } + } + + //Different response if this is a 'blueimp' request + if (HttpContext.Request.Query.Any(x => x.Key == "origin")) + { + KeyValuePair origin = HttpContext.Request.Query.First(x => x.Key == "origin"); + if (origin.Value == "blueimp") + { + return new JsonResult(tempFiles); //Don't output the angular xsrf stuff, blue imp doesn't like that + } + } + + return Ok(tempFiles); + } + + private bool IsFolderCreationAllowedHere(int parentId) + { + var allMediaTypes = _mediaTypeService.GetAll().ToList(); + var isFolderAllowed = false; + if (parentId == Constants.System.Root) + { + var typesAllowedAtRoot = allMediaTypes.Where(ct => ct.AllowedAsRoot).ToList(); + isFolderAllowed = typesAllowedAtRoot.Any(x => x.Alias == Constants.Conventions.MediaTypes.Folder); + } + else + { + IMedia? parentMediaType = _mediaService.GetById(parentId); + IMediaType? mediaFolderType = + allMediaTypes.FirstOrDefault(x => x.Alias == parentMediaType?.ContentType.Alias); + if (mediaFolderType != null) + { + isFolderAllowed = + mediaFolderType.AllowedContentTypes?.Any(x => x.Alias == Constants.Conventions.MediaTypes.Folder) ?? + false; + } + } + + return isFolderAllowed; + } + + private IMedia? FindInChildren(int mediaId, string nameToFind, string contentTypeAlias) + { + const int pageSize = 500; + var page = 0; + var total = long.MaxValue; + while (page * pageSize < total) + { + IEnumerable children = _mediaService.GetPagedChildren(mediaId, page++, pageSize, out total, + _sqlContext.Query().Where(x => x.Name == nameToFind)); + IMedia? match = children.FirstOrDefault(c => c.ContentType.Alias == contentTypeAlias); + if (match != null) + { + return match; + } + } + + return null; + } + + /// + /// Given a parent id which could be a GUID, UDI or an INT, this will resolve the INT + /// + /// + /// + /// If true, this will check if the current user has access to the resolved integer parent id + /// and if that check fails an unauthorized exception will occur + /// + /// + private async Task?> GetParentIdAsIntAsync(string? parentId, bool validatePermissions) + { + + // test for udi + if (UdiParser.TryParse(parentId, out GuidUdi? parentUdi)) + { + parentId = parentUdi?.Guid.ToString(); + } + + //if it's not an INT then we'll check for GUID + if (int.TryParse(parentId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int intParentId) == false) + { + // if a guid then try to look up the entity + if (Guid.TryParse(parentId, out Guid idGuid)) + { + IEntitySlim? entity = _entityService.Get(idGuid); + if (entity != null) + { + intParentId = entity.Id; + } + else + { + return null; } } else { - var parent = _mediaService.GetById(model.ParentId); - if (parent == null) - { - return NotFound(); - } - - //check if the item is allowed under this one - var parentContentType = _mediaTypeService.Get(parent.ContentTypeId); - if (parentContentType?.AllowedContentTypes?.Select(x => x.Id).ToArray() - .Any(x => x.Value == toMove.ContentType.Id) == false) - { - var notificationModel = new SimpleNotificationModel(); - notificationModel.AddErrorNotification(_localizedTextService.Localize("moveOrCopy", "notAllowedByContentType"), ""); - return ValidationProblem(notificationModel); - } - - // Check on paths - if ((string.Format(",{0},", parent.Path)).IndexOf(string.Format(",{0},", toMove.Id), StringComparison.Ordinal) > -1) - { - var notificationModel = new SimpleNotificationModel(); - notificationModel.AddErrorNotification(_localizedTextService.Localize("moveOrCopy", "notAllowedByPath"), ""); - return ValidationProblem(notificationModel); - } + return ValidationProblem( + "The request was not formatted correctly, the parentId is not an integer, Guid or UDI"); } - - return new ActionResult(toMove); } - [Obsolete("Please use TrackedReferencesController.GetPagedRelationsForItem() instead. Scheduled for removal in V11.")] - public PagedResult GetPagedReferences(int id, string entityType, int pageNumber = 1, int pageSize = 100) + // Authorize... + //ensure the user has access to this folder by parent id! + if (validatePermissions) { - if (pageNumber <= 0 || pageSize <= 0) + var requirement = new MediaPermissionsResourceRequirement(); + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeAsync(User, + new MediaPermissionsResource(_mediaService.GetById(intParentId)), requirement); + if (!authorizationResult.Succeeded) { - throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); + return ValidationProblem( + new SimpleNotificationModel(new BackOfficeNotification( + _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), + _localizedTextService.Localize("speechBubbles", "invalidUserPermissionsText"), + NotificationStyle.Warning)), + StatusCodes.Status403Forbidden); + } + } + + return intParentId; + } + + /// + /// Ensures the item can be moved/copied to the new location + /// + /// + /// + private ActionResult ValidateMoveOrCopy(MoveOrCopy model) + { + if (model == null) + { + return NotFound(); + } + + + IMedia? toMove = _mediaService.GetById(model.Id); + if (toMove == null) + { + return NotFound(); + } + + if (model.ParentId < 0) + { + //cannot move if the content item is not allowed at the root unless there are + //none allowed at root (in which case all should be allowed at root) + IMediaTypeService mediaTypeService = _mediaTypeService; + if (toMove.ContentType.AllowedAsRoot == false && mediaTypeService.GetAll().Any(ct => ct.AllowedAsRoot)) + { + var notificationModel = new SimpleNotificationModel(); + notificationModel.AddErrorNotification(_localizedTextService.Localize("moveOrCopy", "notAllowedAtRoot"), + ""); + return ValidationProblem(notificationModel); + } + } + else + { + IMedia? parent = _mediaService.GetById(model.ParentId); + if (parent == null) + { + return NotFound(); } - var objectType = ObjectTypes.GetUmbracoObjectType(entityType); - var udiType = ObjectTypes.GetUdiType(objectType); - - var relations = _relationService.GetPagedParentEntitiesByChildId(id, pageNumber - 1, pageSize, out var totalRecords, objectType); - - return new PagedResult(totalRecords, pageNumber, pageSize) + //check if the item is allowed under this one + IMediaType? parentContentType = _mediaTypeService.Get(parent.ContentTypeId); + if (parentContentType?.AllowedContentTypes?.Select(x => x.Id).ToArray() + .Any(x => x.Value == toMove.ContentType.Id) == false) { - Items = relations.Cast().Select(rel => new EntityBasic - { - Id = rel.Id, - Key = rel.Key, - Udi = Udi.Create(udiType, rel.Key), - Icon = rel.ContentTypeIcon, - Name = rel.Name, - Alias = rel.ContentTypeAlias - }) - }; + var notificationModel = new SimpleNotificationModel(); + notificationModel.AddErrorNotification( + _localizedTextService.Localize("moveOrCopy", "notAllowedByContentType"), ""); + return ValidationProblem(notificationModel); + } + + // Check on paths + if (string.Format(",{0},", parent.Path) + .IndexOf(string.Format(",{0},", toMove.Id), StringComparison.Ordinal) > -1) + { + var notificationModel = new SimpleNotificationModel(); + notificationModel.AddErrorNotification(_localizedTextService.Localize("moveOrCopy", "notAllowedByPath"), + ""); + return ValidationProblem(notificationModel); + } } + + return new ActionResult(toMove); } + + [Obsolete( + "Please use TrackedReferencesController.GetPagedRelationsForItem() instead. Scheduled for removal in V11.")] + public PagedResult GetPagedReferences(int id, string entityType, int pageNumber = 1, + int pageSize = 100) + { + if (pageNumber <= 0 || pageSize <= 0) + { + throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); + } + + UmbracoObjectTypes objectType = ObjectTypes.GetUmbracoObjectType(entityType); + var udiType = objectType.GetUdiType(); + + IEnumerable relations = + _relationService.GetPagedParentEntitiesByChildId(id, pageNumber - 1, pageSize, out var totalRecords, + objectType); + + return new PagedResult(totalRecords, pageNumber, pageSize) + { + Items = relations.Cast().Select(rel => new EntityBasic + { + Id = rel.Id, + Key = rel.Key, + Udi = Udi.Create(udiType, rel.Key), + Icon = rel.ContentTypeIcon, + Name = rel.Name, + Alias = rel.ContentTypeAlias + }) + }; + } + + #region GetChildren + + private int[]? _userStartNodes; + private readonly PropertyEditorCollection _propertyEditors; + private readonly MediaFileManager _mediaFileManager; + private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; + private readonly IHostingEnvironment _hostingEnvironment; + + + protected int[] UserStartNodes => _userStartNodes ??= + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds(_entityService, + _appCaches) ?? Array.Empty(); + + /// + /// Returns the child media objects - using the entity INT id + /// + [FilterAllowedOutgoingMedia(typeof(IEnumerable>), "Items")] + public PagedResult> GetChildren(int id, + int pageNumber = 0, + int pageSize = 0, + string orderBy = "SortOrder", + Direction orderDirection = Direction.Ascending, + bool orderBySystemField = true, + string filter = "") + { + //if a request is made for the root node data but the user's start node is not the default, then + // we need to return their start nodes + if (id == Constants.System.Root && UserStartNodes.Length > 0 && + UserStartNodes.Contains(Constants.System.Root) == false) + { + if (pageNumber > 0) + { + return new PagedResult>(0, 0, 0); + } + + IMedia[] nodes = _mediaService.GetByIds(UserStartNodes).ToArray(); + if (nodes.Length == 0) + { + return new PagedResult>(0, 0, 0); + } + + if (pageSize < nodes.Length) + { + pageSize = nodes.Length; // bah + } + + var pr = new PagedResult>(nodes.Length, pageNumber, pageSize) + { + Items = nodes.Select(_umbracoMapper.Map>) + .WhereNotNull() + }; + return pr; + } + + // else proceed as usual + + long totalChildren; + List children; + if (pageNumber > 0 && pageSize > 0) + { + IQuery? queryFilter = null; + if (filter.IsNullOrWhiteSpace() == false) + { + //add the default text filter + queryFilter = _sqlContext.Query() + .Where(x => x.Name != null) + .Where(x => x.Name!.Contains(filter)); + } + + children = _mediaService + .GetPagedChildren( + id, pageNumber - 1, pageSize, + out totalChildren, + queryFilter, + Ordering.By(orderBy, orderDirection, isCustomField: !orderBySystemField)).ToList(); + } + else + { + //better to not use this without paging where possible, currently only the sort dialog does + children = _mediaService.GetPagedChildren(id, 0, int.MaxValue, out var total).ToList(); + totalChildren = children.Count; + } + + if (totalChildren == 0) + { + return new PagedResult>(0, 0, 0); + } + + var pagedResult = new PagedResult>(totalChildren, pageNumber, pageSize) + { + Items = children + .Select(_umbracoMapper.Map>).WhereNotNull() + }; + + return pagedResult; + } + + /// + /// Returns the child media objects - using the entity GUID id + /// + /// + /// + /// + /// + /// + /// + /// + /// + [FilterAllowedOutgoingMedia(typeof(IEnumerable>), "Items")] + public ActionResult>> GetChildren(Guid id, + int pageNumber = 0, + int pageSize = 0, + string orderBy = "SortOrder", + Direction orderDirection = Direction.Ascending, + bool orderBySystemField = true, + string filter = "") + { + IEntitySlim? entity = _entityService.Get(id); + if (entity != null) + { + return GetChildren(entity.Id, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter); + } + + return NotFound(); + } + + /// + /// Returns the child media objects - using the entity UDI id + /// + /// + /// + /// + /// + /// + /// + /// + /// + [FilterAllowedOutgoingMedia(typeof(IEnumerable>), "Items")] + public ActionResult>> GetChildren(Udi id, + int pageNumber = 0, + int pageSize = 0, + string orderBy = "SortOrder", + Direction orderDirection = Direction.Ascending, + bool orderBySystemField = true, + string filter = "") + { + var guidUdi = id as GuidUdi; + if (guidUdi != null) + { + IEntitySlim? entity = _entityService.Get(guidUdi.Guid); + if (entity != null) + { + return GetChildren(entity.Id, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, + filter); + } + } + + return NotFound(); + } + + #endregion } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaTypeController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaTypeController.cs index c5c5a38b4b..9582a6f032 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MediaTypeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaTypeController.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; @@ -9,50 +6,50 @@ using Umbraco.Cms.Core.Editors; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Web.BackOffice.Filters; -using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// An API controller used for dealing with content types +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +[ParameterSwapControllerActionSelector(nameof(GetAllowedChildren), "contentId", typeof(int), typeof(Guid), typeof(Udi))] +public class MediaTypeController : ContentTypeControllerBase { - /// - /// An API controller used for dealing with content types - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - [ParameterSwapControllerActionSelector(nameof(GetAllowedChildren), "contentId", typeof(int), typeof(Guid), typeof(Udi))] - public class MediaTypeController : ContentTypeControllerBase - { - // TODO: Split this controller apart so that authz is consistent, currently we need to authz each action individually. - // It would be possible to have something like a MediaTypeInfoController for the GetById/GetAllowedChildren/etc... actions + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + // TODO: Split this controller apart so that authz is consistent, currently we need to authz each action individually. + // It would be possible to have something like a MediaTypeInfoController for the GetById/GetAllowedChildren/etc... actions - private readonly IContentTypeService _contentTypeService; - private readonly IEntityService _entityService; - private readonly ILocalizedTextService _localizedTextService; - private readonly IMediaService _mediaService; - private readonly IMediaTypeService _mediaTypeService; - private readonly IShortStringHelper _shortStringHelper; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IContentTypeService _contentTypeService; + private readonly IEntityService _entityService; + private readonly ILocalizedTextService _localizedTextService; + private readonly IMediaService _mediaService; + private readonly IMediaTypeService _mediaTypeService; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; - public MediaTypeController(ICultureDictionary cultureDictionary, - EditorValidatorCollection editorValidatorCollection, - IContentTypeService contentTypeService, - IMediaTypeService mediaTypeService, - IMemberTypeService memberTypeService, - IUmbracoMapper umbracoMapper, - ILocalizedTextService localizedTextService, - IShortStringHelper shortStringHelper, - IEntityService entityService, - IMediaService mediaService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor) - : base( + public MediaTypeController( + ICultureDictionary cultureDictionary, + EditorValidatorCollection editorValidatorCollection, + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService, + IUmbracoMapper umbracoMapper, + ILocalizedTextService localizedTextService, + IShortStringHelper shortStringHelper, + IEntityService entityService, + IMediaService mediaService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor) + : base( cultureDictionary, editorValidatorCollection, contentTypeService, @@ -60,364 +57,373 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers memberTypeService, umbracoMapper, localizedTextService) + { + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); + _mediaTypeService = mediaTypeService ?? throw new ArgumentNullException(nameof(mediaTypeService)); + _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _localizedTextService = + localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + } + + public int GetCount() => _contentTypeService.Count(); + + /// + /// Gets the media type a given id + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] + public ActionResult GetById(int id) + { + IMediaType? ct = _mediaTypeService.Get(id); + if (ct == null) { - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); - _mediaTypeService = mediaTypeService ?? throw new ArgumentNullException(nameof(mediaTypeService)); - _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _localizedTextService = - localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + return NotFound(); } - public int GetCount() => _contentTypeService.Count(); + MediaTypeDisplay? dto = _umbracoMapper.Map(ct); + return dto; + } - /// - /// Gets the media type a given id - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] - public ActionResult GetById(int id) + /// + /// Gets the media type a given guid + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] + public ActionResult GetById(Guid id) + { + IMediaType? mediaType = _mediaTypeService.Get(id); + if (mediaType == null) { - var ct = _mediaTypeService.Get(id); - if (ct == null) + return NotFound(); + } + + MediaTypeDisplay? dto = _umbracoMapper.Map(mediaType); + return dto; + } + + /// + /// Gets the media type a given udi + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) + { + return NotFound(); + } + + IMediaType? mediaType = _mediaTypeService.Get(guidUdi.Guid); + if (mediaType == null) + { + return NotFound(); + } + + MediaTypeDisplay? dto = _umbracoMapper.Map(mediaType); + return dto; + } + + /// + /// Deletes a media type with a given ID + /// + /// + /// + [HttpDelete] + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public IActionResult DeleteById(int id) + { + IMediaType? foundType = _mediaTypeService.Get(id); + if (foundType == null) + { + return NotFound(); + } + + _mediaTypeService.Delete(foundType, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + return Ok(); + } + + /// + /// Returns the available compositions for this content type + /// This has been wrapped in a dto instead of simple parameters to support having multiple parameters in post request + /// body + /// + /// + /// + /// This is normally an empty list but if additional content type aliases are passed in, any content types containing + /// those aliases will be filtered out + /// along with any content types that have matching property types that are included in the filtered content types + /// + /// + /// This is normally an empty list but if additional property type aliases are passed in, any content types that have + /// these aliases will be filtered out. + /// This is required because in the case of creating/modifying a content type because new property types being added to + /// it are not yet persisted so cannot + /// be looked up via the db, they need to be passed in. + /// + /// + /// Filter applied when resolving compositions + /// + /// + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public IActionResult GetAvailableCompositeMediaTypes(GetAvailableCompositionsFilter filter) + { + ActionResult>> actionResult = PerformGetAvailableCompositeContentTypes( + filter.ContentTypeId, + UmbracoObjectTypes.MediaType, + filter.FilterContentTypes, + filter.FilterPropertyTypes, + filter.IsElement); + + if (!(actionResult.Result is null)) + { + return actionResult.Result; + } + + var result = actionResult.Value?.Select(x => new { contentType = x.Item1, allowed = x.Item2 }); + return Ok(result); + } + + /// + /// Returns where a particular composition has been used + /// This has been wrapped in a dto instead of simple parameters to support having multiple parameters in post request + /// body + /// + /// + /// + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public IActionResult GetWhereCompositionIsUsedInContentTypes(GetAvailableCompositionsFilter filter) + { + var result = + PerformGetWhereCompositionIsUsedInContentTypes(filter.ContentTypeId, UmbracoObjectTypes.MediaType).Value? + .Select(x => new { contentType = x }); + return Ok(result); + } + + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public MediaTypeDisplay? GetEmpty(int parentId) + { + IMediaType mt; + if (parentId != Constants.System.Root) + { + IMediaType? parent = _mediaTypeService.Get(parentId); + mt = parent != null + ? new MediaType(_shortStringHelper, parent, string.Empty) + : new MediaType(_shortStringHelper, parentId); + } + else + { + mt = new MediaType(_shortStringHelper, parentId); + } + + mt.Icon = Constants.Icons.MediaImage; + + MediaTypeDisplay? dto = _umbracoMapper.Map(mt); + return dto; + } + + + /// + /// Returns all media types + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public IEnumerable GetAll() => + _mediaTypeService.GetAll() + .Select(_umbracoMapper.Map).WhereNotNull(); + + /// + /// Deletes a media type container with a given ID + /// + /// + /// + [HttpDelete] + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public IActionResult DeleteContainer(int id) + { + _mediaTypeService.DeleteContainer(id, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + return Ok(); + } + + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public IActionResult PostCreateContainer(int parentId, string name) + { + Attempt?> result = + _mediaTypeService.CreateContainer(parentId, Guid.NewGuid(), name, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + if (result.Success) + { + return Ok(result.Result); //return the id + } + + return ValidationProblem(result.Exception?.Message); + } + + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public IActionResult PostRenameContainer(int id, string name) + { + Attempt?> result = + _mediaTypeService.RenameContainer(id, name, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + + if (result.Success) + { + return Ok(result.Result); //return the id + } + + return ValidationProblem(result.Exception?.Message); + } + + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public ActionResult PostSave(MediaTypeSave contentTypeSave) + { + ActionResult savedCt = PerformPostSave( + contentTypeSave, + i => _mediaTypeService.Get(i), + type => _mediaTypeService.Save(type)); + + if (!(savedCt.Result is null)) + { + return savedCt.Result; + } + + MediaTypeDisplay? display = _umbracoMapper.Map(savedCt.Value); + + + display?.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "mediaTypeSavedHeader"), + string.Empty); + + return display; + } + + /// + /// Move the media type + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public IActionResult PostMove(MoveOrCopy move) => + PerformMove( + move, + i => _mediaTypeService.Get(i), + (type, i) => _mediaTypeService.Move(type, i)); + + /// + /// Copy the media type + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] + public IActionResult PostCopy(MoveOrCopy copy) => + PerformCopy( + copy, + i => _mediaTypeService.Get(i), + (type, i) => _mediaTypeService.Copy(type, i)); + + + #region GetAllowedChildren + + /// + /// Returns the allowed child content type objects for the content item id passed in - based on an INT id + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] + [OutgoingEditorModelEvent] + public IEnumerable GetAllowedChildren(int contentId) + { + if (contentId == Constants.System.RecycleBinContent) + { + return Enumerable.Empty(); + } + + IEnumerable types; + if (contentId == Constants.System.Root) + { + types = _mediaTypeService.GetAll().ToList(); + + //if no allowed root types are set, just return everything + if (types.Any(x => x.AllowedAsRoot)) { - return NotFound(); + types = types.Where(x => x.AllowedAsRoot); } - - var dto = _umbracoMapper.Map(ct); - return dto; } - - /// - /// Gets the media type a given guid - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] - public ActionResult GetById(Guid id) + else { - var mediaType = _mediaTypeService.Get(id); - if (mediaType == null) + IMedia? contentItem = _mediaService.GetById(contentId); + if (contentItem == null) { - return NotFound(); - } - - var dto = _umbracoMapper.Map(mediaType); - return dto; - } - - /// - /// Gets the media type a given udi - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] - public ActionResult GetById(Udi id) - { - var guidUdi = id as GuidUdi; - if (guidUdi == null) - return NotFound(); - - var mediaType = _mediaTypeService.Get(guidUdi.Guid); - if (mediaType == null) - { - return NotFound(); - } - - var dto = _umbracoMapper.Map(mediaType); - return dto; - } - - /// - /// Deletes a media type with a given ID - /// - /// - /// - [HttpDelete] - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public IActionResult DeleteById(int id) - { - var foundType = _mediaTypeService.Get(id); - if (foundType == null) - { - return NotFound(); - } - - _mediaTypeService.Delete(foundType, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - return Ok(); - } - - /// - /// Returns the available compositions for this content type - /// This has been wrapped in a dto instead of simple parameters to support having multiple parameters in post request - /// body - /// - /// - /// - /// This is normally an empty list but if additional content type aliases are passed in, any content types containing - /// those aliases will be filtered out - /// along with any content types that have matching property types that are included in the filtered content types - /// - /// - /// This is normally an empty list but if additional property type aliases are passed in, any content types that have - /// these aliases will be filtered out. - /// This is required because in the case of creating/modifying a content type because new property types being added to - /// it are not yet persisted so cannot - /// be looked up via the db, they need to be passed in. - /// - /// - /// Filter applied when resolving compositions - /// - /// - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public IActionResult GetAvailableCompositeMediaTypes(GetAvailableCompositionsFilter filter) - { - var actionResult = PerformGetAvailableCompositeContentTypes(filter.ContentTypeId, - UmbracoObjectTypes.MediaType, filter.FilterContentTypes, filter.FilterPropertyTypes, - filter.IsElement); - - if (!(actionResult.Result is null)) - { - return actionResult.Result; - } - - var result = actionResult.Value?.Select(x => new - { - contentType = x.Item1, - allowed = x.Item2 - }); - return Ok(result); - } - - /// - /// Returns where a particular composition has been used - /// This has been wrapped in a dto instead of simple parameters to support having multiple parameters in post request - /// body - /// - /// - /// - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public IActionResult GetWhereCompositionIsUsedInContentTypes(GetAvailableCompositionsFilter filter) - { - var result = - PerformGetWhereCompositionIsUsedInContentTypes(filter.ContentTypeId, UmbracoObjectTypes.MediaType).Value? - .Select(x => new - { - contentType = x - }); - return Ok(result); - } - - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public MediaTypeDisplay? GetEmpty(int parentId) - { - IMediaType mt; - if (parentId != Constants.System.Root) - { - var parent = _mediaTypeService.Get(parentId); - mt = parent != null - ? new MediaType(_shortStringHelper, parent, string.Empty) - : new MediaType(_shortStringHelper, parentId); - } - else - mt = new MediaType(_shortStringHelper, parentId); - - mt.Icon = Constants.Icons.MediaImage; - - var dto = _umbracoMapper.Map(mt); - return dto; - } - - - /// - /// Returns all media types - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public IEnumerable GetAll() => - _mediaTypeService.GetAll() - .Select(_umbracoMapper.Map).WhereNotNull(); - - /// - /// Deletes a media type container with a given ID - /// - /// - /// - [HttpDelete] - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public IActionResult DeleteContainer(int id) - { - _mediaTypeService.DeleteContainer(id, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - return Ok(); - } - - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public IActionResult PostCreateContainer(int parentId, string name) - { - var result = _mediaTypeService.CreateContainer(parentId, Guid.NewGuid(), name, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - if (result.Success) - return Ok(result.Result); //return the id - else - return ValidationProblem(result.Exception?.Message); - } - - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public IActionResult PostRenameContainer(int id, string name) - { - var result = _mediaTypeService.RenameContainer(id, name, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - if (result.Success) - return Ok(result.Result); //return the id - else - return ValidationProblem(result.Exception?.Message); - } - - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public ActionResult PostSave(MediaTypeSave contentTypeSave) - { - var savedCt = PerformPostSave( - contentTypeSave, - i => _mediaTypeService.Get(i), - type => _mediaTypeService.Save(type)); - - if (!(savedCt.Result is null)) - { - return savedCt.Result; - } - - var display = _umbracoMapper.Map(savedCt.Value); - - - display?.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles","mediaTypeSavedHeader"), - string.Empty); - - return display; - } - - /// - /// Move the media type - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public IActionResult PostMove(MoveOrCopy move) - { - return PerformMove( - move, - i => _mediaTypeService.Get(i), - (type, i) => _mediaTypeService.Move(type, i)); - } - - /// - /// Copy the media type - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - public IActionResult PostCopy(MoveOrCopy copy) - { - return PerformCopy( - copy, - i => _mediaTypeService.Get(i), - (type, i) => _mediaTypeService.Copy(type, i)); - } - - - #region GetAllowedChildren - - /// - /// Returns the allowed child content type objects for the content item id passed in - based on an INT id - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] - [OutgoingEditorModelEvent] - public IEnumerable GetAllowedChildren(int contentId) - { - if (contentId == Constants.System.RecycleBinContent) return Enumerable.Empty(); - - IEnumerable types; - if (contentId == Constants.System.Root) - { - types = _mediaTypeService.GetAll().ToList(); - - //if no allowed root types are set, just return everything - if (types.Any(x => x.AllowedAsRoot)) - types = types.Where(x => x.AllowedAsRoot); - } - else - { - var contentItem = _mediaService.GetById(contentId); - if (contentItem == null) - { - return Enumerable.Empty(); - } - - var contentType = _mediaTypeService.Get(contentItem.ContentTypeId); - var ids = contentType?.AllowedContentTypes?.OrderBy(c => c.SortOrder).Select(x => x.Id.Value).ToArray(); - - if (ids is null || ids.Any() == false) return Enumerable.Empty(); - - types = _mediaTypeService.GetAll(ids).OrderBy(c => ids.IndexOf(c.Id)).ToList(); } - var basics = types.Select(_umbracoMapper.Map).WhereNotNull().ToList(); + IMediaType? contentType = _mediaTypeService.Get(contentItem.ContentTypeId); + var ids = contentType?.AllowedContentTypes?.OrderBy(c => c.SortOrder).Select(x => x.Id.Value).ToArray(); - foreach (var basic in basics) + if (ids is null || ids.Any() == false) { - basic.Name = TranslateItem(basic.Name); - basic.Description = TranslateItem(basic.Description); + return Enumerable.Empty(); } - return basics.OrderBy(c => contentId == Constants.System.Root ? c.Name : string.Empty); + types = _mediaTypeService.GetAll(ids).OrderBy(c => ids.IndexOf(c.Id)).ToList(); } - /// - /// Returns the allowed child content type objects for the content item id passed in - based on a GUID id - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] - public ActionResult> GetAllowedChildren(Guid contentId) + var basics = types.Select(_umbracoMapper.Map).WhereNotNull().ToList(); + + foreach (ContentTypeBasic basic in basics) { - var entity = _entityService.Get(contentId); + basic.Name = TranslateItem(basic.Name); + basic.Description = TranslateItem(basic.Description); + } + + return basics.OrderBy(c => contentId == Constants.System.Root ? c.Name : string.Empty); + } + + /// + /// Returns the allowed child content type objects for the content item id passed in - based on a GUID id + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] + public ActionResult> GetAllowedChildren(Guid contentId) + { + IEntitySlim? entity = _entityService.Get(contentId); + if (entity != null) + { + return new ActionResult>(GetAllowedChildren(entity.Id)); + } + + return NotFound(); + } + + /// + /// Returns the allowed child content type objects for the content item id passed in - based on a UDI id + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] + public ActionResult> GetAllowedChildren(Udi contentId) + { + var guidUdi = contentId as GuidUdi; + if (guidUdi != null) + { + IEntitySlim? entity = _entityService.Get(guidUdi.Guid); if (entity != null) { return new ActionResult>(GetAllowedChildren(entity.Id)); } - - return NotFound(); } - /// - /// Returns the allowed child content type objects for the content item id passed in - based on a UDI id - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaOrMediaTypes)] - public ActionResult> GetAllowedChildren(Udi contentId) - { - var guidUdi = contentId as GuidUdi; - if (guidUdi != null) - { - var entity = _entityService.Get(guidUdi.Guid); - if (entity != null) - { - return new ActionResult>(GetAllowedChildren(entity.Id)); - } - } - - return NotFound(); - } - - #endregion + return NotFound(); } + + #endregion } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs index f706e9ed59..70f337f44f 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs @@ -1,12 +1,7 @@ -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; -using System.Linq; -using System.Net.Http; using System.Net.Mime; using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -25,7 +20,6 @@ using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Services.Implement; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.BackOffice.ModelBinders; @@ -35,717 +29,731 @@ using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// This controller is decorated with the UmbracoApplicationAuthorizeAttribute which means that any user requesting +/// access to ALL of the methods on this controller will need access to the member application. +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessMembers)] +[OutgoingNoHyphenGuidFormat] +public class MemberController : ContentControllerBase { - /// - /// This controller is decorated with the UmbracoApplicationAuthorizeAttribute which means that any user requesting - /// access to ALL of the methods on this controller will need access to the member application. - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessMembers)] - [OutgoingNoHyphenGuidFormat] - public class MemberController : ContentControllerBase + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IDataTypeService _dataTypeService; + private readonly IJsonSerializer _jsonSerializer; + private readonly ILocalizedTextService _localizedTextService; + private readonly IMemberManager _memberManager; + private readonly IMemberService _memberService; + private readonly IMemberTypeService _memberTypeService; + private readonly IPasswordChanger _passwordChanger; + private readonly PropertyEditorCollection _propertyEditors; + private readonly ICoreScopeProvider _scopeProvider; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; + + /// + /// Initializes a new instance of the class. + /// + /// The culture dictionary + /// The logger factory + /// The string helper + /// The event messages factory + /// The entry point for localizing key services + /// The property editors + /// The mapper + /// The member service + /// The member type service + /// The member manager + /// The data-type service + /// The back office security accessor + /// The JSON serializer + /// The password changer + /// The core scope provider + public MemberController( + ICultureDictionary cultureDictionary, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper, + IEventMessagesFactory eventMessages, + ILocalizedTextService localizedTextService, + PropertyEditorCollection propertyEditors, + IUmbracoMapper umbracoMapper, + IMemberService memberService, + IMemberTypeService memberTypeService, + IMemberManager memberManager, + IDataTypeService dataTypeService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IJsonSerializer jsonSerializer, + IPasswordChanger passwordChanger, + ICoreScopeProvider scopeProvider) + : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, jsonSerializer) { - private readonly PropertyEditorCollection _propertyEditors; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IMemberService _memberService; - private readonly IMemberTypeService _memberTypeService; - private readonly IMemberManager _memberManager; - private readonly IDataTypeService _dataTypeService; - private readonly ILocalizedTextService _localizedTextService; - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly IJsonSerializer _jsonSerializer; - private readonly IShortStringHelper _shortStringHelper; - private readonly IPasswordChanger _passwordChanger; - private readonly ICoreScopeProvider _scopeProvider; + _propertyEditors = propertyEditors; + _umbracoMapper = umbracoMapper; + _memberService = memberService; + _memberTypeService = memberTypeService; + _memberManager = memberManager; + _dataTypeService = dataTypeService; + _localizedTextService = localizedTextService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _jsonSerializer = jsonSerializer; + _shortStringHelper = shortStringHelper; + _passwordChanger = passwordChanger; + _scopeProvider = scopeProvider; + } - /// - /// Initializes a new instance of the class. - /// - /// The culture dictionary - /// The logger factory - /// The string helper - /// The event messages factory - /// The entry point for localizing key services - /// The property editors - /// The mapper - /// The member service - /// The member type service - /// The member manager - /// The data-type service - /// The back office security accessor - /// The JSON serializer - /// The password changer - public MemberController( - ICultureDictionary cultureDictionary, - ILoggerFactory loggerFactory, - IShortStringHelper shortStringHelper, - IEventMessagesFactory eventMessages, - ILocalizedTextService localizedTextService, - PropertyEditorCollection propertyEditors, - IUmbracoMapper umbracoMapper, - IMemberService memberService, - IMemberTypeService memberTypeService, - IMemberManager memberManager, - IDataTypeService dataTypeService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IJsonSerializer jsonSerializer, - IPasswordChanger passwordChanger, - ICoreScopeProvider scopeProvider) - : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, jsonSerializer) + /// + /// The paginated list of members + /// + /// The page number to display + /// The size of the page + /// The ordering of the member list + /// The direction of the member list + /// The system field to order by + /// The current filter for the list + /// The member type + /// The paged result of members + public PagedResult GetPagedResults( + int pageNumber = 1, + int pageSize = 100, + string orderBy = "username", + Direction orderDirection = Direction.Ascending, + bool orderBySystemField = true, + string filter = "", + string? memberTypeAlias = null) + { + if (pageNumber <= 0 || pageSize <= 0) { - _propertyEditors = propertyEditors; - _umbracoMapper = umbracoMapper; - _memberService = memberService; - _memberTypeService = memberTypeService; - _memberManager = memberManager; - _dataTypeService = dataTypeService; - _localizedTextService = localizedTextService; - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _jsonSerializer = jsonSerializer; - _shortStringHelper = shortStringHelper; - _passwordChanger = passwordChanger; - _scopeProvider = scopeProvider; + throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); } - /// - /// The paginated list of members - /// - /// The page number to display - /// The size of the page - /// The ordering of the member list - /// The direction of the member list - /// The system field to order by - /// The current filter for the list - /// The member type - /// The paged result of members - public PagedResult GetPagedResults( - int pageNumber = 1, - int pageSize = 100, - string orderBy = "username", - Direction orderDirection = Direction.Ascending, - bool orderBySystemField = true, - string filter = "", - string? memberTypeAlias = null) + IMember[] members = _memberService.GetAll( + pageNumber - 1, + pageSize, + out var totalRecords, + orderBy, + orderDirection, + orderBySystemField, + memberTypeAlias, + filter).ToArray(); + if (totalRecords == 0) { - - if (pageNumber <= 0 || pageSize <= 0) - { - throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); - } - - IMember[] members = _memberService.GetAll( - pageNumber - 1, - pageSize, - out var totalRecords, - orderBy, - orderDirection, - orderBySystemField, - memberTypeAlias, - filter).ToArray(); - if (totalRecords == 0) - { - return new PagedResult(0, 0, 0); - } - - var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) - { - Items = members.Select(x => _umbracoMapper.Map(x)).WhereNotNull() - }; - return pagedResult; + return new PagedResult(0, 0, 0); } - /// - /// Returns a display node with a list view to render members - /// - /// The member type to list - /// The member list for display - public MemberListDisplay GetListNodeDisplay(string listName) + var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) { - IMemberType? foundType = _memberTypeService.Get(listName); - string? name = foundType != null ? foundType.Name : listName; + Items = members.Select(x => _umbracoMapper.Map(x)).WhereNotNull() + }; + return pagedResult; + } - var apps = new List(); - apps.Add(ListViewContentAppFactory.CreateContentApp(_dataTypeService, _propertyEditors, listName, "member", Core.Constants.DataTypes.DefaultMembersListView)); - apps[0].Active = true; + /// + /// Returns a display node with a list view to render members + /// + /// The member type to list + /// The member list for display + public MemberListDisplay GetListNodeDisplay(string listName) + { + IMemberType? foundType = _memberTypeService.Get(listName); + var name = foundType != null ? foundType.Name : listName; - var display = new MemberListDisplay + var apps = new List + { + ListViewContentAppFactory.CreateContentApp(_dataTypeService, _propertyEditors, listName, "member", Constants.DataTypes.DefaultMembersListView) + }; + apps[0].Active = true; + + var display = new MemberListDisplay + { + ContentTypeAlias = listName, + ContentTypeName = name, + Id = listName, + IsContainer = true, + Name = listName == Constants.Conventions.MemberTypes.AllMembersListId ? "All Members" : name, + Path = "-1," + listName, + ParentId = -1, + ContentApps = apps + }; + + return display; + } + + /// + /// Gets the content json for the member + /// + /// The Guid key of the member + /// The member for display + [OutgoingEditorModelEvent] + public MemberDisplay? GetByKey(Guid key) + { + IMember? foundMember = _memberService.GetByKey(key); + if (foundMember == null) + { + HandleContentNotFound(key); + } + + return _umbracoMapper.Map(foundMember); + } + + /// + /// Gets an empty content item for the + /// + /// The content type + /// The empty member for display + [OutgoingEditorModelEvent] + public ActionResult GetEmpty(string? contentTypeAlias = null) + { + if (contentTypeAlias == null) + { + return NotFound(); + } + + IMemberType? contentType = _memberTypeService.Get(contentTypeAlias); + if (contentType == null) + { + return NotFound(); + } + + var newPassword = _memberManager.GeneratePassword(); + + IMember emptyContent = new Member(contentType); + if (emptyContent.AdditionalData is not null) + { + emptyContent.AdditionalData["NewPassword"] = newPassword; + } + + return _umbracoMapper.Map(emptyContent); + } + + /// + /// Saves member + /// + /// The content item to save as a member + /// The resulting member display object + [FileUploadCleanupFilter] + [OutgoingEditorModelEvent] + [MemberSaveValidation] + public async Task> PostSave([ModelBinder(typeof(MemberBinder))] MemberSave contentItem) + { + if (contentItem == null) + { + throw new ArgumentNullException("The member content item was null"); + } + + // If we've reached here it means: + // * Our model has been bound + // * and validated + // * any file attachments have been saved to their temporary location for us to use + // * we have a reference to the DTO object and the persisted object + // * Permissions are valid + + // map the properties to the persisted entity + MapPropertyValues(contentItem); + + await ValidateMemberDataAsync(contentItem); + + // Unlike content/media - if there are errors for a member, we do NOT proceed to save them, we cannot so return the errors + if (ModelState.IsValid == false) + { + MemberDisplay? forDisplay = _umbracoMapper.Map(contentItem.PersistedContent); + return ValidationProblem(forDisplay, ModelState); + } + + // Create a scope here which will wrap all child data operations in a single transaction. + // We'll complete this at the end of this method if everything succeeeds, else + // all data operations will roll back. + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + + // Depending on the action we need to first do a create or update using the membership manager + // this ensures that passwords are formatted correctly and also performs the validation on the provider itself. + switch (contentItem.Action) + { + case ContentSaveAction.Save: + ActionResult updateSuccessful = await UpdateMemberAsync(contentItem); + if (!(updateSuccessful.Result is null)) + { + return updateSuccessful.Result; + } + + break; + case ContentSaveAction.SaveNew: + ActionResult createSuccessful = await CreateMemberAsync(contentItem); + if (!(createSuccessful.Result is null)) + { + return createSuccessful.Result; + } + + break; + default: + // we don't support anything else for members + return NotFound(); + } + + // return the updated model + MemberDisplay? display = _umbracoMapper.Map(contentItem.PersistedContent); + + // lastly, if it is not valid, add the model state to the outgoing object and throw a 403 + if (!ModelState.IsValid) + { + return ValidationProblem(display, ModelState, StatusCodes.Status403Forbidden); + } + + // put the correct messages in + switch (contentItem.Action) + { + case ContentSaveAction.Save: + case ContentSaveAction.SaveNew: + display?.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "editMemberSaved"), + _localizedTextService.Localize("speechBubbles", "editMemberSaved")); + break; + } + + // Mark transaction to commit all changes + scope.Complete(); + + return display; + } + + /// + /// Maps the property values to the persisted entity + /// + /// The member content item to map properties from + private void MapPropertyValues(MemberSave contentItem) + { + if (contentItem.PersistedContent is not null) + { + // Don't update the name if it is empty + if (contentItem.Name.IsNullOrWhiteSpace() == false) { - ContentTypeAlias = listName, - ContentTypeName = name, - Id = listName, - IsContainer = true, - Name = listName == Constants.Conventions.MemberTypes.AllMembersListId ? "All Members" : name, - Path = "-1," + listName, - ParentId = -1, - ContentApps = apps + contentItem.PersistedContent.Name = contentItem.Name; + } + + // map the custom properties - this will already be set for new entities in our member binder + contentItem.PersistedContent.IsApproved = contentItem.IsApproved; + contentItem.PersistedContent.Email = contentItem.Email.Trim(); + contentItem.PersistedContent.Username = contentItem.Username; + } + + // use the base method to map the rest of the properties + MapPropertyValuesForPersistence( + contentItem, + contentItem.PropertyCollectionDto, + (save, property) => property?.GetValue(), // get prop val + (save, property, v) => property?.SetValue(v), // set prop val + null); // member are all invariant + } + + /// + /// Create a member from the supplied member content data + /// All member password processing and creation is done via the identity manager + /// + /// Member content data + /// The identity result of the created member + private async Task> CreateMemberAsync(MemberSave contentItem) + { + IMemberType? memberType = _memberTypeService.Get(contentItem.ContentTypeAlias); + if (memberType == null) + { + throw new InvalidOperationException($"No member type found with alias {contentItem.ContentTypeAlias}"); + } + + var identityMember = MemberIdentityUser.CreateNew( + contentItem.Username, + contentItem.Email, + memberType.Alias, + contentItem.IsApproved, + contentItem.Name); + + IdentityResult created = await _memberManager.CreateAsync(identityMember, contentItem.Password?.NewPassword); + + if (created.Succeeded == false) + { + MemberDisplay? forDisplay = _umbracoMapper.Map(contentItem.PersistedContent); + foreach (IdentityError error in created.Errors) + { + switch (error.Code) + { + case nameof(IdentityErrorDescriber.InvalidUserName): + ModelState.AddPropertyError( + new ValidationResult(error.Description, new[] { "value" }), + string.Format("{0}login", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); + break; + case nameof(IdentityErrorDescriber.PasswordMismatch): + case nameof(IdentityErrorDescriber.PasswordRequiresDigit): + case nameof(IdentityErrorDescriber.PasswordRequiresLower): + case nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric): + case nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars): + case nameof(IdentityErrorDescriber.PasswordRequiresUpper): + case nameof(IdentityErrorDescriber.PasswordTooShort): + ModelState.AddPropertyError( + new ValidationResult(error.Description, new[] { "value" }), + string.Format("{0}password", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); + break; + case nameof(IdentityErrorDescriber.InvalidEmail): + ModelState.AddPropertyError( + new ValidationResult(error.Description, new[] { "value" }), + string.Format("{0}email", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); + break; + case nameof(IdentityErrorDescriber.DuplicateUserName): + ModelState.AddPropertyError( + new ValidationResult(error.Description, new[] { "value" }), + string.Format("{0}login", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); + break; + case nameof(IdentityErrorDescriber.DuplicateEmail): + ModelState.AddPropertyError( + new ValidationResult(error.Description, new[] { "value" }), + string.Format("{0}email", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); + break; + } + } + + return ValidationProblem(forDisplay, ModelState); + } + + // now re-look up the member, which will now exist + IMember? member = _memberService.GetByEmail(contentItem.Email); + + if (member is null) + { + return false; + } + + // map the save info over onto the user + member = _umbracoMapper.Map(contentItem, member); + + var creatorId = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1; + member.CreatorId = creatorId; + + // assign the mapped property values that are not part of the identity properties + var builtInAliases = ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper).Select(x => x.Key) + .ToArray(); + foreach (ContentPropertyBasic property in contentItem.Properties) + { + if (builtInAliases.Contains(property.Alias) == false) + { + member.Properties[property.Alias]?.SetValue(property.Value); + } + } + + // now the member has been saved via identity, resave the member with mapped content properties + _memberService.Save(member); + contentItem.PersistedContent = member; + + ActionResult rolesChanged = await AddOrUpdateRoles(contentItem.Groups, identityMember); + if (!rolesChanged.Value && rolesChanged.Result != null) + { + return rolesChanged.Result; + } + + return true; + } + + /// + /// Update existing member data + /// + /// The member to save + /// + /// We need to use both IMemberService and ASP.NET Identity to do our updates because Identity is responsible for + /// passwords/security. + /// When this method is called, the IMember will already have updated/mapped values from the http POST. + /// So then we do this in order: + /// 1. Deal with sensitive property values on IMember + /// 2. Use IMemberService to persist all changes + /// 3. Use ASP.NET and MemberUserManager to deal with lockouts + /// 4. Use ASP.NET, MemberUserManager and password changer to deal with passwords + /// 5. Deal with groups/roles + /// + private async Task> UpdateMemberAsync(MemberSave contentItem) + { + if (contentItem.PersistedContent is not null) + { + contentItem.PersistedContent.WriterId = + _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1; + } + + // If the user doesn't have access to sensitive values, then we need to check if any of the built in member property types + // have been marked as sensitive. If that is the case we cannot change these persisted values no matter what value has been posted. + // There's only 3 special ones we need to deal with that are part of the MemberSave instance: Comments, IsApproved, IsLockedOut + // but we will take care of this in a generic way below so that it works for all props. + if (!_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasAccessToSensitiveData() ?? true) + { + IMemberType? memberType = contentItem.PersistedContent is null + ? null + : _memberTypeService.Get(contentItem.PersistedContent.ContentTypeId); + var sensitiveProperties = memberType? + .PropertyTypes.Where(x => memberType.IsSensitiveProperty(x.Alias)) + .ToList(); + + if (sensitiveProperties is not null) + { + foreach (IPropertyType sensitiveProperty in sensitiveProperties) + { + // TODO: This logic seems to deviate from the logic that is in v8 where we are explitly checking + // against 3 properties: Comments, IsApproved, IsLockedOut, is the v8 version incorrect? + + ContentPropertyBasic? destProp = + contentItem.Properties.FirstOrDefault(x => x.Alias == sensitiveProperty.Alias); + if (destProp != null) + { + // if found, change the value of the contentItem model to the persisted value so it remains unchanged + var origValue = contentItem.PersistedContent?.GetValue(sensitiveProperty.Alias); + destProp.Value = origValue; + } + } + } + } + + if (contentItem.PersistedContent is not null) + { + // First save the IMember with mapped values before we start updating data with aspnet identity + _memberService.Save(contentItem.PersistedContent); + } + + var needsResync = false; + + MemberIdentityUser identityMember = await _memberManager.FindByIdAsync(contentItem.Id?.ToString()); + if (identityMember == null) + { + return ValidationProblem("Member was not found"); + } + + // Handle unlocking with the member manager (takes care of other nuances) + if (identityMember.IsLockedOut && contentItem.IsLockedOut == false) + { + IdentityResult unlockResult = + await _memberManager.SetLockoutEndDateAsync(identityMember, DateTimeOffset.Now.AddMinutes(-1)); + if (unlockResult.Succeeded == false) + { + return ValidationProblem( + $"Could not unlock for member {contentItem.Id} - error {unlockResult.Errors.ToErrorMessage()}"); + } + + needsResync = true; + } + else if (identityMember.IsLockedOut == false && contentItem.IsLockedOut) + { + // NOTE: This should not ever happen unless someone is mucking around with the request data. + // An admin cannot simply lock a user, they get locked out by password attempts, but an admin can unlock them + return ValidationProblem("An admin cannot lock a member"); + } + + // If we're changing the password... + // Handle changing with the member manager & password changer (takes care of other nuances) + if (contentItem.Password != null) + { + IdentityResult validatePassword = + await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword); + if (validatePassword.Succeeded == false) + { + return ValidationProblem(validatePassword.Errors.ToErrorMessage()); + } + + if (!int.TryParse(identityMember.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) + { + return ValidationProblem("Member ID was not valid"); + } + + var changingPasswordModel = new ChangingPasswordModel + { + Id = intId, + OldPassword = contentItem.Password.OldPassword, + NewPassword = contentItem.Password.NewPassword }; - return display; + // Change and persist the password + Attempt passwordChangeResult = + await _passwordChanger.ChangePasswordWithIdentityAsync(changingPasswordModel, _memberManager); + + if (!passwordChangeResult.Success) + { + foreach (var memberName in passwordChangeResult.Result?.ChangeError?.MemberNames ?? + Enumerable.Empty()) + { + ModelState.AddModelError(memberName, passwordChangeResult.Result?.ChangeError?.ErrorMessage ?? string.Empty); + } + + return ValidationProblem(ModelState); + } + + needsResync = true; } - /// - /// Gets the content json for the member - /// - /// The Guid key of the member - /// The member for display - [OutgoingEditorModelEvent] - public MemberDisplay? GetByKey(Guid key) + // Update the roles and check for changes + ActionResult rolesChanged = await AddOrUpdateRoles(contentItem.Groups, identityMember); + if (!rolesChanged.Value && rolesChanged.Result != null) { - IMember? foundMember = _memberService.GetByKey(key); - if (foundMember == null) - { - HandleContentNotFound(key); - } - - return _umbracoMapper.Map(foundMember); + return rolesChanged.Result; } - /// - /// Gets an empty content item for the - /// - /// The content type - /// The empty member for display - [OutgoingEditorModelEvent] - public ActionResult GetEmpty(string? contentTypeAlias = null) + needsResync = true; + + // If there have been underlying changes made by ASP.NET Identity, then we need to resync the + // IMember on the PersistedContent with what is stored since it will be mapped to display. + if (needsResync && contentItem.PersistedContent is not null) { - if (contentTypeAlias == null) - { - return NotFound(); - } - - IMemberType? contentType = _memberTypeService.Get(contentTypeAlias); - if (contentType == null) - { - return NotFound(); - } - - string newPassword = _memberManager.GeneratePassword(); - - IMember emptyContent = new Member(contentType); - if (emptyContent.AdditionalData is not null) - { - emptyContent.AdditionalData["NewPassword"] = newPassword; - } - - return _umbracoMapper.Map(emptyContent); + contentItem.PersistedContent = _memberService.GetById(contentItem.PersistedContent.Id)!; } - /// - /// Saves member - /// - /// The content item to save as a member - /// The resulting member display object - [FileUploadCleanupFilter] - [OutgoingEditorModelEvent] - [MemberSaveValidation] - public async Task> PostSave([ModelBinder(typeof(MemberBinder))] MemberSave contentItem) + return true; + } + + private async Task ValidateMemberDataAsync(MemberSave contentItem) + { + if (contentItem.Name.IsNullOrWhiteSpace()) { - if (contentItem == null) - { - throw new ArgumentNullException("The member content item was null"); - } - - // If we've reached here it means: - // * Our model has been bound - // * and validated - // * any file attachments have been saved to their temporary location for us to use - // * we have a reference to the DTO object and the persisted object - // * Permissions are valid - - // map the properties to the persisted entity - MapPropertyValues(contentItem); - - await ValidateMemberDataAsync(contentItem); - - // Unlike content/media - if there are errors for a member, we do NOT proceed to save them, we cannot so return the errors - if (ModelState.IsValid == false) - { - MemberDisplay? forDisplay = _umbracoMapper.Map(contentItem.PersistedContent); - return ValidationProblem(forDisplay, ModelState); - } - - // Create a scope here which will wrap all child data operations in a single transaction. - // We'll complete this at the end of this method if everything succeeeds, else - // all data operations will roll back. - using ICoreScope scope = _scopeProvider.CreateCoreScope(); - - // Depending on the action we need to first do a create or update using the membership manager - // this ensures that passwords are formatted correctly and also performs the validation on the provider itself. - switch (contentItem.Action) - { - case ContentSaveAction.Save: - ActionResult updateSuccessful = await UpdateMemberAsync(contentItem); - if (!(updateSuccessful.Result is null)) - { - return updateSuccessful.Result; - } - - break; - case ContentSaveAction.SaveNew: - ActionResult createSuccessful = await CreateMemberAsync(contentItem); - if (!(createSuccessful.Result is null)) - { - return createSuccessful.Result; - } - - break; - default: - // we don't support anything else for members - return NotFound(); - } - - // return the updated model - MemberDisplay? display = _umbracoMapper.Map(contentItem.PersistedContent); - - // lastly, if it is not valid, add the model state to the outgoing object and throw a 403 - if (!ModelState.IsValid) - { - return ValidationProblem(display, ModelState, StatusCodes.Status403Forbidden); - } - - // put the correct messages in - switch (contentItem.Action) - { - case ContentSaveAction.Save: - case ContentSaveAction.SaveNew: - display?.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles","editMemberSaved"), - _localizedTextService.Localize("speechBubbles","editMemberSaved")); - break; - } - - // Mark transaction to commit all changes - scope.Complete(); - - return display; + ModelState.AddPropertyError( + new ValidationResult("Invalid user name", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); + return false; } - /// - /// Maps the property values to the persisted entity - /// - /// The member content item to map properties from - private void MapPropertyValues(MemberSave contentItem) + if (contentItem.Password != null && !contentItem.Password.NewPassword.IsNullOrWhiteSpace()) { - if (contentItem.PersistedContent is not null) - { - // Don't update the name if it is empty - if (contentItem.Name.IsNullOrWhiteSpace() == false) - { - contentItem.PersistedContent.Name = contentItem.Name; - } - - // map the custom properties - this will already be set for new entities in our member binder - contentItem.PersistedContent.IsApproved = contentItem.IsApproved; - contentItem.PersistedContent.Email = contentItem.Email.Trim(); - contentItem.PersistedContent.Username = contentItem.Username; - } - - // use the base method to map the rest of the properties - MapPropertyValuesForPersistence( - contentItem, - contentItem.PropertyCollectionDto, - (save, property) => property?.GetValue(), // get prop val - (save, property, v) => property?.SetValue(v), // set prop val - null); // member are all invariant - } - - /// - /// Create a member from the supplied member content data - /// - /// All member password processing and creation is done via the identity manager - /// - /// Member content data - /// The identity result of the created member - private async Task> CreateMemberAsync(MemberSave contentItem) - { - IMemberType? memberType = _memberTypeService.Get(contentItem.ContentTypeAlias); - if (memberType == null) - { - throw new InvalidOperationException($"No member type found with alias {contentItem.ContentTypeAlias}"); - } - - var identityMember = MemberIdentityUser.CreateNew( - contentItem.Username, - contentItem.Email, - memberType.Alias, - contentItem.IsApproved, - contentItem.Name); - - IdentityResult created = await _memberManager.CreateAsync(identityMember, contentItem.Password?.NewPassword); - - if (created.Succeeded == false) - { - MemberDisplay? forDisplay = _umbracoMapper.Map(contentItem.PersistedContent); - foreach (IdentityError error in created.Errors) - { - switch (error.Code) - { - case nameof(IdentityErrorDescriber.InvalidUserName): - ModelState.AddPropertyError( - new ValidationResult(error.Description, new[] { "value" }), - string.Format("{0}login", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); - break; - case nameof(IdentityErrorDescriber.PasswordMismatch): - case nameof(IdentityErrorDescriber.PasswordRequiresDigit): - case nameof(IdentityErrorDescriber.PasswordRequiresLower): - case nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric): - case nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars): - case nameof(IdentityErrorDescriber.PasswordRequiresUpper): - case nameof(IdentityErrorDescriber.PasswordTooShort): - ModelState.AddPropertyError( - new ValidationResult(error.Description, new[] { "value" }), - string.Format("{0}password", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); - break; - case nameof(IdentityErrorDescriber.InvalidEmail): - ModelState.AddPropertyError( - new ValidationResult(error.Description, new[] { "value" }), - string.Format("{0}email", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); - break; - case nameof(IdentityErrorDescriber.DuplicateUserName): - ModelState.AddPropertyError( - new ValidationResult(error.Description, new[] { "value" }), - string.Format("{0}login", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); - break; - case nameof(IdentityErrorDescriber.DuplicateEmail): - ModelState.AddPropertyError( - new ValidationResult(error.Description, new[] { "value" }), - string.Format("{0}email", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); - break; - } - } - return ValidationProblem(forDisplay, ModelState); - } - - // now re-look up the member, which will now exist - IMember? member = _memberService.GetByEmail(contentItem.Email); - - if (member is null) - { - return false; - } - - // map the save info over onto the user - member = _umbracoMapper.Map(contentItem, member); - - int creatorId = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1; - member.CreatorId = creatorId; - - // assign the mapped property values that are not part of the identity properties - string[] builtInAliases = ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper).Select(x => x.Key).ToArray(); - foreach (ContentPropertyBasic property in contentItem.Properties) - { - if (builtInAliases.Contains(property.Alias) == false) - { - member.Properties[property.Alias]?.SetValue(property.Value); - } - } - - // now the member has been saved via identity, resave the member with mapped content properties - _memberService.Save(member); - contentItem.PersistedContent = member; - - ActionResult rolesChanged = await AddOrUpdateRoles(contentItem.Groups, identityMember); - if (!rolesChanged.Value && rolesChanged.Result != null) - { - return rolesChanged.Result; - } - - return true; - } - - /// - /// Update existing member data - /// - /// The member to save - /// - /// We need to use both IMemberService and ASP.NET Identity to do our updates because Identity is responsible for passwords/security. - /// When this method is called, the IMember will already have updated/mapped values from the http POST. - /// So then we do this in order: - /// 1. Deal with sensitive property values on IMember - /// 2. Use IMemberService to persist all changes - /// 3. Use ASP.NET and MemberUserManager to deal with lockouts - /// 4. Use ASP.NET, MemberUserManager and password changer to deal with passwords - /// 5. Deal with groups/roles - /// - private async Task> UpdateMemberAsync(MemberSave contentItem) - { - if (contentItem.PersistedContent is not null) - { - contentItem.PersistedContent.WriterId = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1; - } - - // If the user doesn't have access to sensitive values, then we need to check if any of the built in member property types - // have been marked as sensitive. If that is the case we cannot change these persisted values no matter what value has been posted. - // There's only 3 special ones we need to deal with that are part of the MemberSave instance: Comments, IsApproved, IsLockedOut - // but we will take care of this in a generic way below so that it works for all props. - if (!_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasAccessToSensitiveData() ?? true) - { - IMemberType? memberType = contentItem.PersistedContent is null ? null : _memberTypeService.Get(contentItem.PersistedContent.ContentTypeId); - var sensitiveProperties = memberType? - .PropertyTypes.Where(x => memberType.IsSensitiveProperty(x.Alias)) - .ToList(); - - if (sensitiveProperties is not null) - { - foreach (IPropertyType sensitiveProperty in sensitiveProperties) - { - // TODO: This logic seems to deviate from the logic that is in v8 where we are explitly checking - // against 3 properties: Comments, IsApproved, IsLockedOut, is the v8 version incorrect? - - ContentPropertyBasic? destProp = contentItem.Properties.FirstOrDefault(x => x.Alias == sensitiveProperty.Alias); - if (destProp != null) - { - // if found, change the value of the contentItem model to the persisted value so it remains unchanged - object? origValue = contentItem.PersistedContent?.GetValue(sensitiveProperty.Alias); - destProp.Value = origValue; - } - } - } - } - - if (contentItem.PersistedContent is not null) - { - // First save the IMember with mapped values before we start updating data with aspnet identity - _memberService.Save(contentItem.PersistedContent); - } - - bool needsResync = false; - - MemberIdentityUser identityMember = await _memberManager.FindByIdAsync(contentItem.Id?.ToString()); - if (identityMember == null) - { - return ValidationProblem("Member was not found"); - } - - // Handle unlocking with the member manager (takes care of other nuances) - if (identityMember.IsLockedOut && contentItem.IsLockedOut == false) - { - IdentityResult unlockResult = await _memberManager.SetLockoutEndDateAsync(identityMember, DateTimeOffset.Now.AddMinutes(-1)); - if (unlockResult.Succeeded == false) - { - return ValidationProblem( - $"Could not unlock for member {contentItem.Id} - error {unlockResult.Errors.ToErrorMessage()}"); - } - needsResync = true; - } - else if (identityMember.IsLockedOut == false && contentItem.IsLockedOut) - { - // NOTE: This should not ever happen unless someone is mucking around with the request data. - // An admin cannot simply lock a user, they get locked out by password attempts, but an admin can unlock them - return ValidationProblem("An admin cannot lock a member"); - } - - // If we're changing the password... - // Handle changing with the member manager & password changer (takes care of other nuances) - if (contentItem.Password != null) - { - IdentityResult validatePassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword); - if (validatePassword.Succeeded == false) - { - return ValidationProblem(validatePassword.Errors.ToErrorMessage()); - } - - if (!int.TryParse(identityMember.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) - { - return ValidationProblem("Member ID was not valid"); - } - - var changingPasswordModel = new ChangingPasswordModel - { - Id = intId, - OldPassword = contentItem.Password.OldPassword, - NewPassword = contentItem.Password.NewPassword, - }; - - // Change and persist the password - Attempt passwordChangeResult = await _passwordChanger.ChangePasswordWithIdentityAsync(changingPasswordModel, _memberManager); - - if (!passwordChangeResult.Success) - { - foreach (string memberName in passwordChangeResult.Result?.ChangeError?.MemberNames ?? Enumerable.Empty()) - { - ModelState.AddModelError(memberName, passwordChangeResult.Result?.ChangeError?.ErrorMessage ?? string.Empty); - } - return ValidationProblem(ModelState); - } - - needsResync = true; - } - - // Update the roles and check for changes - ActionResult rolesChanged = await AddOrUpdateRoles(contentItem.Groups, identityMember); - if (!rolesChanged.Value && rolesChanged.Result != null) - { - return rolesChanged.Result; - } - else - { - needsResync = true; - } - - // If there have been underlying changes made by ASP.NET Identity, then we need to resync the - // IMember on the PersistedContent with what is stored since it will be mapped to display. - if (needsResync && contentItem.PersistedContent is not null) - { - contentItem.PersistedContent = _memberService.GetById(contentItem.PersistedContent.Id)!; - } - - return true; - } - - private async Task ValidateMemberDataAsync(MemberSave contentItem) - { - if (contentItem.Name.IsNullOrWhiteSpace()) + IdentityResult validPassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword); + if (!validPassword.Succeeded) { ModelState.AddPropertyError( - new ValidationResult("Invalid user name", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); + new ValidationResult("Invalid password: " + MapErrors(validPassword.Errors), new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}password"); return false; } - - if (contentItem.Password != null && !contentItem.Password.NewPassword.IsNullOrWhiteSpace()) - { - IdentityResult validPassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword); - if (!validPassword.Succeeded) - { - ModelState.AddPropertyError( - new ValidationResult("Invalid password: " + MapErrors(validPassword.Errors), new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}password"); - return false; - } - } - - IMember? byUsername = _memberService.GetByUsername(contentItem.Username); - if (byUsername != null && byUsername.Key != contentItem.Key) - { - ModelState.AddPropertyError( - new ValidationResult("Username is already in use", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); - return false; - } - - IMember? byEmail = _memberService.GetByEmail(contentItem.Email); - if (byEmail != null && byEmail.Key != contentItem.Key) - { - ModelState.AddPropertyError( - new ValidationResult("Email address is already in use", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); - return false; - } - - return true; } - private string MapErrors(IEnumerable result) + IMember? byUsername = _memberService.GetByUsername(contentItem.Username); + if (byUsername != null && byUsername.Key != contentItem.Key) { - var sb = new StringBuilder(); - IEnumerable identityErrors = result.ToList(); - foreach (IdentityError error in identityErrors) - { - string errorString = $"{error.Description}"; - sb.AppendLine(errorString); - } - - return sb.ToString(); + ModelState.AddPropertyError( + new ValidationResult("Username is already in use", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); + return false; } - /// - /// Add or update the identity roles - /// - /// The groups to updates - /// The member as an identity user - private async Task> AddOrUpdateRoles(IEnumerable? groups, MemberIdentityUser identityMember) + IMember? byEmail = _memberService.GetByEmail(contentItem.Email); + if (byEmail != null && byEmail.Key != contentItem.Key) { - var hasChanges = false; - - // We're gonna look up the current roles now because the below code can cause - // events to be raised and developers could be manually adding roles to members in - // their handlers. If we don't look this up now there's a chance we'll just end up - // removing the roles they've assigned. - IEnumerable currentRoles = await _memberManager.GetRolesAsync(identityMember); - - // find the ones to remove and remove them - IEnumerable roles = currentRoles.ToList(); - string[] rolesToRemove = roles.Except(groups ?? Enumerable.Empty()).ToArray(); - - // Now let's do the role provider stuff - now that we've saved the content item (that is important since - // if we are changing the username, it must be persisted before looking up the member roles). - if (rolesToRemove.Any()) - { - IdentityResult identityResult = await _memberManager.RemoveFromRolesAsync(identityMember, rolesToRemove); - if (!identityResult.Succeeded) - { - return ValidationProblem(identityResult.Errors.ToErrorMessage()); - } - hasChanges = true; - } - - // find the ones to add and add them - string[]? toAdd = groups?.Except(roles).ToArray(); - if (toAdd?.Any() ?? false) - { - // add the ones submitted - IdentityResult identityResult = await _memberManager.AddToRolesAsync(identityMember, toAdd); - if (!identityResult.Succeeded) - { - return ValidationProblem(identityResult.Errors.ToErrorMessage()); - } - hasChanges = true; - } - - return hasChanges; + ModelState.AddPropertyError( + new ValidationResult("Email address is already in use", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); + return false; } - /// - /// Permanently deletes a member - /// - /// Guid of the member to delete - /// The result of the deletion - /// - [HttpPost] - public IActionResult DeleteByKey(Guid key) + return true; + } + + private string MapErrors(IEnumerable result) + { + var sb = new StringBuilder(); + IEnumerable identityErrors = result.ToList(); + foreach (IdentityError error in identityErrors) { - IMember? foundMember = _memberService.GetByKey(key); - if (foundMember == null) - { - return HandleContentNotFound(key); - } - - _memberService.Delete(foundMember); - - return Ok(); + var errorString = $"{error.Description}"; + sb.AppendLine(errorString); } - /// - /// Exports member data based on their unique Id - /// - /// The unique member identifier - /// - [HttpGet] - public IActionResult ExportMemberData(Guid key) + return sb.ToString(); + } + + /// + /// Add or update the identity roles + /// + /// The groups to updates + /// The member as an identity user + private async Task> AddOrUpdateRoles(IEnumerable? groups, MemberIdentityUser identityMember) + { + var hasChanges = false; + + // We're gonna look up the current roles now because the below code can cause + // events to be raised and developers could be manually adding roles to members in + // their handlers. If we don't look this up now there's a chance we'll just end up + // removing the roles they've assigned. + IEnumerable currentRoles = await _memberManager.GetRolesAsync(identityMember); + + // find the ones to remove and remove them + IEnumerable roles = currentRoles.ToList(); + var rolesToRemove = roles.Except(groups ?? Enumerable.Empty()).ToArray(); + + // Now let's do the role provider stuff - now that we've saved the content item (that is important since + // if we are changing the username, it must be persisted before looking up the member roles). + if (rolesToRemove.Any()) { - IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - - if (currentUser?.HasAccessToSensitiveData() == false) + IdentityResult identityResult = await _memberManager.RemoveFromRolesAsync(identityMember, rolesToRemove); + if (!identityResult.Succeeded) { - return Forbid(); + return ValidationProblem(identityResult.Errors.ToErrorMessage()); } - MemberExportModel? member = ((MemberService)_memberService).ExportMember(key); - if (member is null) - { - throw new NullReferenceException("No member found with key " + key); - } - - var json = _jsonSerializer.Serialize(member); - - var fileName = $"{member.Name}_{member.Email}.txt"; - - // Set custom header so umbRequestHelper.downloadFile can save the correct filename - HttpContext.Response.Headers.Add("x-filename", fileName); - - return File(Encoding.UTF8.GetBytes(json), MediaTypeNames.Application.Octet, fileName); + hasChanges = true; } + + // find the ones to add and add them + var toAdd = groups?.Except(roles).ToArray(); + if (toAdd?.Any() ?? false) + { + // add the ones submitted + IdentityResult identityResult = await _memberManager.AddToRolesAsync(identityMember, toAdd); + if (!identityResult.Succeeded) + { + return ValidationProblem(identityResult.Errors.ToErrorMessage()); + } + + hasChanges = true; + } + + return hasChanges; + } + + /// + /// Permanently deletes a member + /// + /// Guid of the member to delete + /// The result of the deletion + [HttpPost] + public IActionResult DeleteByKey(Guid key) + { + IMember? foundMember = _memberService.GetByKey(key); + if (foundMember == null) + { + return HandleContentNotFound(key); + } + + _memberService.Delete(foundMember); + + return Ok(); + } + + /// + /// Exports member data based on their unique Id + /// + /// The unique member identifier + /// + /// + /// + [HttpGet] + public IActionResult ExportMemberData(Guid key) + { + IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + + if (currentUser?.HasAccessToSensitiveData() == false) + { + return Forbid(); + } + + MemberExportModel? member = ((MemberService)_memberService).ExportMember(key); + if (member is null) + { + throw new NullReferenceException("No member found with key " + key); + } + + var json = _jsonSerializer.Serialize(member); + + var fileName = $"{member.Name}_{member.Email}.txt"; + + // Set custom header so umbRequestHelper.downloadFile can save the correct filename + HttpContext.Response.Headers.Add("x-filename", fileName); + + return File(Encoding.UTF8.GetBytes(json), MediaTypeNames.Application.Octet, fileName); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberGroupController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberGroupController.cs index c5c8771146..f39795f32e 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberGroupController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberGroupController.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; @@ -13,160 +10,160 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// An API controller used for dealing with member groups +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessMemberGroups)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +public class MemberGroupController : UmbracoAuthorizedJsonController { - /// - /// An API controller used for dealing with member groups - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMemberGroups)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - public class MemberGroupController : UmbracoAuthorizedJsonController + private readonly ILocalizedTextService _localizedTextService; + private readonly IMemberGroupService _memberGroupService; + private readonly IUmbracoMapper _umbracoMapper; + + public MemberGroupController( + IMemberGroupService memberGroupService, + IUmbracoMapper umbracoMapper, + ILocalizedTextService localizedTextService) { - private readonly ILocalizedTextService _localizedTextService; - private readonly IMemberGroupService _memberGroupService; - private readonly IUmbracoMapper _umbracoMapper; + _memberGroupService = memberGroupService ?? throw new ArgumentNullException(nameof(memberGroupService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _localizedTextService = + localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + } - public MemberGroupController( - IMemberGroupService memberGroupService, - IUmbracoMapper umbracoMapper, - ILocalizedTextService localizedTextService) + /// + /// Gets the member group json for the member group id + /// + /// + /// + public ActionResult GetById(int id) + { + IMemberGroup? memberGroup = _memberGroupService.GetById(id); + if (memberGroup == null) { - _memberGroupService = memberGroupService ?? throw new ArgumentNullException(nameof(memberGroupService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _localizedTextService = - localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + return NotFound(); } - /// - /// Gets the member group json for the member group id - /// - /// - /// - public ActionResult GetById(int id) - { - IMemberGroup? memberGroup = _memberGroupService.GetById(id); - if (memberGroup == null) - { - return NotFound(); - } + MemberGroupDisplay? dto = _umbracoMapper.Map(memberGroup); + return dto; + } - MemberGroupDisplay? dto = _umbracoMapper.Map(memberGroup); - return dto; + /// + /// Gets the member group json for the member group guid + /// + /// + /// + public ActionResult GetById(Guid id) + { + IMemberGroup? memberGroup = _memberGroupService.GetById(id); + if (memberGroup == null) + { + return NotFound(); } - /// - /// Gets the member group json for the member group guid - /// - /// - /// - public ActionResult GetById(Guid id) - { - IMemberGroup? memberGroup = _memberGroupService.GetById(id); - if (memberGroup == null) - { - return NotFound(); - } + return _umbracoMapper.Map(memberGroup); + } - return _umbracoMapper.Map(memberGroup); + /// + /// Gets the member group json for the member group udi + /// + /// + /// + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) + { + return NotFound(); } - /// - /// Gets the member group json for the member group udi - /// - /// - /// - public ActionResult GetById(Udi id) + IMemberGroup? memberGroup = _memberGroupService.GetById(guidUdi.Guid); + if (memberGroup == null) { - var guidUdi = id as GuidUdi; - if (guidUdi == null) - { - return NotFound(); - } - - IMemberGroup? memberGroup = _memberGroupService.GetById(guidUdi.Guid); - if (memberGroup == null) - { - return NotFound(); - } - - return _umbracoMapper.Map(memberGroup); + return NotFound(); } - public IEnumerable GetByIds([FromQuery] int[] ids) - => _memberGroupService.GetByIds(ids).Select(_umbracoMapper.Map).WhereNotNull(); + return _umbracoMapper.Map(memberGroup); + } - [HttpDelete] - [HttpPost] - public IActionResult DeleteById(int id) + public IEnumerable GetByIds([FromQuery] int[] ids) + => _memberGroupService.GetByIds(ids).Select(_umbracoMapper.Map) + .WhereNotNull(); + + [HttpDelete] + [HttpPost] + public IActionResult DeleteById(int id) + { + IMemberGroup? memberGroup = _memberGroupService.GetById(id); + if (memberGroup == null) { - IMemberGroup? memberGroup = _memberGroupService.GetById(id); - if (memberGroup == null) - { - return NotFound(); - } - - _memberGroupService.Delete(memberGroup); - return Ok(); + return NotFound(); } - public IEnumerable GetAllGroups() - => _memberGroupService.GetAll() - .Select(_umbracoMapper.Map).WhereNotNull(); + _memberGroupService.Delete(memberGroup); + return Ok(); + } - public MemberGroupDisplay? GetEmpty() + public IEnumerable GetAllGroups() + => _memberGroupService.GetAll() + .Select(_umbracoMapper.Map).WhereNotNull(); + + public MemberGroupDisplay? GetEmpty() + { + var item = new MemberGroup(); + return _umbracoMapper.Map(item); + } + + public bool IsMemberGroupNameUnique(int id, string? oldName, string? newName) + { + if (newName == oldName) { - var item = new MemberGroup(); - return _umbracoMapper.Map(item); + return true; // name hasn't changed } - public bool IsMemberGroupNameUnique(int id, string? oldName, string? newName) + IMemberGroup? memberGroup = _memberGroupService.GetByName(newName); + if (memberGroup == null) { - if (newName == oldName) - { - return true; // name hasn't changed - } - - IMemberGroup? memberGroup = _memberGroupService.GetByName(newName); - if (memberGroup == null) - { - return true; // no member group found - } - - return memberGroup.Id == id; + return true; // no member group found } - public ActionResult PostSave(MemberGroupSave saveModel) + return memberGroup.Id == id; + } + + public ActionResult PostSave(MemberGroupSave saveModel) + { + var id = saveModel.Id is not null ? int.Parse(saveModel.Id.ToString()!, CultureInfo.InvariantCulture) : default; + IMemberGroup? memberGroup = id > 0 ? _memberGroupService.GetById(id) : new MemberGroup(); + if (memberGroup == null) { - var id = saveModel.Id is not null ? int.Parse(saveModel.Id.ToString()!, CultureInfo.InvariantCulture) : default; - IMemberGroup? memberGroup = id > 0 ? _memberGroupService.GetById(id) : new MemberGroup(); - if (memberGroup == null) - { - return NotFound(); - } + return NotFound(); + } - if (IsMemberGroupNameUnique(memberGroup.Id, memberGroup.Name, saveModel.Name)) - { - memberGroup.Name = saveModel.Name; - _memberGroupService.Save(memberGroup); + if (IsMemberGroupNameUnique(memberGroup.Id, memberGroup.Name, saveModel.Name)) + { + memberGroup.Name = saveModel.Name; + _memberGroupService.Save(memberGroup); - MemberGroupDisplay? display = _umbracoMapper.Map(memberGroup); + MemberGroupDisplay? display = _umbracoMapper.Map(memberGroup); - display?.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles", "memberGroupSavedHeader"), - string.Empty); + display?.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "memberGroupSavedHeader"), + string.Empty); - return display; - } - else - { - MemberGroupDisplay? display = _umbracoMapper.Map(memberGroup); - display?.AddErrorNotification( - _localizedTextService.Localize("speechBubbles", "memberGroupNameDuplicate"), - string.Empty); + return display; + } + else + { + MemberGroupDisplay? display = _umbracoMapper.Map(memberGroup); + display?.AddErrorNotification( + _localizedTextService.Localize("speechBubbles", "memberGroupNameDuplicate"), + string.Empty); - return display; - } + return display; } } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberTypeController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberTypeController.cs index a73fb442ca..984cff0582 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberTypeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberTypeController.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; @@ -15,256 +12,265 @@ using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// An API controller used for dealing with member types +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessMemberTypes)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +public class MemberTypeController : ContentTypeControllerBase { - /// - /// An API controller used for dealing with member types - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMemberTypes)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - public class MemberTypeController : ContentTypeControllerBase + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly ILocalizedTextService _localizedTextService; + private readonly IMemberTypeService _memberTypeService; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; + + public MemberTypeController( + ICultureDictionary cultureDictionary, + EditorValidatorCollection editorValidatorCollection, + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService, + IUmbracoMapper umbracoMapper, + ILocalizedTextService localizedTextService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IShortStringHelper shortStringHelper) + : base( + cultureDictionary, + editorValidatorCollection, + contentTypeService, + mediaTypeService, + memberTypeService, + umbracoMapper, + localizedTextService) { - private readonly IMemberTypeService _memberTypeService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IShortStringHelper _shortStringHelper; - private readonly IUmbracoMapper _umbracoMapper; - private readonly ILocalizedTextService _localizedTextService; + _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _localizedTextService = + localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + } - public MemberTypeController( - ICultureDictionary cultureDictionary, - EditorValidatorCollection editorValidatorCollection, - IContentTypeService contentTypeService, - IMediaTypeService mediaTypeService, - IMemberTypeService memberTypeService, - IUmbracoMapper umbracoMapper, - ILocalizedTextService localizedTextService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IShortStringHelper shortStringHelper) - : base(cultureDictionary, - editorValidatorCollection, - contentTypeService, - mediaTypeService, - memberTypeService, - umbracoMapper, - localizedTextService) + /// + /// Gets the member type a given id + /// + /// + /// + public ActionResult GetById(int id) + { + IMemberType? mt = _memberTypeService.Get(id); + if (mt == null) { - _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _localizedTextService = - localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + return NotFound(); } - /// - /// Gets the member type a given id - /// - /// - /// - public ActionResult GetById(int id) - { - var mt = _memberTypeService.Get(id); - if (mt == null) - { - return NotFound(); - } + MemberTypeDisplay? dto = _umbracoMapper.Map(mt); + return dto; + } - var dto =_umbracoMapper.Map(mt); - return dto; + /// + /// Gets the member type a given guid + /// + /// + /// + public ActionResult GetById(Guid id) + { + IMemberType? memberType = _memberTypeService.Get(id); + if (memberType == null) + { + return NotFound(); } - /// - /// Gets the member type a given guid - /// - /// - /// - public ActionResult GetById(Guid id) - { - var memberType = _memberTypeService.Get(id); - if (memberType == null) - { - return NotFound(); - } + MemberTypeDisplay? dto = _umbracoMapper.Map(memberType); + return dto; + } - var dto = _umbracoMapper.Map(memberType); - return dto; + /// + /// Gets the member type a given udi + /// + /// + /// + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) + { + return NotFound(); } - /// - /// Gets the member type a given udi - /// - /// - /// - public ActionResult GetById(Udi id) + IMemberType? memberType = _memberTypeService.Get(guidUdi.Guid); + if (memberType == null) { - var guidUdi = id as GuidUdi; - if (guidUdi == null) - return NotFound(); - - var memberType = _memberTypeService.Get(guidUdi.Guid); - if (memberType == null) - { - return NotFound(); - } - - var dto = _umbracoMapper.Map(memberType); - return dto; + return NotFound(); } - /// - /// Deletes a document type with a given id - /// - /// - /// - [HttpDelete] - [HttpPost] - public IActionResult DeleteById(int id) - { - var foundType = _memberTypeService.Get(id); - if (foundType == null) - { - return NotFound(); - } + MemberTypeDisplay? dto = _umbracoMapper.Map(memberType); + return dto; + } - _memberTypeService.Delete(foundType, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - return Ok(); + /// + /// Deletes a document type with a given id + /// + /// + /// + [HttpDelete] + [HttpPost] + public IActionResult DeleteById(int id) + { + IMemberType? foundType = _memberTypeService.Get(id); + if (foundType == null) + { + return NotFound(); } - /// - /// Returns the available compositions for this content type - /// - /// - /// - /// This is normally an empty list but if additional content type aliases are passed in, any content types containing those aliases will be filtered out - /// along with any content types that have matching property types that are included in the filtered content types - /// - /// - /// This is normally an empty list but if additional property type aliases are passed in, any content types that have these aliases will be filtered out. - /// This is required because in the case of creating/modifying a content type because new property types being added to it are not yet persisted so cannot - /// be looked up via the db, they need to be passed in. - /// - /// - public IActionResult GetAvailableCompositeMemberTypes(int contentTypeId, - [FromQuery]string[] filterContentTypes, - [FromQuery]string[] filterPropertyTypes) + _memberTypeService.Delete(foundType, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + return Ok(); + } + + /// + /// Returns the available compositions for this content type + /// + /// + /// + /// This is normally an empty list but if additional content type aliases are passed in, any content types containing + /// those aliases will be filtered out + /// along with any content types that have matching property types that are included in the filtered content types + /// + /// + /// This is normally an empty list but if additional property type aliases are passed in, any content types that have + /// these aliases will be filtered out. + /// This is required because in the case of creating/modifying a content type because new property types being added to + /// it are not yet persisted so cannot + /// be looked up via the db, they need to be passed in. + /// + /// + public IActionResult GetAvailableCompositeMemberTypes( + int contentTypeId, + [FromQuery] string[] filterContentTypes, + [FromQuery] string[] filterPropertyTypes) + { + ActionResult>> actionResult = PerformGetAvailableCompositeContentTypes( + contentTypeId, + UmbracoObjectTypes.MemberType, + filterContentTypes, + filterPropertyTypes, + false); + + if (!(actionResult.Result is null)) { - var actionResult = PerformGetAvailableCompositeContentTypes(contentTypeId, - UmbracoObjectTypes.MemberType, filterContentTypes, filterPropertyTypes, - false); + return actionResult.Result; + } - if (!(actionResult.Result is null)) + var result = actionResult.Value? + .Select(x => new { contentType = x.Item1, allowed = x.Item2 }); + return Ok(result); + } + + public MemberTypeDisplay? GetEmpty() + { + var ct = new MemberType(_shortStringHelper, -1) + { + Icon = Constants.Icons.Member + }; + + MemberTypeDisplay? dto = _umbracoMapper.Map(ct); + return dto; + } + + + /// + /// Returns all member types + /// + [Obsolete( + "Use MemberTypeQueryController.GetAllTypes instead as it only requires AuthorizationPolicies.TreeAccessMembersOrMemberTypes and not both this and AuthorizationPolicies.TreeAccessMemberTypes")] + [Authorize(Policy = AuthorizationPolicies.TreeAccessMembersOrMemberTypes)] + public IEnumerable GetAllTypes() => + _memberTypeService.GetAll() + .Select(_umbracoMapper.Map).WhereNotNull(); + + public ActionResult PostSave(MemberTypeSave contentTypeSave) + { + //get the persisted member type + var ctId = Convert.ToInt32(contentTypeSave.Id); + IMemberType? ct = ctId > 0 ? _memberTypeService.Get(ctId) : null; + + if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasAccessToSensitiveData() == false) + { + //We need to validate if any properties on the contentTypeSave have had their IsSensitiveValue changed, + //and if so, we need to check if the current user has access to sensitive values. If not, we have to return an error + IEnumerable props = contentTypeSave.Groups.SelectMany(x => x.Properties); + if (ct != null) { - return actionResult.Result; - } - - var result = actionResult.Value? - .Select(x => new + foreach (MemberPropertyTypeBasic prop in props) { - contentType = x.Item1, - allowed = x.Item2 - }); - return Ok(result); - } - - public MemberTypeDisplay? GetEmpty() - { - var ct = new MemberType(_shortStringHelper, -1); - ct.Icon = Constants.Icons.Member; - - var dto =_umbracoMapper.Map(ct); - return dto; - } - - - /// - /// Returns all member types - /// - [Obsolete("Use MemberTypeQueryController.GetAllTypes instead as it only requires AuthorizationPolicies.TreeAccessMembersOrMemberTypes and not both this and AuthorizationPolicies.TreeAccessMemberTypes")] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMembersOrMemberTypes)] - public IEnumerable GetAllTypes() - { - return _memberTypeService.GetAll() - .Select(_umbracoMapper.Map).WhereNotNull(); - } - - public ActionResult PostSave(MemberTypeSave contentTypeSave) - { - //get the persisted member type - var ctId = Convert.ToInt32(contentTypeSave.Id); - var ct = ctId > 0 ? _memberTypeService.Get(ctId) : null; - - if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasAccessToSensitiveData() == false) - { - //We need to validate if any properties on the contentTypeSave have had their IsSensitiveValue changed, - //and if so, we need to check if the current user has access to sensitive values. If not, we have to return an error - var props = contentTypeSave.Groups.SelectMany(x => x.Properties); - if (ct != null) - { - foreach (var prop in props) + // Id 0 means the property was just added, no need to look it up + if (prop.Id == 0) { - // Id 0 means the property was just added, no need to look it up - if (prop.Id == 0) - continue; - - var foundOnContentType = ct.PropertyTypes.FirstOrDefault(x => x.Id == prop.Id); - if (foundOnContentType == null) - { - return NotFound(new - { Message = "No property type with id " + prop.Id + " found on the content type" }); - } - - if (ct.IsSensitiveProperty(foundOnContentType.Alias) && prop.IsSensitiveData == false) - { - //if these don't match, then we cannot continue, this user is not allowed to change this value - return Forbid(); - } + continue; } - } - else - { - //if it is new, then we can just verify if any property has sensitive data turned on which is not allowed - if (props.Any(prop => prop.IsSensitiveData)) + + IPropertyType? foundOnContentType = ct.PropertyTypes.FirstOrDefault(x => x.Id == prop.Id); + if (foundOnContentType == null) { + return NotFound(new + { + Message = "No property type with id " + prop.Id + " found on the content type" + }); + } + + if (ct.IsSensitiveProperty(foundOnContentType.Alias) && prop.IsSensitiveData == false) + { + //if these don't match, then we cannot continue, this user is not allowed to change this value return Forbid(); } } } - - - var savedCt = PerformPostSave( - contentTypeSave: contentTypeSave, - getContentType: i => ct, - saveContentType: type => _memberTypeService.Save(type)); - - if (!(savedCt.Result is null)) + else { - return savedCt.Result; + //if it is new, then we can just verify if any property has sensitive data turned on which is not allowed + if (props.Any(prop => prop.IsSensitiveData)) + { + return Forbid(); + } } - - var display =_umbracoMapper.Map(savedCt.Value); - - display?.AddSuccessNotification( - _localizedTextService.Localize("speechBubbles","memberTypeSavedHeader"), - string.Empty); - - return display; } - /// - /// Copy the member type - /// - /// - /// - [Authorize(Policy = AuthorizationPolicies.TreeAccessMemberTypes)] - public IActionResult PostCopy(MoveOrCopy copy) + + ActionResult savedCt = + PerformPostSave( + contentTypeSave, + i => ct, + type => _memberTypeService.Save(type)); + + if (!(savedCt.Result is null)) { - return PerformCopy( - copy, - i => _memberTypeService.Get(i), - (type, i) => _memberTypeService.Copy(type, i)); + return savedCt.Result; } + + MemberTypeDisplay? display = _umbracoMapper.Map(savedCt.Value); + + display?.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "memberTypeSavedHeader"), + string.Empty); + + return display; } + + /// + /// Copy the member type + /// + /// + /// + [Authorize(Policy = AuthorizationPolicies.TreeAccessMemberTypes)] + public IActionResult PostCopy(MoveOrCopy copy) => + PerformCopy( + copy, + i => _memberTypeService.Get(i), + (type, i) => _memberTypeService.Copy(type, i)); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberTypeQueryController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberTypeQueryController.cs index e2dd4b8a4c..64464b4cb9 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberTypeQueryController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberTypeQueryController.cs @@ -1,7 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -9,35 +7,32 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// An API controller used for dealing with member types +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessMembersOrMemberTypes)] +public class MemberTypeQueryController : BackOfficeNotificationsController { - /// - /// An API controller used for dealing with member types - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMembersOrMemberTypes)] - public class MemberTypeQueryController : BackOfficeNotificationsController + private readonly IMemberTypeService _memberTypeService; + private readonly IUmbracoMapper _umbracoMapper; + + + public MemberTypeQueryController( + IMemberTypeService memberTypeService, + IUmbracoMapper umbracoMapper) { - private readonly IMemberTypeService _memberTypeService; - private readonly IUmbracoMapper _umbracoMapper; - - - public MemberTypeQueryController( - IMemberTypeService memberTypeService, - IUmbracoMapper umbracoMapper) - { - _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - } - - /// - /// Returns all member types - /// - public IEnumerable GetAllTypes() => - _memberTypeService.GetAll() - .Select(_umbracoMapper.Map).WhereNotNull(); - + _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); } + + /// + /// Returns all member types + /// + public IEnumerable GetAllTypes() => + _memberTypeService.GetAll() + .Select(_umbracoMapper.Map).WhereNotNull(); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/PackageController.cs b/src/Umbraco.Web.BackOffice/Controllers/PackageController.cs index 5faf83d430..33e52c6fea 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PackageController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PackageController.cs @@ -1,164 +1,163 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net; +using System.Net.Mime; using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Packaging; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Install; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -using Microsoft.Extensions.Logging; -using Umbraco.Cms.Infrastructure.Install; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// A controller used for managing packages in the back office +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessPackages)] +public class PackageController : UmbracoAuthorizedJsonController { - /// - /// A controller used for managing packages in the back office - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessPackages)] - public class PackageController : UmbracoAuthorizedJsonController + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly ILogger _logger; + private readonly PackageMigrationRunner _packageMigrationRunner; + private readonly IPackagingService _packagingService; + + public PackageController( + IPackagingService packagingService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + PackageMigrationRunner packageMigrationRunner, + ILogger logger) { - private readonly IPackagingService _packagingService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly PackageMigrationRunner _packageMigrationRunner; - private readonly ILogger _logger; - - public PackageController( - IPackagingService packagingService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - PackageMigrationRunner packageMigrationRunner, - ILogger logger) - { - _packagingService = packagingService ?? throw new ArgumentNullException(nameof(packagingService)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _packageMigrationRunner = packageMigrationRunner; - _logger = logger; - } - - public IEnumerable GetCreatedPackages() - { - return _packagingService.GetAllCreatedPackages().WhereNotNull(); - } - - public ActionResult GetCreatedPackageById(int id) - { - var package = _packagingService.GetCreatedPackageById(id); - if (package == null) - return NotFound(); - - return package; - } - - public PackageDefinition GetEmpty() => new PackageDefinition(); - - /// - /// Creates or updates a package - /// - /// - /// - public ActionResult PostSavePackage(PackageDefinition model) - { - if (ModelState.IsValid == false) - return ValidationProblem(ModelState); - - // Save it - if (!_packagingService.SaveCreatedPackage(model)) - { - return ValidationProblem( - model.Id == default - ? $"A package with the name {model.Name} already exists" - : $"The package with id {model.Id} was not found"); - } - - // The packagePath will be on the model - return model; - } - - /// - /// Deletes a created package - /// - /// - /// - [HttpPost] - [HttpDelete] - public IActionResult DeleteCreatedPackage(int packageId) - { - _packagingService.DeleteCreatedPackage(packageId, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); - - return Ok(); - } - - [HttpPost] - public ActionResult> RunMigrations([FromQuery]string packageName) - { - try - { - _packageMigrationRunner.RunPackageMigrationsIfPending(packageName); - return _packagingService.GetAllInstalledPackages().ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Package migration failed on package {Package}", packageName); - - return ValidationErrorResult.CreateNotificationValidationErrorResult( - $"Package migration failed on package {packageName} with error: {ex.Message}. Check log for full details."); - } - } - - [HttpGet] - public IActionResult DownloadCreatedPackage(int id) - { - var package = _packagingService.GetCreatedPackageById(id); - if (package == null) - return NotFound(); - - if (!System.IO.File.Exists(package.PackagePath)) - return ValidationProblem("No file found for path " + package.PackagePath); - - var fileName = Path.GetFileName(package.PackagePath); - - var encoding = Encoding.UTF8; - - var cd = new System.Net.Mime.ContentDisposition - { - FileName = WebUtility.UrlEncode(fileName), - Inline = false // false = prompt the user for downloading; true = browser to try to show the file inline - }; - Response.Headers.Add("Content-Disposition", cd.ToString()); - // Set custom header so umbRequestHelper.downloadFile can save the correct filename - Response.Headers.Add("x-filename", WebUtility.UrlEncode(fileName)); - return new FileStreamResult(System.IO.File.OpenRead(package.PackagePath), new MediaTypeHeaderValue("application/octet-stream") - { - Charset = encoding.WebName, - }); - - } - - public ActionResult GetInstalledPackageByName([FromQuery] string packageName) - { - InstalledPackage? pack = _packagingService.GetInstalledPackageByName(packageName); - if (pack == null) - { - return NotFound(); - } - - return pack; - } - - /// - /// Returns all installed packages - only shows their latest versions - /// - /// - public IEnumerable GetInstalled() - => _packagingService.GetAllInstalledPackages().ToList(); + _packagingService = packagingService ?? throw new ArgumentNullException(nameof(packagingService)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _packageMigrationRunner = packageMigrationRunner; + _logger = logger; } + + public IEnumerable GetCreatedPackages() => + _packagingService.GetAllCreatedPackages().WhereNotNull(); + + public ActionResult GetCreatedPackageById(int id) + { + PackageDefinition? package = _packagingService.GetCreatedPackageById(id); + if (package == null) + { + return NotFound(); + } + + return package; + } + + public PackageDefinition GetEmpty() => new(); + + /// + /// Creates or updates a package + /// + /// + /// + public ActionResult PostSavePackage(PackageDefinition model) + { + if (ModelState.IsValid == false) + { + return ValidationProblem(ModelState); + } + + // Save it + if (!_packagingService.SaveCreatedPackage(model)) + { + return ValidationProblem( + model.Id == default + ? $"A package with the name {model.Name} already exists" + : $"The package with id {model.Id} was not found"); + } + + // The packagePath will be on the model + return model; + } + + /// + /// Deletes a created package + /// + /// + /// + [HttpPost] + [HttpDelete] + public IActionResult DeleteCreatedPackage(int packageId) + { + _packagingService.DeleteCreatedPackage(packageId, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1); + + return Ok(); + } + + [HttpPost] + public ActionResult> RunMigrations([FromQuery] string packageName) + { + try + { + _packageMigrationRunner.RunPackageMigrationsIfPending(packageName); + return _packagingService.GetAllInstalledPackages().ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Package migration failed on package {Package}", packageName); + + return ValidationErrorResult.CreateNotificationValidationErrorResult( + $"Package migration failed on package {packageName} with error: {ex.Message}. Check log for full details."); + } + } + + [HttpGet] + public IActionResult DownloadCreatedPackage(int id) + { + PackageDefinition? package = _packagingService.GetCreatedPackageById(id); + if (package == null) + { + return NotFound(); + } + + if (!System.IO.File.Exists(package.PackagePath)) + { + return ValidationProblem("No file found for path " + package.PackagePath); + } + + var fileName = Path.GetFileName(package.PackagePath); + + Encoding encoding = Encoding.UTF8; + + var cd = new ContentDisposition + { + FileName = WebUtility.UrlEncode(fileName), + Inline = false // false = prompt the user for downloading; true = browser to try to show the file inline + }; + Response.Headers.Add("Content-Disposition", cd.ToString()); + // Set custom header so umbRequestHelper.downloadFile can save the correct filename + Response.Headers.Add("x-filename", WebUtility.UrlEncode(fileName)); + return new FileStreamResult(System.IO.File.OpenRead(package.PackagePath), new MediaTypeHeaderValue("application/octet-stream") { Charset = encoding.WebName }); + } + + public ActionResult GetInstalledPackageByName([FromQuery] string packageName) + { + InstalledPackage? pack = _packagingService.GetInstalledPackageByName(packageName); + if (pack == null) + { + return NotFound(); + } + + return pack; + } + + /// + /// Returns all installed packages - only shows their latest versions + /// + /// + public IEnumerable GetInstalled() + => _packagingService.GetAllInstalledPackages().ToList(); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ParameterSwapControllerActionSelectorAttribute.cs b/src/Umbraco.Web.BackOffice/Controllers/ParameterSwapControllerActionSelectorAttribute.cs index c6840b8db7..32f6b2c14d 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ParameterSwapControllerActionSelectorAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ParameterSwapControllerActionSelectorAttribute.cs @@ -1,226 +1,222 @@ -using System; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.Extensions.Primitives; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// +/// This attribute is odd because it applies at class level where some methods may use it whilst others don't. +/// +/// +/// What we should probably have (if we really even need something like this at all) is an attribute for method +/// level. +/// +/// +/// +/// [HasParameterFromUriOrBodyOfType("ids", typeof(Guid[]))] +/// public IActionResult GetByIds([FromJsonPath] Guid[] ids) { } +/// +/// [HasParameterFromUriOrBodyOfType("ids", typeof(int[]))] +/// public IActionResult GetByIds([FromJsonPath] int[] ids) { } +/// +/// +/// +/// +/// That way we wouldn't need confusing things like Accept returning true when action name doesn't even match +/// attribute metadata. +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +internal class ParameterSwapControllerActionSelectorAttribute : Attribute, IActionConstraint { - /// - /// - /// This attribute is odd because it applies at class level where some methods may use it whilst others don't. - /// - /// - /// - /// What we should probably have (if we really even need something like this at all) is an attribute for method level. - /// - /// - /// - /// - /// [HasParameterFromUriOrBodyOfType("ids", typeof(Guid[]))] - /// public IActionResult GetByIds([FromJsonPath] Guid[] ids) { } - /// - /// [HasParameterFromUriOrBodyOfType("ids", typeof(int[]))] - /// public IActionResult GetByIds([FromJsonPath] int[] ids) { } - /// - /// - /// - /// - /// - /// That way we wouldn't need confusing things like Accept returning true when action name doesn't even match attribute metadata. - /// - /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] - internal class ParameterSwapControllerActionSelectorAttribute : Attribute, IActionConstraint + private readonly string _actionName; + private readonly string _parameterName; + private readonly Type[] _supportedTypes; + + public ParameterSwapControllerActionSelectorAttribute(string actionName, string parameterName, + params Type[] supportedTypes) { + _actionName = actionName; + _parameterName = parameterName; + _supportedTypes = supportedTypes; + } - private readonly string _actionName; - private readonly string _parameterName; - private readonly Type[] _supportedTypes; + /// + public int Order { get; set; } = 101; - public ParameterSwapControllerActionSelectorAttribute(string actionName, string parameterName, params Type[] supportedTypes) + /// + public bool Accept(ActionConstraintContext context) + { + if (!IsValidCandidate(context.CurrentCandidate)) { - _actionName = actionName; - _parameterName = parameterName; - _supportedTypes = supportedTypes; + // See remarks on class, required because we apply at class level + // and some controllers have some actions with parameter swaps and others without. + return true; } - /// - public int Order { get; set; } = 101; + ActionSelectorCandidate? chosenCandidate = SelectAction(context); - /// - public bool Accept(ActionConstraintContext context) + var found = context.CurrentCandidate.Equals(chosenCandidate); + return found; + } + + private ActionSelectorCandidate? SelectAction(ActionConstraintContext context) + { + if (TryBindFromUri(context, out ActionSelectorCandidate? candidate)) { - if (!IsValidCandidate(context.CurrentCandidate)) - { - // See remarks on class, required because we apply at class level - // and some controllers have some actions with parameter swaps and others without. - return true; - } - - ActionSelectorCandidate? chosenCandidate = SelectAction(context); - - var found = context.CurrentCandidate.Equals(chosenCandidate); - return found; + return candidate; } - private ActionSelectorCandidate? SelectAction(ActionConstraintContext context) + HttpContext httpContext = context.RouteContext.HttpContext; + + // if it's a post we can try to read from the body and bind from the json value + if (context.RouteContext.HttpContext.Request.Method.Equals(HttpMethod.Post.Method)) { - if (TryBindFromUri(context, out var candidate)) + JObject? postBodyJson; + + if (httpContext.Items.TryGetValue(Constants.HttpContext.Items.RequestBodyAsJObject, out var value) && + value is JObject cached) { - return candidate; + postBodyJson = cached; } - - HttpContext httpContext = context.RouteContext.HttpContext; - - // if it's a post we can try to read from the body and bind from the json value - if (context.RouteContext.HttpContext.Request.Method.Equals(HttpMethod.Post.Method)) + else { - JObject? postBodyJson; - - if (httpContext.Items.TryGetValue(Constants.HttpContext.Items.RequestBodyAsJObject, out var value) && value is JObject cached) + // We need to use the asynchronous method here if synchronous IO is not allowed (it may or may not be, depending + // on configuration in UmbracoBackOfficeServiceCollectionExtensions.AddUmbraco()). + // We can't use async/await due to the need to override IsValidForRequest, which doesn't have an async override, so going with + // this, which seems to be the least worst option for "sync to async" (https://stackoverflow.com/a/32429753/489433). + // + // To expand on the above, if KestrelServerOptions/IISServerOptions is AllowSynchronousIO=false + // And you attempt to read stream sync an InvalidOperationException is thrown with message + // "Synchronous operations are disallowed. Call ReadAsync or set AllowSynchronousIO to true instead." + var rawBody = Task.Run(() => httpContext.Request.GetRawBodyStringAsync()).GetAwaiter().GetResult(); + try { - postBodyJson = cached; + postBodyJson = JsonConvert.DeserializeObject(rawBody); + httpContext.Items[Constants.HttpContext.Items.RequestBodyAsJObject] = postBodyJson; } - else + catch (JsonException) { - // We need to use the asynchronous method here if synchronous IO is not allowed (it may or may not be, depending - // on configuration in UmbracoBackOfficeServiceCollectionExtensions.AddUmbraco()). - // We can't use async/await due to the need to override IsValidForRequest, which doesn't have an async override, so going with - // this, which seems to be the least worst option for "sync to async" (https://stackoverflow.com/a/32429753/489433). - // - // To expand on the above, if KestrelServerOptions/IISServerOptions is AllowSynchronousIO=false - // And you attempt to read stream sync an InvalidOperationException is thrown with message - // "Synchronous operations are disallowed. Call ReadAsync or set AllowSynchronousIO to true instead." - var rawBody = Task.Run(() => httpContext.Request.GetRawBodyStringAsync()).GetAwaiter().GetResult(); - try - { - postBodyJson = JsonConvert.DeserializeObject(rawBody); - httpContext.Items[Constants.HttpContext.Items.RequestBodyAsJObject] = postBodyJson; - } - catch (JsonException) - { - postBodyJson = null; - } - } - - if (postBodyJson == null) - { - return null; - } - - var requestParam = postBodyJson[_parameterName]; - - if (requestParam != null) - { - var paramTypes = _supportedTypes; - - foreach (var paramType in paramTypes) - { - try - { - var converted = requestParam.ToObject(paramType); - if (converted != null) - { - var foundCandidate = MatchByType(paramType, context); - if (foundCandidate.HasValue) - { - return foundCandidate; - } - } - } - catch (JsonException) - { - // can't convert - } - } + postBodyJson = null; } } - return null; - } - - private bool TryBindFromUri(ActionConstraintContext context, out ActionSelectorCandidate? foundCandidate) - { - - string? requestParam = null; - if (context.RouteContext.HttpContext.Request.Query.TryGetValue(_parameterName, out var stringValues)) + if (postBodyJson == null) { - requestParam = stringValues.ToString(); + return null; } - if (requestParam is null && context.RouteContext.RouteData.Values.TryGetValue(_parameterName, out var value)) - { - requestParam = value?.ToString(); - } - - if (requestParam == string.Empty && _supportedTypes.Length > 0) - { - // if it's empty then in theory we can select any of the actions since they'll all need to deal with empty or null parameters - // so we'll try to use the first one available - foundCandidate = MatchByType(_supportedTypes[0], context); - if (foundCandidate.HasValue) - { - return true; - } - } + JToken? requestParam = postBodyJson[_parameterName]; if (requestParam != null) { - foreach (var paramType in _supportedTypes) - { - // check if this is IEnumerable and if so this will get it's type - // we need to know this since the requestParam will always just be a string - var enumType = paramType.GetEnumeratedType(); + Type[] paramTypes = _supportedTypes; - var converted = requestParam.TryConvertTo(enumType ?? paramType); - if (converted.Success) + foreach (Type paramType in paramTypes) + { + try { - foundCandidate = MatchByType(paramType, context); - if (foundCandidate.HasValue) + var converted = requestParam.ToObject(paramType); + if (converted != null) { - return true; + ActionSelectorCandidate? foundCandidate = MatchByType(paramType, context); + if (foundCandidate.HasValue) + { + return foundCandidate; + } } } + catch (JsonException) + { + // can't convert + } } } + } - foundCandidate = null; + return null; + } + + private bool TryBindFromUri(ActionConstraintContext context, out ActionSelectorCandidate? foundCandidate) + { + string? requestParam = null; + if (context.RouteContext.HttpContext.Request.Query.TryGetValue(_parameterName, out StringValues stringValues)) + { + requestParam = stringValues.ToString(); + } + + if (requestParam is null && context.RouteContext.RouteData.Values.TryGetValue(_parameterName, out var value)) + { + requestParam = value?.ToString(); + } + + if (requestParam == string.Empty && _supportedTypes.Length > 0) + { + // if it's empty then in theory we can select any of the actions since they'll all need to deal with empty or null parameters + // so we'll try to use the first one available + foundCandidate = MatchByType(_supportedTypes[0], context); + if (foundCandidate.HasValue) + { + return true; + } + } + + if (requestParam != null) + { + foreach (Type paramType in _supportedTypes) + { + // check if this is IEnumerable and if so this will get it's type + // we need to know this since the requestParam will always just be a string + Type? enumType = paramType.GetEnumeratedType(); + + Attempt converted = requestParam.TryConvertTo(enumType ?? paramType); + if (converted.Success) + { + foundCandidate = MatchByType(paramType, context); + if (foundCandidate.HasValue) + { + return true; + } + } + } + } + + foundCandidate = null; + return false; + } + + private ActionSelectorCandidate? MatchByType(Type idType, ActionConstraintContext context) + { + if (context.Candidates.Count() > 1) + { + // choose the one that has the parameter with the T type + ActionSelectorCandidate candidate = context.Candidates.FirstOrDefault(x => + x.Action.Parameters.FirstOrDefault(p => p.Name == _parameterName && p.ParameterType == idType) != null); + + return candidate; + } + + return null; + } + + private bool IsValidCandidate(ActionSelectorCandidate candidate) + { + if (!(candidate.Action is ControllerActionDescriptor controllerActionDescriptor)) + { return false; } - private ActionSelectorCandidate? MatchByType(Type idType, ActionConstraintContext context) + if (controllerActionDescriptor.ActionName != _actionName) { - if (context.Candidates.Count() > 1) - { - // choose the one that has the parameter with the T type - var candidate = context.Candidates.FirstOrDefault(x => x.Action.Parameters.FirstOrDefault(p => p.Name == _parameterName && p.ParameterType == idType) != null); - - return candidate; - } - - return null; + return false; } - private bool IsValidCandidate(ActionSelectorCandidate candidate) - { - if (!(candidate.Action is ControllerActionDescriptor controllerActionDescriptor)) - { - return false; - } - - if (controllerActionDescriptor.ActionName != _actionName) - { - return false; - } - - return true; - } + return true; } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs index 5f41682ee4..19ca323d9d 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs @@ -1,7 +1,3 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewEngines; @@ -11,6 +7,9 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Editors; using Umbraco.Cms.Core.Features; using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -23,130 +22,143 @@ using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers -{ - [DisableBrowserCache] - [Area(Constants.Web.Mvc.BackOfficeArea)] - public class PreviewController : Controller - { - private readonly UmbracoFeatures _features; - private readonly GlobalSettings _globalSettings; - private readonly IPublishedSnapshotService _publishedSnapshotService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly ILocalizationService _localizationService; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly ICookieManager _cookieManager; - private readonly IRuntimeMinifier _runtimeMinifier; - private readonly ICompositeViewEngine _viewEngines; - private readonly IUmbracoContextAccessor _umbracoContextAccessor; +namespace Umbraco.Cms.Web.BackOffice.Controllers; - public PreviewController( - UmbracoFeatures features, - IOptionsSnapshot globalSettings, - IPublishedSnapshotService publishedSnapshotService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - ILocalizationService localizationService, - IHostingEnvironment hostingEnvironment, - ICookieManager cookieManager, - IRuntimeMinifier runtimeMinifier, - ICompositeViewEngine viewEngines, - IUmbracoContextAccessor umbracoContextAccessor) +[DisableBrowserCache] +[Area(Constants.Web.Mvc.BackOfficeArea)] +public class PreviewController : Controller +{ + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly ICookieManager _cookieManager; + private readonly UmbracoFeatures _features; + private readonly GlobalSettings _globalSettings; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly ILocalizationService _localizationService; + private readonly IPublishedSnapshotService _publishedSnapshotService; + private readonly IRuntimeMinifier _runtimeMinifier; + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly ICompositeViewEngine _viewEngines; + + public PreviewController( + UmbracoFeatures features, + IOptionsSnapshot globalSettings, + IPublishedSnapshotService publishedSnapshotService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILocalizationService localizationService, + IHostingEnvironment hostingEnvironment, + ICookieManager cookieManager, + IRuntimeMinifier runtimeMinifier, + ICompositeViewEngine viewEngines, + IUmbracoContextAccessor umbracoContextAccessor) + { + _features = features; + _globalSettings = globalSettings.Value; + _publishedSnapshotService = publishedSnapshotService; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _localizationService = localizationService; + _hostingEnvironment = hostingEnvironment; + _cookieManager = cookieManager; + _runtimeMinifier = runtimeMinifier; + _viewEngines = viewEngines; + _umbracoContextAccessor = umbracoContextAccessor; + } + + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccessWithoutApproval)] + [DisableBrowserCache] + public ActionResult Index(int? id = null) + { + IEnumerable availableLanguages = _localizationService.GetAllLanguages(); + if (id.HasValue) { - _features = features; - _globalSettings = globalSettings.Value; - _publishedSnapshotService = publishedSnapshotService; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _localizationService = localizationService; - _hostingEnvironment = hostingEnvironment; - _cookieManager = cookieManager; - _runtimeMinifier = runtimeMinifier; - _viewEngines = viewEngines; - _umbracoContextAccessor = umbracoContextAccessor; + IUmbracoContext umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); + IPublishedContent? content = umbracoContext.Content?.GetById(true, id.Value); + if (content is null) + { + return NotFound(); + } + + availableLanguages = availableLanguages.Where(language => content.Cultures.ContainsKey(language.IsoCode)); } - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccessWithoutApproval)] - [DisableBrowserCache] - public ActionResult Index(int? id = null) + var model = new BackOfficePreviewModel(_features, availableLanguages); + + if (model.PreviewExtendedHeaderView.IsNullOrWhiteSpace() == false) { - var availableLanguages = _localizationService.GetAllLanguages(); - if (id.HasValue) + ViewEngineResult viewEngineResult = + _viewEngines.FindView(ControllerContext, model.PreviewExtendedHeaderView!, false); + if (viewEngineResult.View == null) { - var umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); - var content = umbracoContext.Content?.GetById(true, id.Value); - if (content is null) - return NotFound(); - - availableLanguages = availableLanguages.Where(language => content.Cultures.ContainsKey(language.IsoCode)); + throw new InvalidOperationException("Could not find the view " + model.PreviewExtendedHeaderView + + ", the following locations were searched: " + Environment.NewLine + + string.Join(Environment.NewLine, + viewEngineResult.SearchedLocations)); } - var model = new BackOfficePreviewModel(_features, availableLanguages); + } - if (model.PreviewExtendedHeaderView.IsNullOrWhiteSpace() == false) - { - var viewEngineResult = _viewEngines.FindView(ControllerContext, model.PreviewExtendedHeaderView!, false); - if (viewEngineResult.View == null) - throw new InvalidOperationException("Could not find the view " + model.PreviewExtendedHeaderView + ", the following locations were searched: " + Environment.NewLine + string.Join(Environment.NewLine, viewEngineResult.SearchedLocations)); - } - - var viewPath = Path.Combine( + var viewPath = Path.Combine( _globalSettings.UmbracoPath, Constants.Web.Mvc.BackOfficeArea, ControllerExtensions.GetControllerName() + ".cshtml") - .Replace("\\", "/"); // convert to forward slashes since it's a virtual path + .Replace("\\", "/"); // convert to forward slashes since it's a virtual path - return View(viewPath, model); - } + return View(viewPath, model); + } - /// - /// Returns the JavaScript file for preview - /// - /// - [MinifyJavaScriptResult(Order = 0)] - // TODO: Replace this with response caching https://docs.microsoft.com/en-us/aspnet/core/performance/caching/response?view=aspnetcore-3.1 - //[OutputCache(Order = 1, VaryByParam = "none", Location = OutputCacheLocation.Server, Duration = 5000)] - public async Task Application() + /// + /// Returns the JavaScript file for preview + /// + /// + [MinifyJavaScriptResult(Order = 0)] + // TODO: Replace this with response caching https://docs.microsoft.com/en-us/aspnet/core/performance/caching/response?view=aspnetcore-3.1 + //[OutputCache(Order = 1, VaryByParam = "none", Location = OutputCacheLocation.Server, Duration = 5000)] + public async Task Application() + { + IEnumerable files = + await _runtimeMinifier.GetJsAssetPathsAsync(BackOfficeWebAssets.UmbracoPreviewJsBundleName); + var result = + BackOfficeJavaScriptInitializer.GetJavascriptInitialization(files, "umbraco.preview", _globalSettings, + _hostingEnvironment); + + return new JavaScriptResult(result); + } + + /// + /// The endpoint that is loaded within the preview iframe + /// + [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] + public ActionResult Frame(int id, string culture) + { + EnterPreview(id); + + // use a numeric URL because content may not be in cache and so .Url would fail + var query = culture.IsNullOrWhiteSpace() ? string.Empty : $"?culture={culture}"; + + return RedirectPermanent($"../../{id}{query}"); + } + + public ActionResult? EnterPreview(int id) + { + IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + _cookieManager.SetCookieValue(Constants.Web.PreviewCookieName, "preview"); + + return new EmptyResult(); + } + + public ActionResult End(string? redir = null) + { + _cookieManager.ExpireCookie(Constants.Web.PreviewCookieName); + + // Expire Client-side cookie that determines whether the user has accepted to be in Preview Mode when visiting the website. + _cookieManager.ExpireCookie(Constants.Web.AcceptPreviewCookieName); + + if (Uri.IsWellFormedUriString(redir, UriKind.Relative) + && redir.StartsWith("//") == false + && Uri.TryCreate(redir, UriKind.Relative, out Uri? url)) { - var files = await _runtimeMinifier.GetJsAssetPathsAsync(BackOfficeWebAssets.UmbracoPreviewJsBundleName); - var result = BackOfficeJavaScriptInitializer.GetJavascriptInitialization(files, "umbraco.preview", _globalSettings, _hostingEnvironment); - - return new JavaScriptResult(result); + return Redirect(url.ToString()); } - /// - /// The endpoint that is loaded within the preview iframe - /// - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - public ActionResult Frame(int id, string culture) - { - EnterPreview(id); - - // use a numeric URL because content may not be in cache and so .Url would fail - var query = culture.IsNullOrWhiteSpace() ? string.Empty : $"?culture={culture}"; - - return RedirectPermanent($"../../{id}{query}"); - } - - public ActionResult? EnterPreview(int id) - { - var user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - _cookieManager.SetCookieValue(Constants.Web.PreviewCookieName, "preview"); - - return new EmptyResult(); - } - - public ActionResult End(string? redir = null) - { - _cookieManager.ExpireCookie(Constants.Web.PreviewCookieName); - - // Expire Client-side cookie that determines whether the user has accepted to be in Preview Mode when visiting the website. - _cookieManager.ExpireCookie(Constants.Web.AcceptPreviewCookieName); - - if (Uri.IsWellFormedUriString(redir, UriKind.Relative) - && redir.StartsWith("//") == false - && Uri.TryCreate(redir, UriKind.Relative, out var url)) - return Redirect(url.ToString()); - - return Redirect("/"); - } + return Redirect("/"); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/PublicAccessController.cs b/src/Umbraco.Web.BackOffice/Controllers/PublicAccessController.cs index 4877f1c805..de2180c812 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PublicAccessController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PublicAccessController.cs @@ -1,15 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Entities; -using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; @@ -17,189 +12,192 @@ using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)] +public class PublicAccessController : BackOfficeNotificationsController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)] - public class PublicAccessController : BackOfficeNotificationsController + private readonly IContentService _contentService; + private readonly IEntityService _entityService; + private readonly IMemberRoleManager _memberRoleManager; + private readonly IMemberService _memberService; + private readonly IPublicAccessService _publicAccessService; + private readonly IUmbracoMapper _umbracoMapper; + + public PublicAccessController( + IPublicAccessService publicAccessService, + IContentService contentService, + IEntityService entityService, + IMemberService memberService, + IUmbracoMapper umbracoMapper, + IMemberRoleManager memberRoleManager) { - private readonly IContentService _contentService; - private readonly IPublicAccessService _publicAccessService; - private readonly IEntityService _entityService; - private readonly IMemberService _memberService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IMemberRoleManager _memberRoleManager; + _contentService = contentService; + _publicAccessService = publicAccessService; + _entityService = entityService; + _memberService = memberService; + _umbracoMapper = umbracoMapper; + _memberRoleManager = memberRoleManager; + } - public PublicAccessController( - IPublicAccessService publicAccessService, - IContentService contentService, - IEntityService entityService, - IMemberService memberService, - IUmbracoMapper umbracoMapper, - IMemberRoleManager memberRoleManager) + [Authorize(Policy = AuthorizationPolicies.ContentPermissionProtectById)] + [HttpGet] + public ActionResult GetPublicAccess(int contentId) + { + IContent? content = _contentService.GetById(contentId); + if (content == null) { - _contentService = contentService; - _publicAccessService = publicAccessService; - _entityService = entityService; - _memberService = memberService; - _umbracoMapper = umbracoMapper; - _memberRoleManager = memberRoleManager; + return NotFound(); } - [Authorize(Policy = AuthorizationPolicies.ContentPermissionProtectById)] - [HttpGet] - public ActionResult GetPublicAccess(int contentId) + PublicAccessEntry? entry = _publicAccessService.GetEntryForContent(content); + if (entry == null || entry.ProtectedNodeId != content.Id) { - IContent? content = _contentService.GetById(contentId); - if (content == null) - { - return NotFound(); - } - - PublicAccessEntry? entry = _publicAccessService.GetEntryForContent(content); - if (entry == null || entry.ProtectedNodeId != content.Id) - { - return Ok(); - } - - var nodes = _entityService - .GetAll(UmbracoObjectTypes.Document, entry.LoginNodeId, entry.NoAccessNodeId) - .ToDictionary(x => x.Id); - - if (!nodes.TryGetValue(entry.LoginNodeId, out IEntitySlim? loginPageEntity)) - { - throw new InvalidOperationException($"Login node with id ${entry.LoginNodeId} was not found"); - } - - if (!nodes.TryGetValue(entry.NoAccessNodeId, out IEntitySlim? errorPageEntity)) - { - throw new InvalidOperationException($"Error node with id ${entry.LoginNodeId} was not found"); - } - - // unwrap the current public access setup for the client - // - this API method is the single point of entry for both "modes" of public access (single user and role based) - var usernames = entry.Rules - .Where(rule => rule.RuleType == Constants.Conventions.PublicAccess.MemberUsernameRuleType) - .Select(rule => rule.RuleValue) - .ToArray(); - - MemberDisplay[] members = usernames - .Select(username => _memberService.GetByUsername(username)) - .Select(_umbracoMapper.Map) - .WhereNotNull() - .ToArray(); - - var allGroups = _memberRoleManager.Roles.Where(x => x.Name != null).ToDictionary(x => x.Name!); - MemberGroupDisplay[] groups = entry.Rules - .Where(rule => rule.RuleType == Constants.Conventions.PublicAccess.MemberRoleRuleType) - .Select(rule => rule.RuleValue is not null && allGroups.TryGetValue(rule.RuleValue, out UmbracoIdentityRole? memberRole) ? memberRole : null) - .Select(_umbracoMapper.Map) - .WhereNotNull() - .ToArray(); - - return new PublicAccess - { - Members = members, - Groups = groups, - LoginPage = loginPageEntity != null ? _umbracoMapper.Map(loginPageEntity) : null, - ErrorPage = errorPageEntity != null ? _umbracoMapper.Map(errorPageEntity) : null - }; + return Ok(); } - // set up public access using role based access - [Authorize(Policy = AuthorizationPolicies.ContentPermissionProtectById)] - [HttpPost] - public IActionResult PostPublicAccess(int contentId, [FromQuery(Name = "groups[]")] string[] groups, [FromQuery(Name = "usernames[]")] string[] usernames, int loginPageId, int errorPageId) + var nodes = _entityService + .GetAll(UmbracoObjectTypes.Document, entry.LoginNodeId, entry.NoAccessNodeId) + .ToDictionary(x => x.Id); + + if (!nodes.TryGetValue(entry.LoginNodeId, out IEntitySlim? loginPageEntity)) { - if ((groups == null || groups.Any() == false) && (usernames == null || usernames.Any() == false)) + throw new InvalidOperationException($"Login node with id ${entry.LoginNodeId} was not found"); + } + + if (!nodes.TryGetValue(entry.NoAccessNodeId, out IEntitySlim? errorPageEntity)) + { + throw new InvalidOperationException($"Error node with id ${entry.LoginNodeId} was not found"); + } + + // unwrap the current public access setup for the client + // - this API method is the single point of entry for both "modes" of public access (single user and role based) + var usernames = entry.Rules + .Where(rule => rule.RuleType == Constants.Conventions.PublicAccess.MemberUsernameRuleType) + .Select(rule => rule.RuleValue) + .ToArray(); + + MemberDisplay[] members = usernames + .Select(username => _memberService.GetByUsername(username)) + .Select(_umbracoMapper.Map) + .WhereNotNull() + .ToArray(); + + var allGroups = _memberRoleManager.Roles.Where(x => x.Name != null).ToDictionary(x => x.Name!); + MemberGroupDisplay[] groups = entry.Rules + .Where(rule => rule.RuleType == Constants.Conventions.PublicAccess.MemberRoleRuleType) + .Select(rule => + rule.RuleValue is not null && allGroups.TryGetValue(rule.RuleValue, out UmbracoIdentityRole? memberRole) + ? memberRole + : null) + .Select(_umbracoMapper.Map) + .WhereNotNull() + .ToArray(); + + return new PublicAccess + { + Members = members, + Groups = groups, + LoginPage = loginPageEntity != null ? _umbracoMapper.Map(loginPageEntity) : null, + ErrorPage = errorPageEntity != null ? _umbracoMapper.Map(errorPageEntity) : null + }; + } + + // set up public access using role based access + [Authorize(Policy = AuthorizationPolicies.ContentPermissionProtectById)] + [HttpPost] + public IActionResult PostPublicAccess(int contentId, [FromQuery(Name = "groups[]")] string[] groups, + [FromQuery(Name = "usernames[]")] string[] usernames, int loginPageId, int errorPageId) + { + if ((groups == null || groups.Any() == false) && (usernames == null || usernames.Any() == false)) + { + return BadRequest(); + } + + IContent? content = _contentService.GetById(contentId); + IContent? loginPage = _contentService.GetById(loginPageId); + IContent? errorPage = _contentService.GetById(errorPageId); + if (content == null || loginPage == null || errorPage == null) + { + return BadRequest(); + } + + var isGroupBased = groups != null && groups.Any(); + var candidateRuleValues = isGroupBased + ? groups + : usernames; + var newRuleType = isGroupBased + ? Constants.Conventions.PublicAccess.MemberRoleRuleType + : Constants.Conventions.PublicAccess.MemberUsernameRuleType; + + PublicAccessEntry? entry = _publicAccessService.GetEntryForContent(content); + + if (entry == null || entry.ProtectedNodeId != content.Id) + { + entry = new PublicAccessEntry(content, loginPage, errorPage, new List()); + + if (candidateRuleValues is not null) { - return BadRequest(); - } - - var content = _contentService.GetById(contentId); - var loginPage = _contentService.GetById(loginPageId); - var errorPage = _contentService.GetById(errorPageId); - if (content == null || loginPage == null || errorPage == null) - { - return BadRequest(); - } - - var isGroupBased = groups != null && groups.Any(); - var candidateRuleValues = isGroupBased - ? groups - : usernames; - var newRuleType = isGroupBased - ? Constants.Conventions.PublicAccess.MemberRoleRuleType - : Constants.Conventions.PublicAccess.MemberUsernameRuleType; - - var entry = _publicAccessService.GetEntryForContent(content); - - if (entry == null || entry.ProtectedNodeId != content.Id) - { - entry = new PublicAccessEntry(content, loginPage, errorPage, new List()); - - if (candidateRuleValues is not null) + foreach (var ruleValue in candidateRuleValues) { - foreach (var ruleValue in candidateRuleValues) - { - entry.AddRule(ruleValue, newRuleType); - } + entry.AddRule(ruleValue, newRuleType); } } - else - { - entry.LoginNodeId = loginPage.Id; - entry.NoAccessNodeId = errorPage.Id; - - var currentRules = entry.Rules.ToArray(); - var obsoleteRules = currentRules.Where(rule => - rule.RuleType != newRuleType - || candidateRuleValues?.Contains(rule.RuleValue) == false - ); - var newRuleValues = candidateRuleValues?.Where(group => - currentRules.Any(rule => - rule.RuleType == newRuleType - && rule.RuleValue == group - ) == false - ); - foreach (var rule in obsoleteRules) - { - entry.RemoveRule(rule); - } - - if (newRuleValues is not null) - { - foreach (var ruleValue in newRuleValues) - { - entry.AddRule(ruleValue, newRuleType); - } - } - } - - return _publicAccessService.Save(entry).Success - ? Ok() - : Problem(); } - - [Authorize(Policy = AuthorizationPolicies.ContentPermissionProtectById)] - [HttpPost] - public IActionResult RemovePublicAccess(int contentId) + else { - var content = _contentService.GetById(contentId); - if (content == null) + entry.LoginNodeId = loginPage.Id; + entry.NoAccessNodeId = errorPage.Id; + + PublicAccessRule[] currentRules = entry.Rules.ToArray(); + IEnumerable obsoleteRules = currentRules.Where(rule => + rule.RuleType != newRuleType + || candidateRuleValues?.Contains(rule.RuleValue) == false + ); + IEnumerable? newRuleValues = candidateRuleValues?.Where(group => + currentRules.Any(rule => + rule.RuleType == newRuleType + && rule.RuleValue == group + ) == false + ); + foreach (PublicAccessRule rule in obsoleteRules) { - return NotFound(); + entry.RemoveRule(rule); } - var entry = _publicAccessService.GetEntryForContent(content); - if (entry == null) + if (newRuleValues is not null) { - return Ok(); + foreach (var ruleValue in newRuleValues) + { + entry.AddRule(ruleValue, newRuleType); + } } - - return _publicAccessService.Delete(entry).Success - ? Ok() - : Problem(); } + + return _publicAccessService.Save(entry).Success + ? Ok() + : Problem(); + } + + [Authorize(Policy = AuthorizationPolicies.ContentPermissionProtectById)] + [HttpPost] + public IActionResult RemovePublicAccess(int contentId) + { + IContent? content = _contentService.GetById(contentId); + if (content == null) + { + return NotFound(); + } + + PublicAccessEntry? entry = _publicAccessService.GetEntryForContent(content); + if (entry == null) + { + return Ok(); + } + + return _publicAccessService.Delete(entry).Success + ? Ok() + : Problem(); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/PublishedSnapshotCacheStatusController.cs b/src/Umbraco.Web.BackOffice/Controllers/PublishedSnapshotCacheStatusController.cs index acf2f63b32..9980089248 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PublishedSnapshotCacheStatusController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PublishedSnapshotCacheStatusController.cs @@ -1,62 +1,60 @@ -using System; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class PublishedSnapshotCacheStatusController : UmbracoAuthorizedApiController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class PublishedSnapshotCacheStatusController : UmbracoAuthorizedApiController + private readonly DistributedCache _distributedCache; + private readonly IPublishedSnapshotService _publishedSnapshotService; + private readonly IPublishedSnapshotStatus _publishedSnapshotStatus; + + /// + /// Initializes a new instance of the class. + /// + public PublishedSnapshotCacheStatusController( + IPublishedSnapshotService publishedSnapshotService, + IPublishedSnapshotStatus publishedSnapshotStatus, + DistributedCache distributedCache) { - private readonly IPublishedSnapshotService _publishedSnapshotService; - private readonly IPublishedSnapshotStatus _publishedSnapshotStatus; - private readonly DistributedCache _distributedCache; - - /// - /// Initializes a new instance of the class. - /// - public PublishedSnapshotCacheStatusController( - IPublishedSnapshotService publishedSnapshotService, - IPublishedSnapshotStatus publishedSnapshotStatus, - DistributedCache distributedCache) - { - _publishedSnapshotService = publishedSnapshotService ?? throw new ArgumentNullException(nameof(publishedSnapshotService)); - _publishedSnapshotStatus = publishedSnapshotStatus; - _distributedCache = distributedCache; - } - - /// - /// Rebuilds the Database cache - /// - [HttpPost] - public string RebuildDbCache() - { - _publishedSnapshotService.Rebuild(); - return _publishedSnapshotStatus.GetStatus(); - } - - /// - /// Gets a status report - /// - [HttpGet] - public string GetStatus() => _publishedSnapshotStatus.GetStatus(); - - /// - /// Cleans up unused snapshots - /// - [HttpGet] - public async Task Collect() - { - GC.Collect(); - await _publishedSnapshotService.CollectAsync(); - return _publishedSnapshotStatus.GetStatus(); - } - - [HttpPost] - public void ReloadCache() => _distributedCache.RefreshAllPublishedSnapshot(); + _publishedSnapshotService = publishedSnapshotService ?? + throw new ArgumentNullException(nameof(publishedSnapshotService)); + _publishedSnapshotStatus = publishedSnapshotStatus; + _distributedCache = distributedCache; } + + /// + /// Rebuilds the Database cache + /// + [HttpPost] + public string RebuildDbCache() + { + _publishedSnapshotService.Rebuild(); + return _publishedSnapshotStatus.GetStatus(); + } + + /// + /// Gets a status report + /// + [HttpGet] + public string GetStatus() => _publishedSnapshotStatus.GetStatus(); + + /// + /// Cleans up unused snapshots + /// + [HttpGet] + public async Task Collect() + { + GC.Collect(); + await _publishedSnapshotService.CollectAsync(); + return _publishedSnapshotStatus.GetStatus(); + } + + [HttpPost] + public void ReloadCache() => _distributedCache.RefreshAllPublishedSnapshot(); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/PublishedStatusController.cs b/src/Umbraco.Web.BackOffice/Controllers/PublishedStatusController.cs index 8df529ddd3..fd30b2f109 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PublishedStatusController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PublishedStatusController.cs @@ -1,27 +1,23 @@ -using System; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core.PublishedCache; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +public class PublishedStatusController : UmbracoAuthorizedApiController { - public class PublishedStatusController : UmbracoAuthorizedApiController + private readonly IPublishedSnapshotStatus _publishedSnapshotStatus; + + public PublishedStatusController(IPublishedSnapshotStatus publishedSnapshotStatus) => _publishedSnapshotStatus = + publishedSnapshotStatus ?? throw new ArgumentNullException(nameof(publishedSnapshotStatus)); + + [HttpGet] + public string GetPublishedStatusUrl() { - private readonly IPublishedSnapshotStatus _publishedSnapshotStatus; - - public PublishedStatusController(IPublishedSnapshotStatus publishedSnapshotStatus) + if (!string.IsNullOrWhiteSpace(_publishedSnapshotStatus.StatusUrl)) { - _publishedSnapshotStatus = publishedSnapshotStatus ?? throw new ArgumentNullException(nameof(publishedSnapshotStatus)); + return _publishedSnapshotStatus.StatusUrl; } - [HttpGet] - public string GetPublishedStatusUrl() - { - if (!string.IsNullOrWhiteSpace(_publishedSnapshotStatus.StatusUrl)) - { - return _publishedSnapshotStatus.StatusUrl; - } - - throw new NotSupportedException("Not supported: " + _publishedSnapshotStatus.GetType().FullName); - } + throw new NotSupportedException("Not supported: " + _publishedSnapshotStatus.GetType().FullName); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs b/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs index a5f5999a15..c4d7d47d87 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs @@ -1,10 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; -using System.Linq; using System.Security; -using System.Threading; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -18,118 +15,120 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class RedirectUrlManagementController : UmbracoAuthorizedApiController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class RedirectUrlManagementController : UmbracoAuthorizedApiController + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IConfigManipulator _configManipulator; + private readonly ILogger _logger; + private readonly IRedirectUrlService _redirectUrlService; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IOptionsMonitor _webRoutingSettings; + + public RedirectUrlManagementController( + ILogger logger, + IOptionsMonitor webRoutingSettings, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IRedirectUrlService redirectUrlService, + IUmbracoMapper umbracoMapper, + IConfigManipulator configManipulator) { - private readonly ILogger _logger; - private readonly IOptionsMonitor _webRoutingSettings; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IRedirectUrlService _redirectUrlService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IConfigManipulator _configManipulator; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _webRoutingSettings = webRoutingSettings ?? throw new ArgumentNullException(nameof(webRoutingSettings)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _redirectUrlService = redirectUrlService ?? throw new ArgumentNullException(nameof(redirectUrlService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _configManipulator = configManipulator ?? throw new ArgumentNullException(nameof(configManipulator)); + } - public RedirectUrlManagementController( - ILogger logger, - IOptionsMonitor webRoutingSettings, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IRedirectUrlService redirectUrlService, - IUmbracoMapper umbracoMapper, - IConfigManipulator configManipulator) + /// + /// Returns true/false of whether redirect tracking is enabled or not + /// + /// + [HttpGet] + public IActionResult GetEnableState() + { + var enabled = _webRoutingSettings.CurrentValue.DisableRedirectUrlTracking == false; + var userIsAdmin = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false; + return Ok(new { enabled, userIsAdmin }); + } + + //add paging + [HttpGet] + public RedirectUrlSearchResult SearchRedirectUrls(string searchTerm, int page = 0, int pageSize = 10) + { + var searchResult = new RedirectUrlSearchResult(); + + IEnumerable redirects = string.IsNullOrWhiteSpace(searchTerm) + ? _redirectUrlService.GetAllRedirectUrls(page, pageSize, out long resultCount) + : _redirectUrlService.SearchRedirectUrls(searchTerm, page, pageSize, out resultCount); + + searchResult.SearchResults = + _umbracoMapper.MapEnumerable(redirects).WhereNotNull(); + searchResult.TotalCount = resultCount; + searchResult.CurrentPage = page; + searchResult.PageCount = ((int)resultCount + pageSize - 1) / pageSize; + + return searchResult; + } + + /// + /// This lists the RedirectUrls for a particular content item + /// Do we need to consider paging here? + /// + /// Udi of content item to retrieve RedirectUrls for + /// + [HttpGet] + public RedirectUrlSearchResult RedirectUrlsForContentItem(string contentUdi) + { + var redirectsResult = new RedirectUrlSearchResult(); + if (UdiParser.TryParse(contentUdi, out GuidUdi? guidIdi)) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _webRoutingSettings = webRoutingSettings ?? throw new ArgumentNullException(nameof(webRoutingSettings)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _redirectUrlService = redirectUrlService ?? throw new ArgumentNullException(nameof(redirectUrlService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _configManipulator = configManipulator ?? throw new ArgumentNullException(nameof(configManipulator)); + IEnumerable redirects = _redirectUrlService.GetContentRedirectUrls(guidIdi!.Guid); + var mapped = _umbracoMapper.MapEnumerable(redirects).WhereNotNull() + .ToList(); + redirectsResult.SearchResults = mapped; + //not doing paging 'yet' + redirectsResult.TotalCount = mapped.Count; + redirectsResult.CurrentPage = 1; + redirectsResult.PageCount = 1; } - /// - /// Returns true/false of whether redirect tracking is enabled or not - /// - /// - [HttpGet] - public IActionResult GetEnableState() + return redirectsResult; + } + + [HttpPost] + public IActionResult DeleteRedirectUrl(Guid id) + { + _redirectUrlService.Delete(id); + return Ok(); + } + + [HttpPost] + public IActionResult ToggleUrlTracker(bool disable) + { + var userIsAdmin = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin(); + if (userIsAdmin == false) { - var enabled = _webRoutingSettings.CurrentValue.DisableRedirectUrlTracking == false; - var userIsAdmin = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false; - return Ok(new { enabled, userIsAdmin }); + var errorMessage = + "User is not a member of the administrators group and so is not allowed to toggle the URL tracker"; + _logger.LogDebug(errorMessage); + throw new SecurityException(errorMessage); } - //add paging - [HttpGet] - public RedirectUrlSearchResult SearchRedirectUrls(string searchTerm, int page = 0, int pageSize = 10) - { - var searchResult = new RedirectUrlSearchResult(); - long resultCount; + var action = disable ? "disable" : "enable"; - var redirects = string.IsNullOrWhiteSpace(searchTerm) - ? _redirectUrlService.GetAllRedirectUrls(page, pageSize, out resultCount) - : _redirectUrlService.SearchRedirectUrls(searchTerm, page, pageSize, out resultCount); + _configManipulator.SaveDisableRedirectUrlTracking(disable); - searchResult.SearchResults = _umbracoMapper.MapEnumerable(redirects).WhereNotNull(); - searchResult.TotalCount = resultCount; - searchResult.CurrentPage = page; - searchResult.PageCount = ((int)resultCount + pageSize - 1) / pageSize; + // TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended. + // otherwise we can read the old value in GetEnableState. + // The value is equal to JsonConfigurationSource.ReloadDelay + Thread.Sleep(250); - return searchResult; - - } - /// - /// This lists the RedirectUrls for a particular content item - /// Do we need to consider paging here? - /// - /// Udi of content item to retrieve RedirectUrls for - /// - [HttpGet] - public RedirectUrlSearchResult RedirectUrlsForContentItem(string contentUdi) - { - var redirectsResult = new RedirectUrlSearchResult(); - if (UdiParser.TryParse(contentUdi, out GuidUdi? guidIdi)) - { - - var redirects = _redirectUrlService.GetContentRedirectUrls(guidIdi!.Guid); - var mapped = _umbracoMapper.MapEnumerable(redirects).WhereNotNull().ToList(); - redirectsResult.SearchResults = mapped; - //not doing paging 'yet' - redirectsResult.TotalCount = mapped.Count; - redirectsResult.CurrentPage = 1; - redirectsResult.PageCount = 1; - } - return redirectsResult; - } - [HttpPost] - public IActionResult DeleteRedirectUrl(Guid id) - { - _redirectUrlService.Delete(id); - return Ok(); - } - - [HttpPost] - public IActionResult ToggleUrlTracker(bool disable) - { - var userIsAdmin = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin(); - if (userIsAdmin == false) - { - var errorMessage = "User is not a member of the administrators group and so is not allowed to toggle the URL tracker"; - _logger.LogDebug(errorMessage); - throw new SecurityException(errorMessage); - } - - var action = disable ? "disable" : "enable"; - - _configManipulator.SaveDisableRedirectUrlTracking(disable); - - // TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended. - // otherwise we can read the old value in GetEnableState. - // The value is equal to JsonConfigurationSource.ReloadDelay - Thread.Sleep(250); - - return Ok($"URL tracker is now {action}d."); - } + return Ok($"URL tracker is now {action}d."); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/RelationController.cs b/src/Umbraco.Web.BackOffice/Controllers/RelationController.cs index 9d1f2bf66d..72c10a94a7 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/RelationController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/RelationController.cs @@ -1,7 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -9,48 +7,42 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessContent)] +public class RelationController : UmbracoAuthorizedJsonController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessContent)] - public class RelationController : UmbracoAuthorizedJsonController + private readonly IRelationService _relationService; + private readonly IUmbracoMapper _umbracoMapper; + + public RelationController(IUmbracoMapper umbracoMapper, IRelationService relationService) { - private readonly IUmbracoMapper _umbracoMapper; - private readonly IRelationService _relationService; + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _relationService = relationService ?? throw new ArgumentNullException(nameof(relationService)); + } - public RelationController(IUmbracoMapper umbracoMapper, - IRelationService relationService) + public RelationDisplay? GetById(int id) => + _umbracoMapper.Map(_relationService.GetById(id)); + + //[EnsureUserPermissionForContent("childId")] + public IEnumerable GetByChildId(int childId, string relationTypeAlias = "") + { + IRelation[] relations = _relationService.GetByChildId(childId).ToArray(); + + if (relations.Any() == false) { - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _relationService = relationService ?? throw new ArgumentNullException(nameof(relationService)); + return Enumerable.Empty(); } - public RelationDisplay? GetById(int id) + if (string.IsNullOrWhiteSpace(relationTypeAlias) == false) { - return _umbracoMapper.Map(_relationService.GetById(id)); - } - - //[EnsureUserPermissionForContent("childId")] - public IEnumerable GetByChildId(int childId, string relationTypeAlias = "") - { - var relations = _relationService.GetByChildId(childId).ToArray(); - - if (relations.Any() == false) - { - return Enumerable.Empty(); - } - - if (string.IsNullOrWhiteSpace(relationTypeAlias) == false) - { - return - _umbracoMapper.MapEnumerable( - relations.Where(x => x.RelationType.Alias.InvariantEquals(relationTypeAlias))).WhereNotNull(); - } - - return _umbracoMapper.MapEnumerable(relations).WhereNotNull(); + return + _umbracoMapper.MapEnumerable( + relations.Where(x => x.RelationType.Alias.InvariantEquals(relationTypeAlias))).WhereNotNull(); } + return _umbracoMapper.MapEnumerable(relations).WhereNotNull(); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/RelationTypeController.cs b/src/Umbraco.Web.BackOffice/Controllers/RelationTypeController.cs index 5966cd5e17..ffe9a40ca3 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/RelationTypeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/RelationTypeController.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -11,207 +7,237 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; -using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// The API controller for editing relation types. +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessRelationTypes)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +public class RelationTypeController : BackOfficeNotificationsController { - /// - /// The API controller for editing relation types. - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessRelationTypes)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - public class RelationTypeController : BackOfficeNotificationsController - { - private readonly ILogger _logger; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IRelationService _relationService; - private readonly IShortStringHelper _shortStringHelper; + private readonly ILogger _logger; + private readonly IRelationService _relationService; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; - public RelationTypeController( - ILogger logger, - IUmbracoMapper umbracoMapper, - IRelationService relationService, - IShortStringHelper shortStringHelper) + public RelationTypeController( + ILogger logger, + IUmbracoMapper umbracoMapper, + IRelationService relationService, + IShortStringHelper shortStringHelper) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _relationService = relationService ?? throw new ArgumentNullException(nameof(relationService)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + } + + /// + /// Gets a relation type by id + /// + /// The relation type ID. + /// Returns the . + public ActionResult GetById(int id) + { + IRelationType? relationType = _relationService.GetRelationTypeById(id); + + if (relationType == null) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _relationService = relationService ?? throw new ArgumentNullException(nameof(relationService)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + return NotFound(); } - /// - /// Gets a relation type by id - /// - /// The relation type ID. - /// Returns the . - public ActionResult GetById(int id) + RelationTypeDisplay? display = _umbracoMapper.Map(relationType); + + return display; + } + + /// + /// Gets a relation type by guid + /// + /// The relation type ID. + /// Returns the . + public ActionResult GetById(Guid id) + { + IRelationType? relationType = _relationService.GetRelationTypeById(id); + if (relationType == null) { - var relationType = _relationService.GetRelationTypeById(id); + return NotFound(); + } - if (relationType == null) + return _umbracoMapper.Map(relationType); + } + + /// + /// Gets a relation type by udi + /// + /// The relation type ID. + /// Returns the . + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) + { + return NotFound(); + } + + IRelationType? relationType = _relationService.GetRelationTypeById(guidUdi.Guid); + if (relationType == null) + { + return NotFound(); + } + + return _umbracoMapper.Map(relationType); + } + + public PagedResult GetPagedResults(int id, int pageNumber = 1, int pageSize = 100) + { + if (pageNumber <= 0 || pageSize <= 0) + { + throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); + } + + // Ordering do we need to pass through? + IEnumerable relations = + _relationService.GetPagedByRelationTypeId(id, pageNumber - 1, pageSize, out var totalRecords); + + return new PagedResult(totalRecords, pageNumber, pageSize) + { + Items = relations.Select(x => _umbracoMapper.Map(x)) + }; + } + + /// + /// Gets a list of object types which can be associated via relations. + /// + /// A list of available object types. + public List GetRelationObjectTypes() + { + var objectTypes = new List + { + new() { - return NotFound(); + Id = UmbracoObjectTypes.Document.GetGuid(), Name = UmbracoObjectTypes.Document.GetFriendlyName() + }, + new() {Id = UmbracoObjectTypes.Media.GetGuid(), Name = UmbracoObjectTypes.Media.GetFriendlyName()}, + new() {Id = UmbracoObjectTypes.Member.GetGuid(), Name = UmbracoObjectTypes.Member.GetFriendlyName()}, + new() + { + Id = UmbracoObjectTypes.DocumentType.GetGuid(), + Name = UmbracoObjectTypes.DocumentType.GetFriendlyName() + }, + new() + { + Id = UmbracoObjectTypes.MediaType.GetGuid(), + Name = UmbracoObjectTypes.MediaType.GetFriendlyName() + }, + new() + { + Id = UmbracoObjectTypes.MemberType.GetGuid(), + Name = UmbracoObjectTypes.MemberType.GetFriendlyName() + }, + new() + { + Id = UmbracoObjectTypes.DataType.GetGuid(), Name = UmbracoObjectTypes.DataType.GetFriendlyName() + }, + new() + { + Id = UmbracoObjectTypes.MemberGroup.GetGuid(), + Name = UmbracoObjectTypes.MemberGroup.GetFriendlyName() + }, + new() {Id = UmbracoObjectTypes.ROOT.GetGuid(), Name = UmbracoObjectTypes.ROOT.GetFriendlyName()}, + new() + { + Id = UmbracoObjectTypes.RecycleBin.GetGuid(), + Name = UmbracoObjectTypes.RecycleBin.GetFriendlyName() } + }; - var display = _umbracoMapper.Map(relationType); + return objectTypes; + } + + /// + /// Creates a new relation type. + /// + /// The relation type to create. + /// A containing the persisted relation type's ID. + public ActionResult PostCreate(RelationTypeSave relationType) + { + var relationTypePersisted = new RelationType( + relationType.Name, + relationType.Name?.ToSafeAlias(_shortStringHelper, true), + relationType.IsBidirectional, + relationType.ParentObjectType, + relationType.ChildObjectType, + relationType.IsDependency); + + try + { + _relationService.Save(relationTypePersisted); + + return relationTypePersisted.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating relation type with {Name}", relationType.Name); + return ValidationProblem("Error creating relation type."); + } + } + + /// + /// Updates an existing relation type. + /// + /// The relation type to update. + /// A display object containing the updated relation type. + public ActionResult PostSave(RelationTypeSave relationType) + { + IRelationType? relationTypePersisted = _relationService.GetRelationTypeById(relationType.Key); + + if (relationTypePersisted == null) + { + return ValidationProblem("Relation type does not exist"); + } + + _umbracoMapper.Map(relationType, relationTypePersisted); + + try + { + _relationService.Save(relationTypePersisted); + RelationTypeDisplay? display = _umbracoMapper.Map(relationTypePersisted); + display?.AddSuccessNotification("Relation type saved", ""); return display; } - /// - /// Gets a relation type by guid - /// - /// The relation type ID. - /// Returns the . - public ActionResult GetById(Guid id) + catch (Exception ex) { - var relationType = _relationService.GetRelationTypeById(id); - if (relationType == null) - { - return NotFound(); - } - return _umbracoMapper.Map(relationType); - } - - /// - /// Gets a relation type by udi - /// - /// The relation type ID. - /// Returns the . - public ActionResult GetById(Udi id) - { - var guidUdi = id as GuidUdi; - if (guidUdi == null) - return NotFound(); - - var relationType = _relationService.GetRelationTypeById(guidUdi.Guid); - if (relationType == null) - { - return NotFound(); - } - return _umbracoMapper.Map(relationType); - } - - public PagedResult GetPagedResults(int id, int pageNumber = 1, int pageSize = 100) - { - - if (pageNumber <= 0 || pageSize <= 0) - { - throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); - } - - // Ordering do we need to pass through? - var relations = _relationService.GetPagedByRelationTypeId(id, pageNumber -1, pageSize, out long totalRecords); - - return new PagedResult(totalRecords, pageNumber, pageSize) - { - Items = relations.Select(x => _umbracoMapper.Map(x)) - }; - } - - /// - /// Gets a list of object types which can be associated via relations. - /// - /// A list of available object types. - public List GetRelationObjectTypes() - { - var objectTypes = new List - { - new ObjectType{Id = UmbracoObjectTypes.Document.GetGuid(), Name = UmbracoObjectTypes.Document.GetFriendlyName()}, - new ObjectType{Id = UmbracoObjectTypes.Media.GetGuid(), Name = UmbracoObjectTypes.Media.GetFriendlyName()}, - new ObjectType{Id = UmbracoObjectTypes.Member.GetGuid(), Name = UmbracoObjectTypes.Member.GetFriendlyName()}, - new ObjectType{Id = UmbracoObjectTypes.DocumentType.GetGuid(), Name = UmbracoObjectTypes.DocumentType.GetFriendlyName()}, - new ObjectType{Id = UmbracoObjectTypes.MediaType.GetGuid(), Name = UmbracoObjectTypes.MediaType.GetFriendlyName()}, - new ObjectType{Id = UmbracoObjectTypes.MemberType.GetGuid(), Name = UmbracoObjectTypes.MemberType.GetFriendlyName()}, - new ObjectType{Id = UmbracoObjectTypes.DataType.GetGuid(), Name = UmbracoObjectTypes.DataType.GetFriendlyName()}, - new ObjectType{Id = UmbracoObjectTypes.MemberGroup.GetGuid(), Name = UmbracoObjectTypes.MemberGroup.GetFriendlyName()}, - new ObjectType{Id = UmbracoObjectTypes.ROOT.GetGuid(), Name = UmbracoObjectTypes.ROOT.GetFriendlyName()}, - new ObjectType{Id = UmbracoObjectTypes.RecycleBin.GetGuid(), Name = UmbracoObjectTypes.RecycleBin.GetFriendlyName()}, - }; - - return objectTypes; - } - - /// - /// Creates a new relation type. - /// - /// The relation type to create. - /// A containing the persisted relation type's ID. - public ActionResult PostCreate(RelationTypeSave relationType) - { - var relationTypePersisted = new RelationType( - relationType.Name, - relationType.Name?.ToSafeAlias(_shortStringHelper, true), - relationType.IsBidirectional, - relationType.ParentObjectType, - relationType.ChildObjectType, - relationType.IsDependency); - - try - { - _relationService.Save(relationTypePersisted); - - return relationTypePersisted.Id; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating relation type with {Name}", relationType.Name); - return ValidationProblem("Error creating relation type."); - } - } - - /// - /// Updates an existing relation type. - /// - /// The relation type to update. - /// A display object containing the updated relation type. - public ActionResult PostSave(RelationTypeSave relationType) - { - var relationTypePersisted = _relationService.GetRelationTypeById(relationType.Key); - - if (relationTypePersisted == null) - { - return ValidationProblem("Relation type does not exist"); - } - - _umbracoMapper.Map(relationType, relationTypePersisted); - - try - { - _relationService.Save(relationTypePersisted); - var display = _umbracoMapper.Map(relationTypePersisted); - display?.AddSuccessNotification("Relation type saved", ""); - - return display; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving relation type with {Id}", relationType.Id); - return ValidationProblem("Something went wrong when saving the relation type"); - } - } - - /// - /// Deletes a relation type with a given ID. - /// - /// The ID of the relation type to delete. - /// A . - [HttpPost] - [HttpDelete] - public IActionResult DeleteById(int id) - { - var relationType = _relationService.GetRelationTypeById(id); - - if (relationType == null) - return NotFound(); - - _relationService.Delete(relationType); - - return Ok(); + _logger.LogError(ex, "Error saving relation type with {Id}", relationType.Id); + return ValidationProblem("Something went wrong when saving the relation type"); } } + + /// + /// Deletes a relation type with a given ID. + /// + /// The ID of the relation type to delete. + /// A . + [HttpPost] + [HttpDelete] + public IActionResult DeleteById(int id) + { + IRelationType? relationType = _relationService.GetRelationTypeById(id); + + if (relationType == null) + { + return NotFound(); + } + + _relationService.Delete(relationType); + + return Ok(); + } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/SectionController.cs b/src/Umbraco.Web.BackOffice/Controllers/SectionController.cs index ae0fff7da5..b4c4ca0a38 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/SectionController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/SectionController.cs @@ -1,133 +1,149 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Umbraco.Cms.Core.Collections; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Dashboards; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Trees; +using Umbraco.Cms.Core.Sections; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.BackOffice.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// The API controller used for using the list of sections +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class SectionController : UmbracoAuthorizedJsonController { - /// - /// The API controller used for using the list of sections - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class SectionController : UmbracoAuthorizedJsonController + private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IControllerFactory _controllerFactory; + private readonly IDashboardService _dashboardService; + private readonly ILocalizedTextService _localizedTextService; + private readonly ISectionService _sectionService; + private readonly ITreeService _treeService; + private readonly IUmbracoMapper _umbracoMapper; + + public SectionController( + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILocalizedTextService localizedTextService, + IDashboardService dashboardService, + ISectionService sectionService, + ITreeService treeService, + IUmbracoMapper umbracoMapper, + IControllerFactory controllerFactory, + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) { - private readonly IControllerFactory _controllerFactory; - private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; - private readonly IDashboardService _dashboardService; - private readonly ILocalizedTextService _localizedTextService; - private readonly ISectionService _sectionService; - private readonly ITreeService _treeService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _localizedTextService = localizedTextService; + _dashboardService = dashboardService; + _sectionService = sectionService; + _treeService = treeService; + _umbracoMapper = umbracoMapper; + _controllerFactory = controllerFactory; + _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; + } - public SectionController( - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - ILocalizedTextService localizedTextService, - IDashboardService dashboardService, ISectionService sectionService, ITreeService treeService, - IUmbracoMapper umbracoMapper, IControllerFactory controllerFactory, - IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) + public async Task>> GetSections() + { + IEnumerable sections = + _sectionService.GetAllowedSections(_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0); + + Section[] sectionModels = sections.Select(_umbracoMapper.Map
).WhereNotNull().ToArray(); + + // this is a bit nasty since we'll be proxying via the app tree controller but we sort of have to do that + // since tree's by nature are controllers and require request contextual data + var appTreeController = + new ApplicationTreeController(_treeService, _sectionService, _localizedTextService, _controllerFactory, _actionDescriptorCollectionProvider) + { ControllerContext = ControllerContext }; + + IDictionary>> dashboards = + _dashboardService.GetDashboards(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser); + + //now we can add metadata for each section so that the UI knows if there's actually anything at all to render for + //a dashboard for a given section, then the UI can deal with it accordingly (i.e. redirect to the first tree) + foreach (Section? section in sectionModels) { - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _localizedTextService = localizedTextService; - _dashboardService = dashboardService; - _sectionService = sectionService; - _treeService = treeService; - _umbracoMapper = umbracoMapper; - _controllerFactory = controllerFactory; - _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; - } - - public async Task>> GetSections() - { - var sections = _sectionService.GetAllowedSections(_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0); - - var sectionModels = sections.Select(_umbracoMapper.Map
).WhereNotNull().ToArray(); - - // this is a bit nasty since we'll be proxying via the app tree controller but we sort of have to do that - // since tree's by nature are controllers and require request contextual data - var appTreeController = - new ApplicationTreeController(_treeService, _sectionService, _localizedTextService, _controllerFactory, _actionDescriptorCollectionProvider) - { - ControllerContext = ControllerContext - }; - - var dashboards = _dashboardService.GetDashboards(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser); - - //now we can add metadata for each section so that the UI knows if there's actually anything at all to render for - //a dashboard for a given section, then the UI can deal with it accordingly (i.e. redirect to the first tree) - foreach (var section in sectionModels) + var hasDashboards = section?.Alias is not null && + dashboards.TryGetValue(section.Alias, out IEnumerable>? dashboardsForSection) && + dashboardsForSection.Any(); + if (hasDashboards) { - var hasDashboards = section?.Alias is not null && dashboards.TryGetValue(section.Alias, out var dashboardsForSection) && - dashboardsForSection.Any(); - if (hasDashboards) continue; - - // get the first tree in the section and get its root node route path - var sectionRoot = await appTreeController.GetApplicationTrees(section?.Alias, null, null); - - if (!(sectionRoot.Result is null)) - { - return sectionRoot.Result; - } - - if (section is not null) - { - section.RoutePath = GetRoutePathForFirstTree(sectionRoot.Value!); - } + continue; } - return sectionModels; - } + // get the first tree in the section and get its root node route path + ActionResult sectionRoot = + await appTreeController.GetApplicationTrees(section?.Alias, null, null); - /// - /// Returns the first non root/group node's route path - /// - /// - /// - private string? GetRoutePathForFirstTree(TreeRootNode rootNode) - { - if (!rootNode.IsContainer || !rootNode.ContainsTrees) - return rootNode.RoutePath; - - if (rootNode.Children is not null) + if (!(sectionRoot.Result is null)) { - foreach (var node in rootNode.Children) - { - if (node is TreeRootNode groupRoot) - return GetRoutePathForFirstTree(groupRoot); //recurse to get the first tree in the group - return node.RoutePath; - } + return sectionRoot.Result; } - return string.Empty; + if (section is not null) + { + section.RoutePath = GetRoutePathForFirstTree(sectionRoot.Value!); + } } - /// - /// Returns all the sections that the user has access to - /// - /// - public IEnumerable GetAllSections() + return sectionModels; + } + + /// + /// Returns the first non root/group node's route path + /// + /// + /// + private string? GetRoutePathForFirstTree(TreeRootNode rootNode) + { + if (!rootNode.IsContainer || !rootNode.ContainsTrees) { - var sections = _sectionService.GetSections(); - var mapped = sections.Select(_umbracoMapper.Map
); - if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false) - return mapped; - - return mapped.Where(x => _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.AllowedSections.Contains(x?.Alias) ?? false).ToArray(); + return rootNode.RoutePath; } + + if (rootNode.Children is not null) + { + foreach (TreeNode node in rootNode.Children) + { + if (node is TreeRootNode groupRoot) + { + return GetRoutePathForFirstTree(groupRoot); //recurse to get the first tree in the group + } + + return node.RoutePath; + } + } + + return string.Empty; + } + + /// + /// Returns all the sections that the user has access to + /// + /// + public IEnumerable GetAllSections() + { + IEnumerable sections = _sectionService.GetSections(); + IEnumerable mapped = sections.Select(_umbracoMapper.Map
); + if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false) + { + return mapped; + } + + return mapped.Where(x => + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.AllowedSections.Contains(x?.Alias) ?? + false) + .ToArray(); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/StylesheetController.cs b/src/Umbraco.Web.BackOffice/Controllers/StylesheetController.cs index 750ff90137..32adfcfdf6 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/StylesheetController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/StylesheetController.cs @@ -1,46 +1,36 @@ -using System.Collections.Generic; -using System.Linq; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; +using Stylesheet = Umbraco.Cms.Core.Models.ContentEditing.Stylesheet; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// The API controller used for retrieving available stylesheets +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class StylesheetController : UmbracoAuthorizedJsonController { - /// - /// The API controller used for retrieving available stylesheets - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class StylesheetController : UmbracoAuthorizedJsonController + private readonly IFileService _fileService; + + public StylesheetController(IFileService fileService) => _fileService = fileService; + + public IEnumerable GetAll() => + _fileService.GetStylesheets() + .Select(x => + new Stylesheet { Name = x.Alias, Path = x.VirtualPath }); + + public IEnumerable GetRulesByName(string name) { - private readonly IFileService _fileService; - - public StylesheetController(IFileService fileService) + IStylesheet? css = _fileService.GetStylesheet(name.EnsureEndsWith(".css")); + if (css is null || css.Properties is null) { - _fileService = fileService; + return Enumerable.Empty(); } - public IEnumerable GetAll() - { - return _fileService.GetStylesheets() - .Select(x => - new Stylesheet() { - Name = x.Alias, - Path = x.VirtualPath - }); - } - - public IEnumerable GetRulesByName(string name) - { - var css = _fileService.GetStylesheet(name.EnsureEndsWith(".css")); - if (css is null || css.Properties is null) - { - return Enumerable.Empty(); - } - - return css.Properties.Select(x => new StylesheetRule() { Name = x.Name, Selector = x.Alias }); - } + return css.Properties.Select(x => new StylesheetRule { Name = x.Name, Selector = x.Alias }); } - } diff --git a/src/Umbraco.Web.BackOffice/Controllers/TemplateController.cs b/src/Umbraco.Web.BackOffice/Controllers/TemplateController.cs index 65052df636..be57a93328 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/TemplateController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/TemplateController.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -15,255 +12,266 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessTemplates)] +[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] +public class TemplateController : BackOfficeNotificationsController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessTemplates)] - [ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))] - public class TemplateController : BackOfficeNotificationsController + private readonly IDefaultViewContentProvider _defaultViewContentProvider; + private readonly IFileService _fileService; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; + + [ActivatorUtilitiesConstructor] + public TemplateController( + IFileService fileService, + IUmbracoMapper umbracoMapper, + IShortStringHelper shortStringHelper, + IDefaultViewContentProvider defaultViewContentProvider) { - private readonly IFileService _fileService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IShortStringHelper _shortStringHelper; - private readonly IDefaultViewContentProvider _defaultViewContentProvider; + _fileService = fileService ?? throw new ArgumentNullException(nameof(fileService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _defaultViewContentProvider = defaultViewContentProvider ?? + throw new ArgumentNullException(nameof(defaultViewContentProvider)); + } - [ActivatorUtilitiesConstructor] - public TemplateController( - IFileService fileService, - IUmbracoMapper umbracoMapper, - IShortStringHelper shortStringHelper, - IDefaultViewContentProvider defaultViewContentProvider) + [Obsolete("Use ctor will all params")] + public TemplateController( + IFileService fileService, + IUmbracoMapper umbracoMapper, + IShortStringHelper shortStringHelper) + : this(fileService, umbracoMapper, shortStringHelper, StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Gets data type by alias + /// + /// + /// + public TemplateDisplay? GetByAlias(string alias) + { + ITemplate? template = _fileService.GetTemplate(alias); + return template == null ? null : _umbracoMapper.Map(template); + } + + /// + /// Get all templates + /// + /// + public IEnumerable? GetAll() => _fileService.GetTemplates() + ?.Select(_umbracoMapper.Map).WhereNotNull(); + + /// + /// Gets the template json for the template id + /// + /// + /// + public ActionResult GetById(int id) + { + ITemplate? template = _fileService.GetTemplate(id); + if (template == null) { - _fileService = fileService ?? throw new ArgumentNullException(nameof(fileService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _defaultViewContentProvider = defaultViewContentProvider ?? throw new ArgumentNullException(nameof(defaultViewContentProvider)); + return NotFound(); } - [Obsolete("Use ctor will all params")] - public TemplateController( - IFileService fileService, - IUmbracoMapper umbracoMapper, - IShortStringHelper shortStringHelper) - : this(fileService, umbracoMapper, shortStringHelper, StaticServiceProvider.Instance.GetRequiredService()) + return _umbracoMapper.Map(template); + } + + + /// + /// Gets the template json for the template guid + /// + /// + /// + public ActionResult GetById(Guid id) + { + ITemplate? template = _fileService.GetTemplate(id); + if (template == null) { + return NotFound(); } - /// - /// Gets data type by alias - /// - /// - /// - public TemplateDisplay? GetByAlias(string alias) + return _umbracoMapper.Map(template); + } + + /// + /// Gets the template json for the template udi + /// + /// + /// + public ActionResult GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) { - var template = _fileService.GetTemplate(alias); - return template == null ? null : _umbracoMapper.Map(template); + return NotFound(); } - /// - /// Get all templates - /// - /// - public IEnumerable? GetAll() + ITemplate? template = _fileService.GetTemplate(guidUdi.Guid); + if (template == null) { - return _fileService.GetTemplates()?.Select(_umbracoMapper.Map).WhereNotNull(); + return NotFound(); } - /// - /// Gets the template json for the template id - /// - /// - /// - public ActionResult GetById(int id) - { - var template = _fileService.GetTemplate(id); - if (template == null) - return NotFound(); + return _umbracoMapper.Map(template); + } - return _umbracoMapper.Map(template); + /// + /// Deletes a template with a given ID + /// + /// + /// + [HttpDelete] + [HttpPost] + public IActionResult DeleteById(int id) + { + ITemplate? template = _fileService.GetTemplate(id); + if (template == null) + { + return NotFound(); } + _fileService.DeleteTemplate(template.Alias); + return Ok(); + } - /// - /// Gets the template json for the template guid - /// - /// - /// - public ActionResult GetById(Guid id) + public TemplateDisplay? GetScaffold(int id) + { + //empty default + var dt = new Template(_shortStringHelper, string.Empty, string.Empty) { - var template = _fileService.GetTemplate(id); - if (template == null) - return NotFound(); + Path = "-1" + }; - return _umbracoMapper.Map(template); + if (id > 0) + { + ITemplate? master = _fileService.GetTemplate(id); + if (master != null) + { + dt.SetMasterTemplate(master); + } } - /// - /// Gets the template json for the template udi - /// - /// - /// - public ActionResult GetById(Udi id) - { - var guidUdi = id as GuidUdi; - if (guidUdi == null) - return NotFound(); + var content = _defaultViewContentProvider.GetDefaultFileContent(dt.MasterTemplateAlias); + TemplateDisplay? scaffold = _umbracoMapper.Map(dt); - var template = _fileService.GetTemplate(guidUdi.Guid); + if (scaffold is not null) + { + scaffold.Content = content; + } + + return scaffold; + } + + /// + /// Saves the data type + /// + /// + /// + public ActionResult PostSave(TemplateDisplay display) + { + //Checking the submitted is valid with the Required attributes decorated on the ViewModel + if (ModelState.IsValid == false) + { + return ValidationProblem(ModelState); + } + + if (display.Id > 0) + { + // update + ITemplate? template = _fileService.GetTemplate(display.Id); if (template == null) { return NotFound(); } - return _umbracoMapper.Map(template); - } + var changeMaster = template.MasterTemplateAlias != display.MasterTemplateAlias; + var changeAlias = template.Alias != display.Alias; - /// - /// Deletes a template with a given ID - /// - /// - /// - [HttpDelete] - [HttpPost] - public IActionResult DeleteById(int id) - { - var template = _fileService.GetTemplate(id); - if (template == null) - return NotFound(); + _umbracoMapper.Map(display, template); - _fileService.DeleteTemplate(template.Alias); - return Ok(); - } - - public TemplateDisplay? GetScaffold(int id) - { - //empty default - var dt = new Template(_shortStringHelper, string.Empty, string.Empty); - dt.Path = "-1"; - - if (id > 0) + if (changeMaster) { - var master = _fileService.GetTemplate(id); - if(master != null) + if (string.IsNullOrEmpty(display.MasterTemplateAlias) == false) { - dt.SetMasterTemplate(master); - } - } - - var content = _defaultViewContentProvider.GetDefaultFileContent( layoutPageAlias: dt.MasterTemplateAlias ); - var scaffold = _umbracoMapper.Map(dt); - - if (scaffold is not null) - { - scaffold.Content = content; - } - - return scaffold; - } - - /// - /// Saves the data type - /// - /// - /// - public ActionResult PostSave(TemplateDisplay display) - { - - //Checking the submitted is valid with the Required attributes decorated on the ViewModel - if (ModelState.IsValid == false) - { - return ValidationProblem(ModelState); - } - - if (display.Id > 0) - { - // update - var template = _fileService.GetTemplate(display.Id); - if (template == null) - return NotFound(); - - var changeMaster = template.MasterTemplateAlias != display.MasterTemplateAlias; - var changeAlias = template.Alias != display.Alias; - - _umbracoMapper.Map(display, template); - - if (changeMaster) - { - if (string.IsNullOrEmpty(display.MasterTemplateAlias) == false) + ITemplate? master = _fileService.GetTemplate(display.MasterTemplateAlias); + if (master == null || master.Id == display.Id) { - - var master = _fileService.GetTemplate(display.MasterTemplateAlias); - if(master == null || master.Id == display.Id) - { - template.SetMasterTemplate(null); - }else - { - template.SetMasterTemplate(master); - - //After updating the master - ensure we update the path property if it has any children already assigned - var templateHasChildren = _fileService.GetTemplateDescendants(display.Id); - - foreach (var childTemplate in templateHasChildren) - { - //template ID to find - var templateIdInPath = "," + display.Id + ","; - - if (string.IsNullOrEmpty(childTemplate.Path)) - { - continue; - } - - //Find position in current comma separate string path (so we get the correct children path) - var positionInPath = childTemplate.Path.IndexOf(templateIdInPath) + templateIdInPath.Length; - - //Get the substring of the child & any children (descendants it may have too) - var childTemplatePath = childTemplate.Path.Substring(positionInPath); - - //As we are updating the template to be a child of a master - //Set the path to the master's path + its current template id + the current child path substring - childTemplate.Path = master.Path + "," + display.Id + "," + childTemplatePath; - - //Save the children with the updated path - _fileService.SaveTemplate(childTemplate); - } - } + template.SetMasterTemplate(null); } else { - //remove the master - template.SetMasterTemplate(null); + template.SetMasterTemplate(master); + + //After updating the master - ensure we update the path property if it has any children already assigned + IEnumerable templateHasChildren = _fileService.GetTemplateDescendants(display.Id); + + foreach (ITemplate childTemplate in templateHasChildren) + { + //template ID to find + var templateIdInPath = "," + display.Id + ","; + + if (string.IsNullOrEmpty(childTemplate.Path)) + { + continue; + } + + //Find position in current comma separate string path (so we get the correct children path) + var positionInPath = childTemplate.Path.IndexOf(templateIdInPath) + templateIdInPath.Length; + + //Get the substring of the child & any children (descendants it may have too) + var childTemplatePath = childTemplate.Path.Substring(positionInPath); + + //As we are updating the template to be a child of a master + //Set the path to the master's path + its current template id + the current child path substring + childTemplate.Path = master.Path + "," + display.Id + "," + childTemplatePath; + + //Save the children with the updated path + _fileService.SaveTemplate(childTemplate); + } } } - - _fileService.SaveTemplate(template); - - if (changeAlias) + else { - template = _fileService.GetTemplate(template.Id); + //remove the master + template.SetMasterTemplate(null); } - - _umbracoMapper.Map(template, display); } - else + + _fileService.SaveTemplate(template); + + if (changeAlias) { - //create - ITemplate? master = null; - if (string.IsNullOrEmpty(display.MasterTemplateAlias) == false) - { - master = _fileService.GetTemplate(display.MasterTemplateAlias); - if (master == null) - return NotFound(); - } - - // we need to pass the template name as alias to keep the template file casing consistent with templates created with content - // - see comment in FileService.CreateTemplateForContentType for additional details - var template = _fileService.CreateTemplateWithIdentity(display.Name, display.Name, display.Content, master); - _umbracoMapper.Map(template, display); + template = _fileService.GetTemplate(template.Id); } - return display; + _umbracoMapper.Map(template, display); } + else + { + //create + ITemplate? master = null; + if (string.IsNullOrEmpty(display.MasterTemplateAlias) == false) + { + master = _fileService.GetTemplate(display.MasterTemplateAlias); + if (master == null) + { + return NotFound(); + } + } + + // we need to pass the template name as alias to keep the template file casing consistent with templates created with content + // - see comment in FileService.CreateTemplateForContentType for additional details + ITemplate template = + _fileService.CreateTemplateWithIdentity(display.Name, display.Name, display.Content, master); + _umbracoMapper.Map(template, display); + } + + return display; } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/TemplateQueryController.cs b/src/Umbraco.Web.BackOffice/Controllers/TemplateQueryController.cs index e7a61da7cf..0d9299e88c 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/TemplateQueryController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/TemplateQueryController.cs @@ -1,8 +1,6 @@ -using System; -using System.Collections.Generic; using System.Diagnostics; using System.Globalization; -using System.Linq; +using System.Linq.Expressions; using System.Text; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models.PublishedContent; @@ -11,253 +9,282 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// The API controller used for building content queries within the template +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[JsonCamelCaseFormatter] +public class TemplateQueryController : UmbracoAuthorizedJsonController { - /// - /// The API controller used for building content queries within the template - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [JsonCamelCaseFormatter] - public class TemplateQueryController : UmbracoAuthorizedJsonController + private readonly IContentTypeService _contentTypeService; + private readonly ILocalizedTextService _localizedTextService; + private readonly IPublishedContentQuery _publishedContentQuery; + private readonly IPublishedValueFallback _publishedValueFallback; + private readonly IVariationContextAccessor _variationContextAccessor; + + public TemplateQueryController( + IVariationContextAccessor variationContextAccessor, + IPublishedContentQuery publishedContentQuery, + ILocalizedTextService localizedTextService, + IPublishedValueFallback publishedValueFallback, + IContentTypeService contentTypeService) { - private readonly IVariationContextAccessor _variationContextAccessor; - private readonly IPublishedContentQuery _publishedContentQuery; - private readonly ILocalizedTextService _localizedTextService; - private readonly IPublishedValueFallback _publishedValueFallback; - private readonly IContentTypeService _contentTypeService; + _variationContextAccessor = variationContextAccessor ?? + throw new ArgumentNullException(nameof(variationContextAccessor)); + _publishedContentQuery = + publishedContentQuery ?? throw new ArgumentNullException(nameof(publishedContentQuery)); + _localizedTextService = + localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + _publishedValueFallback = + publishedValueFallback ?? throw new ArgumentNullException(nameof(publishedValueFallback)); + _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); + } - public TemplateQueryController( - IVariationContextAccessor variationContextAccessor, - IPublishedContentQuery publishedContentQuery, - ILocalizedTextService localizedTextService, - IPublishedValueFallback publishedValueFallback, - IContentTypeService contentTypeService) + private IEnumerable Terms => new List + { + new(_localizedTextService.Localize("template", "is"), Operator.Equals, new[] {"string"}), + new(_localizedTextService.Localize("template", "isNot"), Operator.NotEquals, new[] {"string"}), + new(_localizedTextService.Localize("template", "before"), Operator.LessThan, new[] {"datetime"}), + new(_localizedTextService.Localize("template", "beforeIncDate"), Operator.LessThanEqualTo, new[] {"datetime"}), + new( + _localizedTextService.Localize("template", "after"), + Operator.GreaterThan, + new[] + { + "datetime" + }), + new( + _localizedTextService.Localize("template", "afterIncDate"), + Operator.GreaterThanEqualTo, + new[] + { + "datetime" + }), + new(_localizedTextService.Localize("template", "equals"), Operator.Equals, new[] {"int"}), + new(_localizedTextService.Localize("template", "doesNotEqual"), Operator.NotEquals, new[] {"int"}), + new(_localizedTextService.Localize("template", "contains"), Operator.Contains, new[] {"string"}), + new(_localizedTextService.Localize("template", "doesNotContain"), Operator.NotContains, new[] {"string"}), + new(_localizedTextService.Localize("template", "greaterThan"), Operator.GreaterThan, new[] {"int"}), + new(_localizedTextService.Localize("template", "greaterThanEqual"), Operator.GreaterThanEqualTo, new[] {"int"}), + new(_localizedTextService.Localize("template", "lessThan"), Operator.LessThan, new[] {"int"}), + new(_localizedTextService.Localize("template", "lessThanEqual"), Operator.LessThanEqualTo, new[] {"int"}) + }; + + private IEnumerable Properties => new List + { + new() {Name = _localizedTextService.Localize("template", "id"), Alias = "Id", Type = "int"}, + new() {Name = _localizedTextService.Localize("template", "name"), Alias = "Name", Type = "string"}, + new() { - _variationContextAccessor = variationContextAccessor ?? - throw new ArgumentNullException(nameof(variationContextAccessor)); - _publishedContentQuery = - publishedContentQuery ?? throw new ArgumentNullException(nameof(publishedContentQuery)); - _localizedTextService = - localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); - _publishedValueFallback = - publishedValueFallback ?? throw new ArgumentNullException(nameof(publishedValueFallback)); - _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); + Name = _localizedTextService.Localize("template", "createdDate"), + Alias = "CreateDate", + Type = "datetime" + }, + new() + { + Name = _localizedTextService.Localize("template", "lastUpdatedDate"), + Alias = "UpdateDate", + Type = "datetime" + } + }; + + public QueryResultModel PostTemplateQuery(QueryModel model) + { + var queryExpression = new StringBuilder(); + IEnumerable? contents; + + if (model == null) + { + contents = _publishedContentQuery.ContentAtRoot().FirstOrDefault()?.Children(_variationContextAccessor); + queryExpression.Append("Umbraco.ContentAtRoot().FirstOrDefault().Children()"); + } + else + { + contents = PostTemplateValue(model, queryExpression); } - private IEnumerable Terms => new List + // timing should be fairly correct, due to the fact that all the linq statements are yield returned. + var timer = new Stopwatch(); + timer.Start(); + List results = contents?.ToList() ?? new List(); + timer.Stop(); + + return new QueryResultModel { - new OperatorTerm(_localizedTextService.Localize("template","is"), Operator.Equals, new [] {"string"}), - new OperatorTerm(_localizedTextService.Localize("template","isNot"), Operator.NotEquals, new [] {"string"}), - new OperatorTerm(_localizedTextService.Localize("template","before"), Operator.LessThan, new [] {"datetime"}), - new OperatorTerm(_localizedTextService.Localize("template","beforeIncDate"), Operator.LessThanEqualTo, new [] {"datetime"}), - new OperatorTerm(_localizedTextService.Localize("template","after"), Operator.GreaterThan, new [] {"datetime"}), - new OperatorTerm(_localizedTextService.Localize("template","afterIncDate"), Operator.GreaterThanEqualTo, new [] {"datetime"}), - new OperatorTerm(_localizedTextService.Localize("template","equals"), Operator.Equals, new [] {"int"}), - new OperatorTerm(_localizedTextService.Localize("template","doesNotEqual"), Operator.NotEquals, new [] {"int"}), - new OperatorTerm(_localizedTextService.Localize("template","contains"), Operator.Contains, new [] {"string"}), - new OperatorTerm(_localizedTextService.Localize("template","doesNotContain"), Operator.NotContains, new [] {"string"}), - new OperatorTerm(_localizedTextService.Localize("template","greaterThan"), Operator.GreaterThan, new [] {"int"}), - new OperatorTerm(_localizedTextService.Localize("template","greaterThanEqual"), Operator.GreaterThanEqualTo, new [] {"int"}), - new OperatorTerm(_localizedTextService.Localize("template","lessThan"), Operator.LessThan, new [] {"int"}), - new OperatorTerm(_localizedTextService.Localize("template","lessThanEqual"), Operator.LessThanEqualTo, new [] {"int"}) - }; - - private IEnumerable Properties => new List + QueryExpression = queryExpression.ToString(), + ResultCount = results.Count, + ExecutionTime = timer.ElapsedMilliseconds, + SampleResults = results.Take(20).Select(x => new TemplateQueryResult { - new PropertyModel { Name = _localizedTextService.Localize("template","id"), Alias = "Id", Type = "int" }, - new PropertyModel { Name = _localizedTextService.Localize("template","name"), Alias = "Name", Type = "string" }, - new PropertyModel { Name = _localizedTextService.Localize("template","createdDate"), Alias = "CreateDate", Type = "datetime" }, - new PropertyModel { Name = _localizedTextService.Localize("template","lastUpdatedDate"), Alias = "UpdateDate", Type = "datetime" } - }; + Icon = "icon-document", + Name = x.Name + }) + }; + } - public QueryResultModel PostTemplateQuery(QueryModel model) + private IEnumerable? PostTemplateValue(QueryModel model, StringBuilder queryExpression) + { + var indent = Environment.NewLine + " "; + + // set the source + IPublishedContent? sourceDocument; + if (model.Source != null && model.Source.Id > 0) { - var queryExpression = new StringBuilder(); - IEnumerable? contents; + sourceDocument = _publishedContentQuery.Content(model.Source.Id); - if (model == null) + if (sourceDocument == null) { - contents = _publishedContentQuery.ContentAtRoot().FirstOrDefault()?.Children(_variationContextAccessor); - queryExpression.Append("Umbraco.ContentAtRoot().FirstOrDefault().Children()"); + queryExpression.AppendFormat("Umbraco.Content({0})", model.Source.Id); } else { - contents = PostTemplateValue(model, queryExpression); + queryExpression.AppendFormat("Umbraco.Content(Guid.Parse(\"{0}\"))", sourceDocument.Key); } - - // timing should be fairly correct, due to the fact that all the linq statements are yield returned. - var timer = new Stopwatch(); - timer.Start(); - var results = contents?.ToList() ?? new List(); - timer.Stop(); - - return new QueryResultModel - { - QueryExpression = queryExpression.ToString(), - ResultCount = results.Count, - ExecutionTime = timer.ElapsedMilliseconds, - SampleResults = results.Take(20).Select(x => new TemplateQueryResult - { - Icon = "icon-document", - Name = x.Name - }) - }; + } + else + { + sourceDocument = _publishedContentQuery.ContentAtRoot().FirstOrDefault(); + queryExpression.Append("Umbraco.ContentAtRoot().FirstOrDefault()"); } - private IEnumerable? PostTemplateValue(QueryModel model, StringBuilder queryExpression) + // get children, optionally filtered by type + IEnumerable? contents; + queryExpression.Append(indent); + if (model.ContentType != null && !model.ContentType.Alias.IsNullOrWhiteSpace()) { - var indent = Environment.NewLine + " "; + contents = sourceDocument == null + ? Enumerable.Empty() + : sourceDocument.ChildrenOfType(_variationContextAccessor, model.ContentType.Alias); + queryExpression.AppendFormat(".ChildrenOfType(\"{0}\")", model.ContentType.Alias); + } + else + { + contents = sourceDocument == null + ? Enumerable.Empty() + : sourceDocument.Children(_variationContextAccessor); + queryExpression.Append(".Children()"); + } - // set the source - IPublishedContent? sourceDocument; - if (model.Source != null && model.Source.Id > 0) + if (model.Filters is not null) + { + // apply filters + foreach (QueryCondition condition in model.Filters.Where(x => !x.ConstraintValue.IsNullOrWhiteSpace())) { - sourceDocument = _publishedContentQuery.Content(model.Source.Id); - - if (sourceDocument == null) - queryExpression.AppendFormat("Umbraco.Content({0})", model.Source.Id); - else - queryExpression.AppendFormat("Umbraco.Content(Guid.Parse(\"{0}\"))", sourceDocument.Key); - } - else - { - sourceDocument = _publishedContentQuery.ContentAtRoot().FirstOrDefault(); - queryExpression.Append("Umbraco.ContentAtRoot().FirstOrDefault()"); - } - - // get children, optionally filtered by type - IEnumerable? contents; - queryExpression.Append(indent); - if (model.ContentType != null && !model.ContentType.Alias.IsNullOrWhiteSpace()) - { - contents = sourceDocument == null - ? Enumerable.Empty() - : sourceDocument.ChildrenOfType(_variationContextAccessor, model.ContentType.Alias); - queryExpression.AppendFormat(".ChildrenOfType(\"{0}\")", model.ContentType.Alias); - } - else - { - contents = sourceDocument == null - ? Enumerable.Empty() - : sourceDocument.Children(_variationContextAccessor); - queryExpression.Append(".Children()"); - } - - if (model.Filters is not null) - { - // apply filters - foreach (var condition in model.Filters.Where(x => !x.ConstraintValue.IsNullOrWhiteSpace())) - { - //x is passed in as the parameter alias for the linq where statement clause - var operation = condition.BuildCondition("x"); - - //for review - this uses a tonized query rather then the normal linq query. - contents = contents?.Where(operation.Compile()); - queryExpression.Append(indent); - queryExpression.AppendFormat(".Where({0})", operation); - } - } - - // always add IsVisible() to the query - contents = contents?.Where(x => x.IsVisible(_publishedValueFallback)); - queryExpression.Append(indent); - queryExpression.Append(".Where(x => x.IsVisible())"); - - // apply sort - if (model.Sort != null && (!model.Sort.Property?.Alias.IsNullOrWhiteSpace() ?? false)) - { - contents = SortByDefaultPropertyValue(contents, model.Sort); + //x is passed in as the parameter alias for the linq where statement clause + Expression> operation = condition.BuildCondition("x"); + //for review - this uses a tonized query rather then the normal linq query. + contents = contents?.Where(operation.Compile()); queryExpression.Append(indent); - queryExpression.AppendFormat(model.Sort.Direction == "ascending" + queryExpression.AppendFormat(".Where({0})", operation); + } + } + + // always add IsVisible() to the query + contents = contents?.Where(x => x.IsVisible(_publishedValueFallback)); + queryExpression.Append(indent); + queryExpression.Append(".Where(x => x.IsVisible())"); + + // apply sort + if (model.Sort != null && (!model.Sort.Property?.Alias.IsNullOrWhiteSpace() ?? false)) + { + contents = SortByDefaultPropertyValue(contents, model.Sort); + + queryExpression.Append(indent); + queryExpression.AppendFormat( + model.Sort.Direction == "ascending" ? ".OrderBy(x => x.{0})" : ".OrderByDescending(x => x.{0})" - , model.Sort?.Property?.Alias); - } - - // take - if (model.Take > 0) - { - contents = contents?.Take(model.Take); - queryExpression.Append(indent); - queryExpression.AppendFormat(".Take({0})", model.Take); - } - - return contents; + , model.Sort?.Property?.Alias); } - private object GetConstraintValue(QueryCondition condition) + // take + if (model.Take > 0) { - switch (condition.Property.Type) - { - case "int": - return int.Parse(condition.ConstraintValue, CultureInfo.InvariantCulture); - case "datetime": - DateTime dt; - return DateTime.TryParse(condition.ConstraintValue, out dt) ? dt : DateTime.Today; - default: - return condition.ConstraintValue; - } + contents = contents?.Take(model.Take); + queryExpression.Append(indent); + queryExpression.AppendFormat(".Take({0})", model.Take); } - private IEnumerable? SortByDefaultPropertyValue(IEnumerable? contents, SortExpression sortExpression) + return contents; + } + + private object GetConstraintValue(QueryCondition condition) + { + switch (condition.Property.Type) { - switch (sortExpression.Property?.Alias) - { - case "id": - return sortExpression.Direction == "ascending" - ? contents?.OrderBy(x => x.Id) - : contents?.OrderByDescending(x => x.Id); - case "createDate": - return sortExpression.Direction == "ascending" - ? contents?.OrderBy(x => x.CreateDate) - : contents?.OrderByDescending(x => x.CreateDate); - case "publishDate": - return sortExpression.Direction == "ascending" - ? contents?.OrderBy(x => x.UpdateDate) - : contents?.OrderByDescending(x => x.UpdateDate); - case "name": - return sortExpression.Direction == "ascending" - ? contents?.OrderBy(x => x.Name) - : contents?.OrderByDescending(x => x.Name); - default: - return sortExpression.Direction == "ascending" - ? contents?.OrderBy(x => x.Name) - : contents?.OrderByDescending(x => x.Name); - } - } - - /// - /// Gets a list of all content types - /// - /// - public IEnumerable GetContentTypes() - { - var contentTypes = _contentTypeService.GetAll() - .Select(x => new ContentTypeModel { Alias = x.Alias, Name = _localizedTextService.Localize("template", "contentOfType", tokens: new string[] { x.Name ?? string.Empty }) }) - .OrderBy(x => x.Name).ToList(); - - contentTypes.Insert(0, new ContentTypeModel { Alias = string.Empty, Name = _localizedTextService.Localize("template", "allContent") }); - - return contentTypes; - } - - /// - /// Returns a collection of allowed properties. - /// - public IEnumerable GetAllowedProperties() - { - return Properties.OrderBy(x => x.Name); - } - - /// - /// Returns a collection of constraint conditions that can be used in the query - /// - public IEnumerable GetFilterConditions() - { - return Terms; + case "int": + return int.Parse(condition.ConstraintValue, CultureInfo.InvariantCulture); + case "datetime": + DateTime dt; + return DateTime.TryParse(condition.ConstraintValue, out dt) ? dt : DateTime.Today; + default: + return condition.ConstraintValue; } } + + private IEnumerable? SortByDefaultPropertyValue(IEnumerable? contents, SortExpression sortExpression) + { + switch (sortExpression.Property?.Alias) + { + case "id": + return sortExpression.Direction == "ascending" + ? contents?.OrderBy(x => x.Id) + : contents?.OrderByDescending(x => x.Id); + case "createDate": + return sortExpression.Direction == "ascending" + ? contents?.OrderBy(x => x.CreateDate) + : contents?.OrderByDescending(x => x.CreateDate); + case "publishDate": + return sortExpression.Direction == "ascending" + ? contents?.OrderBy(x => x.UpdateDate) + : contents?.OrderByDescending(x => x.UpdateDate); + case "name": + return sortExpression.Direction == "ascending" + ? contents?.OrderBy(x => x.Name) + : contents?.OrderByDescending(x => x.Name); + default: + return sortExpression.Direction == "ascending" + ? contents?.OrderBy(x => x.Name) + : contents?.OrderByDescending(x => x.Name); + } + } + + /// + /// Gets a list of all content types + /// + /// + public IEnumerable GetContentTypes() + { + var contentTypes = _contentTypeService.GetAll() + .Select(x => new ContentTypeModel + { + Alias = x.Alias, + Name = _localizedTextService.Localize("template", "contentOfType", new[] { x.Name ?? string.Empty }) + }) + .OrderBy(x => x.Name).ToList(); + + contentTypes.Insert( + 0, + new ContentTypeModel + { + Alias = string.Empty, + Name = _localizedTextService.Localize("template", "allContent") + }); + + return contentTypes; + } + + /// + /// Returns a collection of allowed properties. + /// + public IEnumerable GetAllowedProperties() => Properties.OrderBy(x => x.Name); + + /// + /// Returns a collection of constraint conditions that can be used in the query + /// + public IEnumerable GetFilterConditions() => Terms; } diff --git a/src/Umbraco.Web.BackOffice/Controllers/TinyMceController.cs b/src/Umbraco.Web.BackOffice/Controllers/TinyMceController.cs index 1b067e71c2..316073d8de 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/TinyMceController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/TinyMceController.cs @@ -1,13 +1,9 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; @@ -17,82 +13,81 @@ using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessForTinyMce)] +public class TinyMceController : UmbracoAuthorizedApiController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessForTinyMce)] - public class TinyMceController : UmbracoAuthorizedApiController + private readonly ContentSettings _contentSettings; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly IIOHelper _ioHelper; + private readonly IShortStringHelper _shortStringHelper; + + public TinyMceController( + IHostingEnvironment hostingEnvironment, + IShortStringHelper shortStringHelper, + IOptionsSnapshot contentSettings, + IIOHelper ioHelper, + IImageUrlGenerator imageUrlGenerator) { - private readonly IHostingEnvironment _hostingEnvironment; - private readonly IShortStringHelper _shortStringHelper; - private readonly ContentSettings _contentSettings; - private readonly IIOHelper _ioHelper; - private readonly IImageUrlGenerator _imageUrlGenerator; + _hostingEnvironment = hostingEnvironment; + _shortStringHelper = shortStringHelper; + _contentSettings = contentSettings.Value; + _ioHelper = ioHelper; + _imageUrlGenerator = imageUrlGenerator; + } - public TinyMceController( - IHostingEnvironment hostingEnvironment, - IShortStringHelper shortStringHelper, - IOptionsSnapshot contentSettings, - IIOHelper ioHelper, - IImageUrlGenerator imageUrlGenerator) + [HttpPost] + public async Task UploadImage(List file) + { + // Create an unique folder path to help with concurrent users to avoid filename clash + var imageTempPath = + _hostingEnvironment.MapPathWebRoot(Constants.SystemDirectories.TempImageUploads + "/" + Guid.NewGuid()); + + // Ensure image temp path exists + if (Directory.Exists(imageTempPath) == false) { - _hostingEnvironment = hostingEnvironment; - _shortStringHelper = shortStringHelper; - _contentSettings = contentSettings.Value; - _ioHelper = ioHelper; - _imageUrlGenerator = imageUrlGenerator; + Directory.CreateDirectory(imageTempPath); } - [HttpPost] - public async Task UploadImage(List file) + // Must have a file + if (file.Count == 0) { - // Create an unique folder path to help with concurrent users to avoid filename clash - var imageTempPath = _hostingEnvironment.MapPathWebRoot(Constants.SystemDirectories.TempImageUploads + "/" + Guid.NewGuid().ToString()); - - // Ensure image temp path exists - if(Directory.Exists(imageTempPath) == false) - { - Directory.CreateDirectory(imageTempPath); - } - - // Must have a file - if (file.Count == 0) - { - return NotFound(); - } - - // Should only have one file - if (file.Count > 1) - { - return new UmbracoProblemResult("Only one file can be uploaded at a time", HttpStatusCode.BadRequest); - } - - var formFile = file.First(); - - // Really we should only have one file per request to this endpoint - // var file = result.FileData[0]; - var fileName = formFile.FileName.Trim(new[] { '\"' }).TrimEnd(); - var safeFileName = fileName.ToSafeFileName(_shortStringHelper); - var ext = safeFileName.Substring(safeFileName.LastIndexOf('.') + 1).ToLowerInvariant(); - - if (_contentSettings.IsFileAllowedForUpload(ext) == false || _imageUrlGenerator.IsSupportedImageFormat(ext) == false) - { - // Throw some error - to say can't upload this IMG type - return new UmbracoProblemResult("This is not an image filetype extension that is approved", HttpStatusCode.BadRequest); - } - - var newFilePath = imageTempPath + Path.DirectorySeparatorChar + safeFileName; - var relativeNewFilePath = _ioHelper.GetRelativePath(newFilePath); - - await using (var stream = System.IO.File.Create(newFilePath)) - { - await formFile.CopyToAsync(stream); - } - - return Ok(new { tmpLocation = relativeNewFilePath }); - + return NotFound(); } + + // Should only have one file + if (file.Count > 1) + { + return new UmbracoProblemResult("Only one file can be uploaded at a time", HttpStatusCode.BadRequest); + } + + IFormFile formFile = file.First(); + + // Really we should only have one file per request to this endpoint + // var file = result.FileData[0]; + var fileName = formFile.FileName.Trim(new[] { '\"' }).TrimEnd(); + var safeFileName = fileName.ToSafeFileName(_shortStringHelper); + var ext = safeFileName.Substring(safeFileName.LastIndexOf('.') + 1).ToLowerInvariant(); + + if (_contentSettings.IsFileAllowedForUpload(ext) == false || + _imageUrlGenerator.IsSupportedImageFormat(ext) == false) + { + // Throw some error - to say can't upload this IMG type + return new UmbracoProblemResult("This is not an image filetype extension that is approved", HttpStatusCode.BadRequest); + } + + var newFilePath = imageTempPath + Path.DirectorySeparatorChar + safeFileName; + var relativeNewFilePath = _ioHelper.GetRelativePath(newFilePath); + + await using (FileStream stream = System.IO.File.Create(newFilePath)) + { + await formFile.CopyToAsync(stream); + } + + return Ok(new { tmpLocation = relativeNewFilePath }); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/TourController.cs b/src/Umbraco.Web.BackOffice/Controllers/TourController.cs index d11334f98e..ae31876ac5 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/TourController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/TourController.cs @@ -1,236 +1,252 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Text; -using System.Threading.Tasks; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Tour; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class TourController : UmbracoAuthorizedJsonController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class TourController : UmbracoAuthorizedJsonController + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IContentTypeService _contentTypeService; + private readonly TourFilterCollection _filters; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly TourSettings _tourSettings; + + public TourController( + TourFilterCollection filters, + IHostingEnvironment hostingEnvironment, + IOptionsSnapshot tourSettings, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IContentTypeService contentTypeService) { - private readonly TourFilterCollection _filters; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly TourSettings _tourSettings; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IContentTypeService _contentTypeService; + _filters = filters; + _hostingEnvironment = hostingEnvironment; - public TourController( - TourFilterCollection filters, - IHostingEnvironment hostingEnvironment, - IOptionsSnapshot tourSettings, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IContentTypeService contentTypeService) + _tourSettings = tourSettings.Value; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _contentTypeService = contentTypeService; + } + + public async Task> GetTours() + { + var result = new List(); + + if (_tourSettings.EnableTours == false) { - _filters = filters; - _hostingEnvironment = hostingEnvironment; - - _tourSettings = tourSettings.Value; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _contentTypeService = contentTypeService; + return result; } - public async Task> GetTours() + IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + if (user == null) { - var result = new List(); + return result; + } - if (_tourSettings.EnableTours == false) - return result; + //get all filters that will be applied to all tour aliases + var aliasOnlyFilters = _filters.Where(x => x.PluginName == null && x.TourFileName == null).ToList(); - var user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - if (user == null) - return result; + //don't pass in any filters for core tours that have a plugin name assigned + var nonPluginFilters = _filters.Where(x => x.PluginName == null).ToList(); - //get all filters that will be applied to all tour aliases - var aliasOnlyFilters = _filters.Where(x => x.PluginName == null && x.TourFileName == null).ToList(); + //add core tour files + IEnumerable embeddedTourNames = GetType() + .Assembly + .GetManifestResourceNames() + .Where(x => x.StartsWith("Umbraco.Cms.Web.BackOffice.EmbeddedResources.Tours.")); - //don't pass in any filters for core tours that have a plugin name assigned - var nonPluginFilters = _filters.Where(x => x.PluginName == null).ToList(); + foreach (var embeddedTourName in embeddedTourNames) + { + await TryParseTourFile(embeddedTourName, result, nonPluginFilters, aliasOnlyFilters, async x => await GetContentFromEmbeddedResource(x)); + } - //add core tour files - var embeddedTourNames = GetType() - .Assembly - .GetManifestResourceNames() - .Where(x => x.StartsWith("Umbraco.Cms.Web.BackOffice.EmbeddedResources.Tours.")); - foreach (var embeddedTourName in embeddedTourNames) + //collect all tour files in packages + var appPlugins = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.AppPlugins); + if (Directory.Exists(appPlugins)) + { + foreach (var plugin in Directory.EnumerateDirectories(appPlugins)) { - await TryParseTourFile(embeddedTourName, result, nonPluginFilters, aliasOnlyFilters, async x=> await GetContentFromEmbeddedResource(x)); - } + var pluginName = Path.GetFileName(plugin.TrimEnd(Constants.CharArrays.Backslash)); + var pluginFilters = _filters.Where(x => x.PluginName != null && x.PluginName.IsMatch(pluginName)) + .ToList(); - - //collect all tour files in packages - var appPlugins = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.AppPlugins); - if (Directory.Exists(appPlugins)) - { - foreach (var plugin in Directory.EnumerateDirectories(appPlugins)) + //If there is any filter applied to match the plugin only (no file or tour alias) then ignore the plugin entirely + var isPluginFiltered = pluginFilters.Any(x => x.TourFileName == null && x.TourAlias == null); + if (isPluginFiltered) { - var pluginName = Path.GetFileName(plugin.TrimEnd(Constants.CharArrays.Backslash)); - var pluginFilters = _filters.Where(x => x.PluginName != null && x.PluginName.IsMatch(pluginName)) - .ToList(); + continue; + } - //If there is any filter applied to match the plugin only (no file or tour alias) then ignore the plugin entirely - var isPluginFiltered = pluginFilters.Any(x => x.TourFileName == null && x.TourAlias == null); - if (isPluginFiltered) continue; + //combine matched package filters with filters not specific to a package + var combinedFilters = nonPluginFilters.Concat(pluginFilters).ToList(); - //combine matched package filters with filters not specific to a package - var combinedFilters = nonPluginFilters.Concat(pluginFilters).ToList(); - - foreach (var backofficeDir in Directory.EnumerateDirectories(plugin, "backoffice")) + foreach (var backofficeDir in Directory.EnumerateDirectories(plugin, "backoffice")) + { + foreach (var tourDir in Directory.EnumerateDirectories(backofficeDir, "tours")) { - foreach (var tourDir in Directory.EnumerateDirectories(backofficeDir, "tours")) + foreach (var tourFile in Directory.EnumerateFiles(tourDir, "*.json")) { - foreach (var tourFile in Directory.EnumerateFiles(tourDir, "*.json")) + await TryParseTourFile( + tourFile, + result, + combinedFilters, + aliasOnlyFilters, + async x => await System.IO.File.ReadAllTextAsync(x), + pluginName); + } + } + } + } + } + + //Get all allowed sections for the current user + var allowedSections = user.AllowedSections.ToList(); + + var toursToBeRemoved = new List(); + + //Checking to see if the user has access to the required tour sections, else we remove the tour + foreach (BackOfficeTourFile backOfficeTourFile in result) + { + if (backOfficeTourFile.Tours != null) + { + foreach (BackOfficeTour tour in backOfficeTourFile.Tours) + { + if (tour.RequiredSections != null) + { + foreach (var toursRequiredSection in tour.RequiredSections) + { + if (allowedSections.Contains(toursRequiredSection) == false) { - await TryParseTourFile(tourFile, result, combinedFilters, aliasOnlyFilters, async x => await System.IO.File.ReadAllTextAsync(x), pluginName); + toursToBeRemoved.Add(backOfficeTourFile); + break; } } } } } + } - //Get all allowed sections for the current user - var allowedSections = user.AllowedSections.ToList(); + return result.Except(toursToBeRemoved).OrderBy(x => x.FileName, StringComparer.InvariantCultureIgnoreCase); + } - var toursToBeRemoved = new List(); + private async Task GetContentFromEmbeddedResource(string fileName) + { + Stream? resourceStream = GetType().Assembly.GetManifestResourceStream(fileName); - //Checking to see if the user has access to the required tour sections, else we remove the tour - foreach (var backOfficeTourFile in result) + if (resourceStream is null) + { + return string.Empty; + } + + using var reader = new StreamReader(resourceStream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + + /// + /// Gets a tours for a specific doctype + /// + /// The documenttype alias + /// A + public async Task> GetToursForDoctype(string doctypeAlias) + { + IEnumerable tourFiles = await GetTours(); + + var doctypeAliasWithCompositions = new List { doctypeAlias }; + + IContentType? contentType = _contentTypeService.Get(doctypeAlias); + + if (contentType != null) + { + doctypeAliasWithCompositions.AddRange(contentType.CompositionAliases()); + } + + return tourFiles.SelectMany(x => x.Tours) + .Where(x => { - if (backOfficeTourFile.Tours != null) + if (string.IsNullOrEmpty(x.ContentType)) { - foreach (var tour in backOfficeTourFile.Tours) - { - if (tour.RequiredSections != null) - { - foreach (var toursRequiredSection in tour.RequiredSections) - { - if (allowedSections.Contains(toursRequiredSection) == false) - { - toursToBeRemoved.Add(backOfficeTourFile); - break; - } - } - } - } + return false; } - } - return result.Except(toursToBeRemoved).OrderBy(x => x.FileName, StringComparer.InvariantCultureIgnoreCase); + IEnumerable contentTypes = x.ContentType + .Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Select(ct => ct.Trim()); + return contentTypes.Intersect(doctypeAliasWithCompositions).Any(); + }); + } + + private async Task TryParseTourFile( + string tourFile, + ICollection result, + List filters, + List aliasOnlyFilters, + Func> fileNameToFileContent, + string? pluginName = null) + { + var fileName = Path.GetFileNameWithoutExtension(tourFile); + if (fileName == null) + { + return; } - private async Task GetContentFromEmbeddedResource(string fileName) - { - var resourceStream = GetType().Assembly.GetManifestResourceStream(fileName); + //get the filters specific to this file + var fileFilters = filters.Where(x => x.TourFileName != null && x.TourFileName.IsMatch(fileName)).ToList(); - if (resourceStream is null) - { - return string.Empty; - } - using var reader = new StreamReader(resourceStream, Encoding.UTF8); - return await reader.ReadToEndAsync(); + //If there is any filter applied to match the file only (no tour alias) then ignore the file entirely + var isFileFiltered = fileFilters.Any(x => x.TourAlias == null); + if (isFileFiltered) + { + return; } - /// - /// Gets a tours for a specific doctype - /// - /// The documenttype alias - /// A - public async Task> GetToursForDoctype(string doctypeAlias) + //now combine all aliases to filter below + var aliasFilters = aliasOnlyFilters.Concat(filters.Where(x => x.TourAlias != null)) + .Select(x => x.TourAlias) + .ToList(); + + try { - var tourFiles = await this.GetTours(); + var contents = await fileNameToFileContent(tourFile); + BackOfficeTour[]? tours = JsonConvert.DeserializeObject(contents); - var doctypeAliasWithCompositions = new List - { - doctypeAlias - }; + IEnumerable? backOfficeTours = tours?.Where(x => + aliasFilters.Count == 0 || aliasFilters.WhereNotNull().All(filter => filter.IsMatch(x.Alias)) == false); - var contentType = _contentTypeService.Get(doctypeAlias); + IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - if (contentType != null) + var localizedTours = backOfficeTours?.Where(x => + string.IsNullOrWhiteSpace(x.Culture) || x.Culture.Equals(user?.Language, StringComparison.InvariantCultureIgnoreCase)).ToList(); + + var tour = new BackOfficeTourFile { - doctypeAliasWithCompositions.AddRange(contentType.CompositionAliases()); - } + FileName = Path.GetFileNameWithoutExtension(tourFile), + PluginName = pluginName, + Tours = localizedTours ?? new List() + }; - return tourFiles.SelectMany(x => x.Tours) - .Where(x => - { - if (string.IsNullOrEmpty(x.ContentType)) - { - return false; - } - var contentTypes = x.ContentType.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Select(ct => ct.Trim()); - return contentTypes.Intersect(doctypeAliasWithCompositions).Any(); - }); + //don't add if all of the tours are filtered + if (tour.Tours.Any()) + { + result.Add(tour); + } } - - private async Task TryParseTourFile(string tourFile, - ICollection result, - List filters, - List aliasOnlyFilters, - Func> fileNameToFileContent, - string? pluginName = null) + catch (IOException e) { - var fileName = Path.GetFileNameWithoutExtension(tourFile); - if (fileName == null) return; - - //get the filters specific to this file - var fileFilters = filters.Where(x => x.TourFileName != null && x.TourFileName.IsMatch(fileName)).ToList(); - - //If there is any filter applied to match the file only (no tour alias) then ignore the file entirely - var isFileFiltered = fileFilters.Any(x => x.TourAlias == null); - if (isFileFiltered) return; - - //now combine all aliases to filter below - var aliasFilters = aliasOnlyFilters.Concat(filters.Where(x => x.TourAlias != null)) - .Select(x => x.TourAlias) - .ToList(); - - try - { - var contents = await fileNameToFileContent(tourFile); - var tours = JsonConvert.DeserializeObject(contents); - - var backOfficeTours = tours?.Where(x => - aliasFilters.Count == 0 || aliasFilters.WhereNotNull().All(filter => filter.IsMatch(x.Alias)) == false); - - var user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - - var localizedTours = backOfficeTours?.Where(x => - string.IsNullOrWhiteSpace(x.Culture) || x.Culture.Equals(user?.Language, - StringComparison.InvariantCultureIgnoreCase)).ToList(); - - var tour = new BackOfficeTourFile - { - FileName = Path.GetFileNameWithoutExtension(tourFile), - PluginName = pluginName, - Tours = localizedTours ?? new List(), - }; - - //don't add if all of the tours are filtered - if (tour.Tours.Any()) - result.Add(tour); - } - catch (IOException e) - { - throw new IOException("Error while trying to read file: " + tourFile, e); - } - catch (JsonReaderException e) - { - throw new JsonReaderException("Error while trying to parse content as tour data: " + tourFile, e); - } + throw new IOException("Error while trying to read file: " + tourFile, e); + } + catch (JsonReaderException e) + { + throw new JsonReaderException("Error while trying to parse content as tour data: " + tourFile, e); } } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/TrackedReferencesController.cs b/src/Umbraco.Web.BackOffice/Controllers/TrackedReferencesController.cs index aa1a0ee86e..c1c2a4f93b 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/TrackedReferencesController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/TrackedReferencesController.cs @@ -7,70 +7,71 @@ using Umbraco.Cms.Web.BackOffice.ModelBinders; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessContentOrMedia)] +public class TrackedReferencesController : BackOfficeNotificationsController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessContentOrMedia)] - public class TrackedReferencesController : BackOfficeNotificationsController + private readonly ITrackedReferencesService _relationService; + + public TrackedReferencesController(ITrackedReferencesService relationService) => _relationService = relationService; + + /// + /// Gets a page list of tracked references for the current item, so you can see where an item is being used. + /// + /// + /// Used by info tabs on content, media etc. and for the delete and unpublish of single items. + /// This is basically finding parents of relations. + /// + public ActionResult> GetPagedReferences(int id, int pageNumber = 1, int pageSize = 100, + bool filterMustBeIsDependency = false) { - private readonly ITrackedReferencesService _relationService; - - public TrackedReferencesController(ITrackedReferencesService relationService) + if (pageNumber <= 0 || pageSize <= 0) { - _relationService = relationService; + return BadRequest("Both pageNumber and pageSize must be greater than zero"); } - /// - /// Gets a page list of tracked references for the current item, so you can see where an item is being used. - /// - /// - /// Used by info tabs on content, media etc. and for the delete and unpublish of single items. - /// This is basically finding parents of relations. - /// - public ActionResult> GetPagedReferences(int id, int pageNumber = 1, int pageSize = 100, bool filterMustBeIsDependency = false) - { - if (pageNumber <= 0 || pageSize <= 0) - { - return BadRequest("Both pageNumber and pageSize must be greater than zero"); - } + return _relationService.GetPagedRelationsForItem(id, pageNumber - 1, pageSize, filterMustBeIsDependency); + } - return _relationService.GetPagedRelationsForItem(id, pageNumber - 1, pageSize, filterMustBeIsDependency); + /// + /// Gets a page list of the child nodes of the current item used in any kind of relation. + /// + /// + /// Used when deleting and unpublishing a single item to check if this item has any descending items that are in any + /// kind of relation. + /// This is basically finding the descending items which are children in relations. + /// + public ActionResult> GetPagedDescendantsInReferences(int parentId, int pageNumber = 1, + int pageSize = 100, bool filterMustBeIsDependency = true) + { + if (pageNumber <= 0 || pageSize <= 0) + { + return BadRequest("Both pageNumber and pageSize must be greater than zero"); } - /// - /// Gets a page list of the child nodes of the current item used in any kind of relation. - /// - /// - /// Used when deleting and unpublishing a single item to check if this item has any descending items that are in any kind of relation. - /// This is basically finding the descending items which are children in relations. - /// - public ActionResult> GetPagedDescendantsInReferences(int parentId, int pageNumber = 1, int pageSize = 100, bool filterMustBeIsDependency = true) - { - if (pageNumber <= 0 || pageSize <= 0) - { - return BadRequest("Both pageNumber and pageSize must be greater than zero"); - } + return _relationService.GetPagedDescendantsInReferences(parentId, pageNumber - 1, pageSize, + filterMustBeIsDependency); + } - return _relationService.GetPagedDescendantsInReferences(parentId, pageNumber - 1, pageSize, filterMustBeIsDependency); + /// + /// Gets a page list of the items used in any kind of relation from selected integer ids. + /// + /// + /// Used when bulk deleting content/media and bulk unpublishing content (delete and unpublish on List view). + /// This is basically finding children of relations. + /// + [HttpGet] + [HttpPost] + public ActionResult> GetPagedReferencedItems([FromJsonPath] int[] ids, int pageNumber = 1, + int pageSize = 100, bool filterMustBeIsDependency = true) + { + if (pageNumber <= 0 || pageSize <= 0) + { + return BadRequest("Both pageNumber and pageSize must be greater than zero"); } - /// - /// Gets a page list of the items used in any kind of relation from selected integer ids. - /// - /// - /// Used when bulk deleting content/media and bulk unpublishing content (delete and unpublish on List view). - /// This is basically finding children of relations. - /// - [HttpGet] - [HttpPost] - public ActionResult> GetPagedReferencedItems([FromJsonPath] int[] ids, int pageNumber = 1, int pageSize = 100, bool filterMustBeIsDependency = true) - { - if (pageNumber <= 0 || pageSize <= 0) - { - return BadRequest("Both pageNumber and pageSize must be greater than zero"); - } - - return _relationService.GetPagedItemsWithRelations(ids, pageNumber - 1, pageSize, filterMustBeIsDependency); - } + return _relationService.GetPagedItemsWithRelations(ids, pageNumber - 1, pageSize, filterMustBeIsDependency); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/TwoFactorLoginController.cs b/src/Umbraco.Web.BackOffice/Controllers/TwoFactorLoginController.cs index 9429423755..b0e081e9de 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/TwoFactorLoginController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/TwoFactorLoginController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -9,122 +9,124 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +public class TwoFactorLoginController : UmbracoAuthorizedJsonController { - public class TwoFactorLoginController : UmbracoAuthorizedJsonController + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IBackOfficeSignInManager _backOfficeSignInManager; + private readonly IBackOfficeUserManager _backOfficeUserManager; + private readonly ILogger _logger; + private readonly ITwoFactorLoginService2 _twoFactorLoginService; + private readonly IOptionsSnapshot _twoFactorLoginViewOptions; + + public TwoFactorLoginController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + ILogger logger, + ITwoFactorLoginService twoFactorLoginService, + IBackOfficeSignInManager backOfficeSignInManager, + IBackOfficeUserManager backOfficeUserManager, + IOptionsSnapshot twoFactorLoginViewOptions) { - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly ILogger _logger; - private readonly ITwoFactorLoginService2 _twoFactorLoginService; - private readonly IBackOfficeSignInManager _backOfficeSignInManager; - private readonly IBackOfficeUserManager _backOfficeUserManager; - private readonly IOptionsSnapshot _twoFactorLoginViewOptions; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _logger = logger; - public TwoFactorLoginController( - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - ILogger logger, - ITwoFactorLoginService twoFactorLoginService, - IBackOfficeSignInManager backOfficeSignInManager, - IBackOfficeUserManager backOfficeUserManager, - IOptionsSnapshot twoFactorLoginViewOptions) + if (twoFactorLoginService is not ITwoFactorLoginService2 twoFactorLoginService2) { - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _logger = logger; - - if (twoFactorLoginService is not ITwoFactorLoginService2 twoFactorLoginService2) - { - throw new ArgumentException("twoFactorLoginService needs to implement ITwoFactorLoginService2 until the interfaces are merged", nameof(twoFactorLoginService)); - } - _twoFactorLoginService = twoFactorLoginService2; - _backOfficeSignInManager = backOfficeSignInManager; - _backOfficeUserManager = backOfficeUserManager; - _twoFactorLoginViewOptions = twoFactorLoginViewOptions; + throw new ArgumentException( + "twoFactorLoginService needs to implement ITwoFactorLoginService2 until the interfaces are merged", + nameof(twoFactorLoginService)); } - /// - /// Used to retrieve the 2FA providers for code submission - /// - /// - [HttpGet] - [AllowAnonymous] - public async Task>> GetEnabled2FAProvidersForCurrentUser() - { - var user = await _backOfficeSignInManager.GetTwoFactorAuthenticationUserAsync(); - if (user == null) - { - _logger.LogWarning("No verified user found, returning 404"); - return NotFound(); - } + _twoFactorLoginService = twoFactorLoginService2; + _backOfficeSignInManager = backOfficeSignInManager; + _backOfficeUserManager = backOfficeUserManager; + _twoFactorLoginViewOptions = twoFactorLoginViewOptions; + } - var userFactors = await _backOfficeUserManager.GetValidTwoFactorProvidersAsync(user); - return new ObjectResult(userFactors); + /// + /// Used to retrieve the 2FA providers for code submission + /// + /// + [HttpGet] + [AllowAnonymous] + public async Task>> GetEnabled2FAProvidersForCurrentUser() + { + BackOfficeIdentityUser? user = await _backOfficeSignInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + _logger.LogWarning("No verified user found, returning 404"); + return NotFound(); } + IList userFactors = await _backOfficeUserManager.GetValidTwoFactorProvidersAsync(user); + return new ObjectResult(userFactors); + } - [HttpGet] - public async Task>> Get2FAProvidersForUser(int userId) + + [HttpGet] + public async Task>> Get2FAProvidersForUser(int userId) + { + BackOfficeIdentityUser user = await _backOfficeUserManager.FindByIdAsync(userId.ToString(CultureInfo.InvariantCulture)); + + var enabledProviderNameHashSet = + new HashSet(await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(user.Key)); + + IEnumerable providerNames = await _backOfficeUserManager.GetValidTwoFactorProvidersAsync(user); + + // Filter out any providers that does not have a view attached to it, since it's unusable then. + providerNames = providerNames.Where(providerName => { - var user = await _backOfficeUserManager.FindByIdAsync(userId.ToString(CultureInfo.InvariantCulture)); + TwoFactorLoginViewOptions options = _twoFactorLoginViewOptions.Get(providerName); + return options is not null && !string.IsNullOrWhiteSpace(options.SetupViewPath); + }); - var enabledProviderNameHashSet = new HashSet(await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(user.Key)); + return providerNames.Select(providerName => + new UserTwoFactorProviderModel(providerName, enabledProviderNameHashSet.Contains(providerName))).ToArray(); + } - IEnumerable providerNames = await _backOfficeUserManager.GetValidTwoFactorProvidersAsync(user); + [HttpGet] + public async Task> SetupInfo(string providerName) + { + IUser? user = _backOfficeSecurityAccessor?.BackOfficeSecurity?.CurrentUser; - // Filter out any providers that does not have a view attached to it, since it's unusable then. - providerNames = providerNames.Where(providerName => - { - TwoFactorLoginViewOptions options = _twoFactorLoginViewOptions.Get(providerName); - return options is not null && !string.IsNullOrWhiteSpace(options.SetupViewPath); - }); + var setupInfo = await _twoFactorLoginService.GetSetupInfoAsync(user!.Key, providerName); - return providerNames.Select(providerName => - new UserTwoFactorProviderModel(providerName, enabledProviderNameHashSet.Contains(providerName))).ToArray(); - } - - [HttpGet] - public async Task> SetupInfo(string providerName) - { - var user = _backOfficeSecurityAccessor?.BackOfficeSecurity?.CurrentUser; - - var setupInfo = await _twoFactorLoginService.GetSetupInfoAsync(user!.Key, providerName); - - return setupInfo; - } + return setupInfo; + } - [HttpPost] - public async Task> ValidateAndSave(string providerName, string secret, string code) - { - var user = _backOfficeSecurityAccessor?.BackOfficeSecurity?.CurrentUser; + [HttpPost] + public async Task> ValidateAndSave(string providerName, string secret, string code) + { + IUser? user = _backOfficeSecurityAccessor?.BackOfficeSecurity?.CurrentUser; - return await _twoFactorLoginService.ValidateAndSaveAsync(providerName, user!.Key, secret, code); - } + return await _twoFactorLoginService.ValidateAndSaveAsync(providerName, user!.Key, secret, code); + } - [HttpPost] - [Authorize(Policy = AuthorizationPolicies.SectionAccessUsers)] - public async Task> Disable(string providerName, Guid userKey) - { - return await _twoFactorLoginService.DisableAsync(userKey, providerName); - } + [HttpPost] + [Authorize(Policy = AuthorizationPolicies.SectionAccessUsers)] + public async Task> Disable(string providerName, Guid userKey) => + await _twoFactorLoginService.DisableAsync(userKey, providerName); - [HttpPost] - public async Task> DisableWithCode(string providerName, string code) - { - Guid? key = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key; + [HttpPost] + public async Task> DisableWithCode(string providerName, string code) + { + Guid? key = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key; - return await _twoFactorLoginService.DisableWithCodeAsync(providerName, key!.Value, code); - } + return await _twoFactorLoginService.DisableWithCodeAsync(providerName, key!.Value, code); + } - [HttpGet] - public ActionResult ViewPathForProviderName(string providerName) - { - var options = _twoFactorLoginViewOptions.Get(providerName); - return options.SetupViewPath; - } + [HttpGet] + public ActionResult ViewPathForProviderName(string providerName) + { + TwoFactorLoginViewOptions? options = _twoFactorLoginViewOptions.Get(providerName); + return options.SetupViewPath; } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UmbracoAuthorizedApiController.cs b/src/Umbraco.Web.BackOffice/Controllers/UmbracoAuthorizedApiController.cs index 472b67224c..14308eb63b 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UmbracoAuthorizedApiController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UmbracoAuthorizedApiController.cs @@ -13,125 +13,123 @@ using Umbraco.Cms.Web.Common.Controllers; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// Provides a base class for authorized auto-routed Umbraco API controllers. +/// +/// +/// This controller will also append a custom header to the response if the user +/// is logged in using forms authentication which indicates the seconds remaining +/// before their timeout expires. +/// +[AngularJsonOnlyConfiguration] // TODO: This could be applied with our Application Model conventions +[JsonExceptionFilter] +[IsBackOffice] +[UmbracoUserTimeoutFilter] +[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] +[DisableBrowserCache] +[UmbracoRequireHttps] +[CheckIfUserTicketDataIsStale] +[MiddlewareFilter(typeof(UnhandledExceptionLoggerFilter))] +public abstract class UmbracoAuthorizedApiController : UmbracoApiController { /// - /// Provides a base class for authorized auto-routed Umbraco API controllers. + /// Returns a validation problem result for the and the /// - /// - /// This controller will also append a custom header to the response if the user - /// is logged in using forms authentication which indicates the seconds remaining - /// before their timeout expires. - /// - [AngularJsonOnlyConfiguration] // TODO: This could be applied with our Application Model conventions - [JsonExceptionFilter] - [IsBackOffice] - [UmbracoUserTimeoutFilter] - [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] - [DisableBrowserCache] - [UmbracoRequireHttps] - [CheckIfUserTicketDataIsStale] - [MiddlewareFilter(typeof(UnhandledExceptionLoggerFilter))] - public abstract class UmbracoAuthorizedApiController : UmbracoApiController + /// + /// + /// + /// + protected virtual ActionResult ValidationProblem(IErrorModel? model, ModelStateDictionary modelStateDictionary, + int statusCode = StatusCodes.Status400BadRequest) { - /// - /// Returns a validation problem result for the and the - /// - /// - /// - /// - /// - protected virtual ActionResult ValidationProblem(IErrorModel? model, ModelStateDictionary modelStateDictionary, int statusCode = StatusCodes.Status400BadRequest) + if (model is not null) { - if (model is not null) - { - model.Errors = modelStateDictionary.ToErrorDictionary(); - } - - return ValidationProblem(model, statusCode); + model.Errors = modelStateDictionary.ToErrorDictionary(); } - /// - /// Overridden to return Umbraco compatible errors - /// - /// - /// - [NonAction] - public override ActionResult ValidationProblem(ModelStateDictionary modelStateDictionary) - { - return new ValidationErrorResult(new SimpleValidationModel(modelStateDictionary.ToErrorDictionary())); - - //ValidationProblemDetails problemDetails = GetValidationProblemDetails(modelStateDictionary: modelStateDictionary); - //return new ValidationErrorResult(problemDetails); - } - - // creates validation problem details instance. - // borrowed from netcore: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Core/src/ControllerBase.cs#L1970 - protected ValidationProblemDetails? GetValidationProblemDetails( - string? detail = null, - string? instance = null, - int? statusCode = null, - string? title = null, - string? type = null, - [ActionResultObjectValue] ModelStateDictionary? modelStateDictionary = null) - { - modelStateDictionary ??= ModelState; - - ValidationProblemDetails? validationProblem; - if (ProblemDetailsFactory == null) - { - // ProblemDetailsFactory may be null in unit testing scenarios. Improvise to make this more testable. - validationProblem = new ValidationProblemDetails(modelStateDictionary) - { - Detail = detail, - Instance = instance, - Status = statusCode, - Title = title, - Type = type, - }; - } - else - { - validationProblem = ProblemDetailsFactory?.CreateValidationProblemDetails( - HttpContext, - modelStateDictionary, - statusCode: statusCode, - title: title, - type: type, - detail: detail, - instance: instance); - } - - return validationProblem; - } - - /// - /// Returns an Umbraco compatible validation problem for the given error message - /// - /// - /// - protected virtual ActionResult ValidationProblem(string errorMessage) - { - ValidationProblemDetails? problemDetails = GetValidationProblemDetails(errorMessage); - return new ValidationErrorResult(problemDetails); - } - - /// - /// Returns an Umbraco compatible validation problem for the object result - /// - /// - /// - /// - protected virtual ActionResult ValidationProblem(object? value, int statusCode) - => new ValidationErrorResult(value, statusCode); - - /// - /// Returns an Umbraco compatible validation problem for the given notification model - /// - /// - /// - /// - protected virtual ActionResult ValidationProblem(INotificationModel? model, int statusCode = StatusCodes.Status400BadRequest) - => new ValidationErrorResult(model, statusCode); + return ValidationProblem(model, statusCode); } + + /// + /// Overridden to return Umbraco compatible errors + /// + /// + /// + [NonAction] + public override ActionResult ValidationProblem(ModelStateDictionary modelStateDictionary) => + new ValidationErrorResult(new SimpleValidationModel(modelStateDictionary.ToErrorDictionary())); + + //ValidationProblemDetails problemDetails = GetValidationProblemDetails(modelStateDictionary: modelStateDictionary); + //return new ValidationErrorResult(problemDetails); + // creates validation problem details instance. + // borrowed from netcore: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Core/src/ControllerBase.cs#L1970 + protected ValidationProblemDetails? GetValidationProblemDetails( + string? detail = null, + string? instance = null, + int? statusCode = null, + string? title = null, + string? type = null, + [ActionResultObjectValue] ModelStateDictionary? modelStateDictionary = null) + { + modelStateDictionary ??= ModelState; + + ValidationProblemDetails? validationProblem; + if (ProblemDetailsFactory == null) + { + // ProblemDetailsFactory may be null in unit testing scenarios. Improvise to make this more testable. + validationProblem = new ValidationProblemDetails(modelStateDictionary) + { + Detail = detail, + Instance = instance, + Status = statusCode, + Title = title, + Type = type + }; + } + else + { + validationProblem = ProblemDetailsFactory?.CreateValidationProblemDetails( + HttpContext, + modelStateDictionary, + statusCode, + title, + type, + detail, + instance); + } + + return validationProblem; + } + + /// + /// Returns an Umbraco compatible validation problem for the given error message + /// + /// + /// + protected virtual ActionResult ValidationProblem(string errorMessage) + { + ValidationProblemDetails? problemDetails = GetValidationProblemDetails(errorMessage); + return new ValidationErrorResult(problemDetails); + } + + /// + /// Returns an Umbraco compatible validation problem for the object result + /// + /// + /// + /// + protected virtual ActionResult ValidationProblem(object? value, int statusCode) + => new ValidationErrorResult(value, statusCode); + + /// + /// Returns an Umbraco compatible validation problem for the given notification model + /// + /// + /// + /// + protected virtual ActionResult ValidationProblem(INotificationModel? model, + int statusCode = StatusCodes.Status400BadRequest) + => new ValidationErrorResult(model, statusCode); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UmbracoAuthorizedJsonController.cs b/src/Umbraco.Web.BackOffice/Controllers/UmbracoAuthorizedJsonController.cs index ca536346ae..e3d2e692c0 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UmbracoAuthorizedJsonController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UmbracoAuthorizedJsonController.cs @@ -1,17 +1,15 @@ using Umbraco.Cms.Web.BackOffice.Filters; -using Umbraco.Cms.Web.Common.Filters; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +/// +/// An abstract API controller that only supports JSON and all requests must contain the correct csrf header +/// +/// +/// Inheriting from this controller means that ALL of your methods are JSON methods that are called by Angular, +/// methods that are not called by Angular or don't contain a valid csrf header will NOT work. +/// +[ValidateAngularAntiForgeryToken] +public abstract class UmbracoAuthorizedJsonController : UmbracoAuthorizedApiController { - /// - /// An abstract API controller that only supports JSON and all requests must contain the correct csrf header - /// - /// - /// Inheriting from this controller means that ALL of your methods are JSON methods that are called by Angular, - /// methods that are not called by Angular or don't contain a valid csrf header will NOT work. - /// - [ValidateAngularAntiForgeryToken] - public abstract class UmbracoAuthorizedJsonController : UmbracoAuthorizedApiController - { - } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UpdateCheckController.cs b/src/Umbraco.Web.BackOffice/Controllers/UpdateCheckController.cs index 9620848666..0c76bfdca5 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UpdateCheckController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UpdateCheckController.cs @@ -1,9 +1,8 @@ -using System; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; @@ -13,103 +12,106 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class UpdateCheckController : UmbracoAuthorizedJsonController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class UpdateCheckController : UmbracoAuthorizedJsonController + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly ICookieManager _cookieManager; + private readonly GlobalSettings _globalSettings; + private readonly IUmbracoVersion _umbracoVersion; + private readonly IUpgradeService _upgradeService; + + public UpdateCheckController( + IUpgradeService upgradeService, + IUmbracoVersion umbracoVersion, + ICookieManager cookieManager, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IOptionsSnapshot globalSettings) { - private readonly IUpgradeService _upgradeService; - private readonly IUmbracoVersion _umbracoVersion; - private readonly ICookieManager _cookieManager; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly GlobalSettings _globalSettings; + _upgradeService = upgradeService ?? throw new ArgumentNullException(nameof(upgradeService)); + _umbracoVersion = umbracoVersion ?? throw new ArgumentNullException(nameof(umbracoVersion)); + _cookieManager = cookieManager ?? throw new ArgumentNullException(nameof(cookieManager)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _globalSettings = globalSettings.Value ?? throw new ArgumentNullException(nameof(globalSettings)); + } - public UpdateCheckController( - IUpgradeService upgradeService, - IUmbracoVersion umbracoVersion, - ICookieManager cookieManager, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IOptionsSnapshot globalSettings) + [UpdateCheckResponseFilter] + public async Task GetCheck() + { + var updChkCookie = _cookieManager.GetCookieValue("UMB_UPDCHK"); + var updateCheckCookie = updChkCookie ?? string.Empty; + if (_globalSettings.VersionCheckPeriod > 0 && string.IsNullOrEmpty(updateCheckCookie) && + (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false)) { - _upgradeService = upgradeService ?? throw new ArgumentNullException(nameof(upgradeService)); - _umbracoVersion = umbracoVersion ?? throw new ArgumentNullException(nameof(umbracoVersion)); - _cookieManager = cookieManager ?? throw new ArgumentNullException(nameof(cookieManager)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _globalSettings = globalSettings.Value ?? throw new ArgumentNullException(nameof(globalSettings)); + try + { + var version = new SemVersion(_umbracoVersion.Version.Major, _umbracoVersion.Version.Minor, + _umbracoVersion.Version.Build, _umbracoVersion.Comment); + UpgradeResult result = await _upgradeService.CheckUpgrade(version); + + return new UpgradeCheckResponse(result.UpgradeType, result.Comment, result.UpgradeUrl, _umbracoVersion); + } + catch + { + //We don't want to crash due to this + return null; + } } - [UpdateCheckResponseFilter] - public async Task GetCheck() - { - var updChkCookie = _cookieManager.GetCookieValue("UMB_UPDCHK"); - var updateCheckCookie = updChkCookie ?? string.Empty; - if (_globalSettings.VersionCheckPeriod > 0 && string.IsNullOrEmpty(updateCheckCookie) && (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false)) - { - try - { - var version = new SemVersion(_umbracoVersion.Version.Major, _umbracoVersion.Version.Minor, - _umbracoVersion.Version.Build, _umbracoVersion.Comment); - var result = await _upgradeService.CheckUpgrade(version); + return null; + } - return new UpgradeCheckResponse(result.UpgradeType, result.Comment, result.UpgradeUrl, _umbracoVersion); - } - catch - { - //We don't want to crash due to this - return null; - } - } - return null; + /// + /// Adds the cookie response if it was successful + /// + /// + /// A filter is required because we are returning an object from the get method and not an HttpResponseMessage + /// + internal class UpdateCheckResponseFilterAttribute : TypeFilterAttribute + { + public UpdateCheckResponseFilterAttribute() : base(typeof(UpdateCheckResponseFilter)) + { } - /// - /// Adds the cookie response if it was successful - /// - /// - /// A filter is required because we are returning an object from the get method and not an HttpResponseMessage - /// - /// - internal class UpdateCheckResponseFilterAttribute : TypeFilterAttribute + private class UpdateCheckResponseFilter : IActionFilter { - public UpdateCheckResponseFilterAttribute() : base(typeof(UpdateCheckResponseFilter)) - { - } + private readonly GlobalSettings _globalSettings; - private class UpdateCheckResponseFilter : IActionFilter - { - private readonly GlobalSettings _globalSettings; + public UpdateCheckResponseFilter(IOptionsSnapshot globalSettings) => + _globalSettings = globalSettings.Value; - public UpdateCheckResponseFilter(IOptionsSnapshot globalSettings) + public void OnActionExecuted(ActionExecutedContext context) + { + if (context.HttpContext.Response == null) { - _globalSettings = globalSettings.Value; + return; } - public void OnActionExecuted(ActionExecutedContext context) + if (context.Result is ObjectResult objectContent) { - if (context.HttpContext.Response == null) return; - - if (context.Result is ObjectResult objectContent) + if (objectContent.Value == null) { - if (objectContent.Value == null) return; + return; + } - context.HttpContext.Response.Cookies.Append("UMB_UPDCHK", "1", new CookieOptions() + context.HttpContext.Response.Cookies.Append("UMB_UPDCHK", "1", + new CookieOptions { Path = "/", Expires = DateTimeOffset.Now.AddDays(_globalSettings.VersionCheckPeriod), HttpOnly = true, Secure = _globalSettings.UseHttps }); - } - } - - public void OnActionExecuting(ActionExecutingContext context) - { - } } - } + public void OnActionExecuting(ActionExecutingContext context) + { + } + } } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs b/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs index e71018c460..ff9160b1c6 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs @@ -1,137 +1,145 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +internal class UserGroupEditorAuthorizationHelper { - internal class UserGroupEditorAuthorizationHelper + private readonly AppCaches _appCaches; + private readonly IContentService _contentService; + private readonly IEntityService _entityService; + private readonly IMediaService _mediaService; + private readonly IUserService _userService; + + public UserGroupEditorAuthorizationHelper(IUserService userService, IContentService contentService, IMediaService mediaService, IEntityService entityService, AppCaches appCaches) { - private readonly IUserService _userService; - private readonly IContentService _contentService; - private readonly IMediaService _mediaService; - private readonly IEntityService _entityService; - private readonly AppCaches _appCaches; + _userService = userService; + _contentService = contentService; + _mediaService = mediaService; + _entityService = entityService; + _appCaches = appCaches; + } - public UserGroupEditorAuthorizationHelper(IUserService userService, IContentService contentService, IMediaService mediaService, IEntityService entityService, AppCaches appCaches) + /// + /// Authorize that the current user belongs to these groups + /// + /// + /// + /// + public Attempt AuthorizeGroupAccess(IUser? currentUser, params int[] groupIds) + { + if (currentUser?.IsAdmin() ?? false) { - _userService = userService; - _contentService = contentService; - _mediaService = mediaService; - _entityService = entityService; - _appCaches = appCaches; - } - - /// - /// Authorize that the current user belongs to these groups - /// - /// - /// - /// - public Attempt AuthorizeGroupAccess(IUser? currentUser, params int[] groupIds) - { - if (currentUser?.IsAdmin() ?? false) - { - return Attempt.Succeed(); - } - - var groups = _userService.GetAllUserGroups(groupIds.ToArray()); - var groupAliases = groups.Select(x => x.Alias).ToArray(); - var userGroups = currentUser?.Groups.Select(x => x.Alias).ToArray() ?? Array.Empty(); - var missingAccess = groupAliases.Except(userGroups).ToArray(); - return missingAccess.Length == 0 - ? Attempt.Succeed() - : Attempt.Fail("User is not a member of " + string.Join(", ", missingAccess)); - } - - /// - /// Authorize that the current user belongs to these groups - /// - /// - /// - /// - public Attempt AuthorizeGroupAccess(IUser? currentUser, params string[] groupAliases) - { - if (currentUser?.IsAdmin() ?? false) - return Attempt.Succeed(); - - var existingGroups = _userService.GetUserGroupsByAlias(groupAliases); - - if(!existingGroups.Any()) - { - // We're dealing with new groups, - // so authorization should be given to any user with access to Users section - if (currentUser?.AllowedSections.Contains(Constants.Applications.Users) ?? false) - return Attempt.Succeed(); - } - - var userGroups = currentUser?.Groups.Select(x => x.Alias).ToArray(); - var missingAccess = groupAliases.Except(userGroups ?? Array.Empty()).ToArray(); - return missingAccess.Length == 0 - ? Attempt.Succeed() - : Attempt.Fail("User is not a member of " + string.Join(", ", missingAccess)); - } - - /// - /// Authorize that the user is not adding a section to the group that they don't have access to - /// - public Attempt AuthorizeSectionChanges( - IUser? currentUser, - IEnumerable? existingSections, - IEnumerable? proposedAllowedSections) - { - if (currentUser?.IsAdmin() ?? false) - return Attempt.Succeed(); - - var sectionsAdded = proposedAllowedSections?.Except(existingSections ?? Enumerable.Empty()).ToArray(); - var sectionAccessMissing = sectionsAdded?.Except(currentUser?.AllowedSections ?? Enumerable.Empty()).ToArray(); - return sectionAccessMissing?.Length > 0 - ? Attempt.Fail("Current user doesn't have access to add these sections " + string.Join(", ", sectionAccessMissing)) - : Attempt.Succeed(); - } - - /// - /// Authorize that the user is not changing to a start node that they don't have access to (including admins) - /// - /// - /// - /// - /// - /// - /// - public Attempt AuthorizeStartNodeChanges(IUser? currentUser, - int? currentContentStartId, - int? proposedContentStartId, - int? currentMediaStartId, - int? proposedMediaStartId) - { - if (currentContentStartId != proposedContentStartId && proposedContentStartId.HasValue) - { - var content = _contentService.GetById(proposedContentStartId.Value); - if (content != null) - { - if (currentUser?.HasPathAccess(content, _entityService, _appCaches) == false) - return Attempt.Fail("Current user doesn't have access to the content path " + content.Path); - } - } - - if (currentMediaStartId != proposedMediaStartId && proposedMediaStartId.HasValue) - { - var media = _mediaService.GetById(proposedMediaStartId.Value); - if (media != null) - { - if (currentUser?.HasPathAccess(media, _entityService, _appCaches) == false) - return Attempt.Fail("Current user doesn't have access to the media path " + media.Path); - } - } - return Attempt.Succeed(); } + + IEnumerable groups = _userService.GetAllUserGroups(groupIds.ToArray()); + var groupAliases = groups.Select(x => x.Alias).ToArray(); + var userGroups = currentUser?.Groups.Select(x => x.Alias).ToArray() ?? Array.Empty(); + var missingAccess = groupAliases.Except(userGroups).ToArray(); + return missingAccess.Length == 0 + ? Attempt.Succeed() + : Attempt.Fail("User is not a member of " + string.Join(", ", missingAccess)); + } + + /// + /// Authorize that the current user belongs to these groups + /// + /// + /// + /// + public Attempt AuthorizeGroupAccess(IUser? currentUser, params string[] groupAliases) + { + if (currentUser?.IsAdmin() ?? false) + { + return Attempt.Succeed(); + } + + IEnumerable existingGroups = _userService.GetUserGroupsByAlias(groupAliases); + + if (!existingGroups.Any()) + { + // We're dealing with new groups, + // so authorization should be given to any user with access to Users section + if (currentUser?.AllowedSections.Contains(Constants.Applications.Users) ?? false) + { + return Attempt.Succeed(); + } + } + + var userGroups = currentUser?.Groups.Select(x => x.Alias).ToArray(); + var missingAccess = groupAliases.Except(userGroups ?? Array.Empty()).ToArray(); + return missingAccess.Length == 0 + ? Attempt.Succeed() + : Attempt.Fail("User is not a member of " + string.Join(", ", missingAccess)); + } + + /// + /// Authorize that the user is not adding a section to the group that they don't have access to + /// + public Attempt AuthorizeSectionChanges( + IUser? currentUser, + IEnumerable? existingSections, + IEnumerable? proposedAllowedSections) + { + if (currentUser?.IsAdmin() ?? false) + { + return Attempt.Succeed(); + } + + var sectionsAdded = proposedAllowedSections?.Except(existingSections ?? Enumerable.Empty()).ToArray(); + var sectionAccessMissing = + sectionsAdded?.Except(currentUser?.AllowedSections ?? Enumerable.Empty()).ToArray(); + return sectionAccessMissing?.Length > 0 + ? Attempt.Fail("Current user doesn't have access to add these sections " + + string.Join(", ", sectionAccessMissing)) + : Attempt.Succeed(); + } + + /// + /// Authorize that the user is not changing to a start node that they don't have access to (including admins) + /// + /// + /// + /// + /// + /// + /// + public Attempt AuthorizeStartNodeChanges( + IUser? currentUser, + int? currentContentStartId, + int? proposedContentStartId, + int? currentMediaStartId, + int? proposedMediaStartId) + { + if (currentContentStartId != proposedContentStartId && proposedContentStartId.HasValue) + { + IContent? content = _contentService.GetById(proposedContentStartId.Value); + if (content != null) + { + if (currentUser?.HasPathAccess(content, _entityService, _appCaches) == false) + { + return Attempt.Fail("Current user doesn't have access to the content path " + content.Path); + } + } + } + + if (currentMediaStartId != proposedMediaStartId && proposedMediaStartId.HasValue) + { + IMedia? media = _mediaService.GetById(proposedMediaStartId.Value); + if (media != null) + { + if (currentUser?.HasPathAccess(media, _entityService, _appCaches) == false) + { + return Attempt.Fail("Current user doesn't have access to the media path " + media.Path); + } + } + } + + return Attempt.Succeed(); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs b/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs index ddad1a0a64..280b8cf30f 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs @@ -1,8 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models.ContentEditing; @@ -14,206 +12,224 @@ using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessUsers)] +[PrefixlessBodyModelValidator] +public class UserGroupsController : BackOfficeNotificationsController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessUsers)] - [PrefixlessBodyModelValidator] - public class UserGroupsController : BackOfficeNotificationsController + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IContentService _contentService; + private readonly IEntityService _entityService; + private readonly ILocalizedTextService _localizedTextService; + private readonly IMediaService _mediaService; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IUserService _userService; + + public UserGroupsController( + IUserService userService, + IContentService contentService, + IEntityService entityService, + IMediaService mediaService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IUmbracoMapper umbracoMapper, + ILocalizedTextService localizedTextService, + IShortStringHelper shortStringHelper, + AppCaches appCaches) { - private readonly IUserService _userService; - private readonly IContentService _contentService; - private readonly IEntityService _entityService; - private readonly IMediaService _mediaService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IUmbracoMapper _umbracoMapper; - private readonly ILocalizedTextService _localizedTextService; - private readonly IShortStringHelper _shortStringHelper; - private readonly AppCaches _appCaches; + _userService = userService ?? throw new ArgumentNullException(nameof(userService)); + _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); + _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); + _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _localizedTextService = + localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _appCaches = appCaches ?? throw new ArgumentNullException(nameof(appCaches)); + } - public UserGroupsController( - IUserService userService, - IContentService contentService, - IEntityService entityService, - IMediaService mediaService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IUmbracoMapper umbracoMapper, - ILocalizedTextService localizedTextService, - IShortStringHelper shortStringHelper, - AppCaches appCaches) + [UserGroupValidate] + public ActionResult PostSaveUserGroup(UserGroupSave userGroupSave) + { + if (userGroupSave == null) { - _userService = userService ?? throw new ArgumentNullException(nameof(userService)); - _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); - _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); - _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _localizedTextService = - localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _appCaches = appCaches ?? throw new ArgumentNullException(nameof(appCaches)); + throw new ArgumentNullException(nameof(userGroupSave)); } - [UserGroupValidate] - public ActionResult PostSaveUserGroup(UserGroupSave userGroupSave) + //authorize that the user has access to save this user group + var authHelper = new UserGroupEditorAuthorizationHelper( + _userService, _contentService, _mediaService, _entityService, _appCaches); + + Attempt isAuthorized = + authHelper.AuthorizeGroupAccess(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, userGroupSave.Alias); + if (isAuthorized == false) { - if (userGroupSave == null) throw new ArgumentNullException(nameof(userGroupSave)); - - //authorize that the user has access to save this user group - var authHelper = new UserGroupEditorAuthorizationHelper( - _userService, _contentService, _mediaService, _entityService, _appCaches); - - var isAuthorized = authHelper.AuthorizeGroupAccess(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, userGroupSave.Alias); - if (isAuthorized == false) - return Unauthorized(isAuthorized.Result); - - //if sections were added we need to check that the current user has access to that section - isAuthorized = authHelper.AuthorizeSectionChanges( - _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, - userGroupSave.PersistedUserGroup?.AllowedSections, - userGroupSave.Sections); - if (isAuthorized == false) - return Unauthorized(isAuthorized.Result); - - //if start nodes were changed we need to check that the current user has access to them - isAuthorized = authHelper.AuthorizeStartNodeChanges(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, - userGroupSave.PersistedUserGroup?.StartContentId, - userGroupSave.StartContentId, - userGroupSave.PersistedUserGroup?.StartMediaId, - userGroupSave.StartMediaId); - if (isAuthorized == false) - return Unauthorized(isAuthorized.Result); - - //need to ensure current user is in a group if not an admin to avoid a 401 - EnsureNonAdminUserIsInSavedUserGroup(userGroupSave); - - //map the model to the persisted instance - _umbracoMapper.Map(userGroupSave, userGroupSave.PersistedUserGroup); - - if (userGroupSave.PersistedUserGroup is not null) - { - //save the group - _userService.Save(userGroupSave.PersistedUserGroup, userGroupSave.Users?.ToArray()); - } - - //deal with permissions - - //remove ones that have been removed - var existing = _userService.GetPermissions(userGroupSave.PersistedUserGroup, true) - .ToDictionary(x => x.EntityId, x => x); - if (userGroupSave.AssignedPermissions is not null) - { - var toRemove = existing.Keys.Except(userGroupSave.AssignedPermissions.Select(x => x.Key)); - foreach (var contentId in toRemove) - { - _userService.RemoveUserGroupPermissions(userGroupSave.PersistedUserGroup?.Id ?? default, contentId); - } - - //update existing - foreach (var assignedPermission in userGroupSave.AssignedPermissions) - { - _userService.ReplaceUserGroupPermissions( - userGroupSave.PersistedUserGroup?.Id ?? default, - assignedPermission.Value.Select(x => x[0]), - assignedPermission.Key); - } - } - - var display = _umbracoMapper.Map(userGroupSave.PersistedUserGroup); - - display?.AddSuccessNotification(_localizedTextService.Localize("speechBubbles","operationSavedHeader"), _localizedTextService.Localize("speechBubbles","editUserGroupSaved")); - return display; + return Unauthorized(isAuthorized.Result); } - private void EnsureNonAdminUserIsInSavedUserGroup(UserGroupSave userGroupSave) + //if sections were added we need to check that the current user has access to that section + isAuthorized = authHelper.AuthorizeSectionChanges( + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, + userGroupSave.PersistedUserGroup?.AllowedSections, + userGroupSave.Sections); + if (isAuthorized == false) { - if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false) - { - return; - } - - var userIds = userGroupSave.Users?.ToList(); - if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser is null || - userIds is null || - userIds.Contains(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id)) - { - return; - } - - userIds.Add(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id); - userGroupSave.Users = userIds; + return Unauthorized(isAuthorized.Result); } - /// - /// Returns the scaffold for creating a new user group - /// - /// - public UserGroupDisplay? GetEmptyUserGroup() + //if start nodes were changed we need to check that the current user has access to them + isAuthorized = authHelper.AuthorizeStartNodeChanges( + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, + userGroupSave.PersistedUserGroup?.StartContentId, + userGroupSave.StartContentId, + userGroupSave.PersistedUserGroup?.StartMediaId, + userGroupSave.StartMediaId); + if (isAuthorized == false) { - return _umbracoMapper.Map(new UserGroup(_shortStringHelper)); + return Unauthorized(isAuthorized.Result); } - /// - /// Returns all user groups - /// - /// - public IEnumerable GetUserGroups(bool onlyCurrentUserGroups = true) + //need to ensure current user is in a group if not an admin to avoid a 401 + EnsureNonAdminUserIsInSavedUserGroup(userGroupSave); + + //map the model to the persisted instance + _umbracoMapper.Map(userGroupSave, userGroupSave.PersistedUserGroup); + + if (userGroupSave.PersistedUserGroup is not null) { - var allGroups = _umbracoMapper.MapEnumerable(_userService.GetAllUserGroups()) - .ToList(); + //save the group + _userService.Save(userGroupSave.PersistedUserGroup, userGroupSave.Users?.ToArray()); + } - var isAdmin = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false; - if (isAdmin) return allGroups; + //deal with permissions - if (onlyCurrentUserGroups == false) + //remove ones that have been removed + var existing = _userService.GetPermissions(userGroupSave.PersistedUserGroup, true) + .ToDictionary(x => x.EntityId, x => x); + if (userGroupSave.AssignedPermissions is not null) + { + IEnumerable toRemove = existing.Keys.Except(userGroupSave.AssignedPermissions.Select(x => x.Key)); + foreach (var contentId in toRemove) { - //this user is not an admin so in that case we need to exclude all admin users - allGroups.RemoveAt(allGroups.IndexOf(allGroups.Find(basic => basic.Alias == Constants.Security.AdminGroupAlias)!)); - return allGroups; + _userService.RemoveUserGroupPermissions(userGroupSave.PersistedUserGroup?.Id ?? default, contentId); } - //we cannot return user groups that this user does not have access to - var currentUserGroups = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Groups.Select(x => x.Alias).ToArray(); - return allGroups.WhereNotNull().Where(x => currentUserGroups?.Contains(x.Alias) ?? false).ToArray(); - } - - /// - /// Return a user group - /// - /// - [Authorize(Policy = AuthorizationPolicies.UserBelongsToUserGroupInRequest)] - public ActionResult GetUserGroup(int id) - { - var found = _userService.GetUserGroupById(id); - if (found == null) - return NotFound(); - - var display = _umbracoMapper.Map(found); - - return display; - } - - [HttpPost] - [HttpDelete] - [Authorize(Policy = AuthorizationPolicies.UserBelongsToUserGroupInRequest)] - public IActionResult PostDeleteUserGroups([FromQuery] int[] userGroupIds) - { - var userGroups = _userService.GetAllUserGroups(userGroupIds) - //never delete the admin group, sensitive data or translators group - .Where(x => !x.IsSystemUserGroup()) - .ToArray(); - foreach (var userGroup in userGroups) + //update existing + foreach (KeyValuePair> assignedPermission in userGroupSave.AssignedPermissions) { - _userService.DeleteUserGroup(userGroup); + _userService.ReplaceUserGroupPermissions( + userGroupSave.PersistedUserGroup?.Id ?? default, + assignedPermission.Value.Select(x => x[0]), + assignedPermission.Key); } - if (userGroups.Length > 1) - { - return Ok(_localizedTextService.Localize("speechBubbles","deleteUserGroupsSuccess", new[] {userGroups.Length.ToString()})); - } - - return Ok(_localizedTextService.Localize("speechBubbles","deleteUserGroupSuccess", new[] {userGroups[0].Name})); } + + UserGroupDisplay? display = _umbracoMapper.Map(userGroupSave.PersistedUserGroup); + + display?.AddSuccessNotification( + _localizedTextService.Localize("speechBubbles", "operationSavedHeader"), + _localizedTextService.Localize("speechBubbles", "editUserGroupSaved")); + return display; + } + + private void EnsureNonAdminUserIsInSavedUserGroup(UserGroupSave userGroupSave) + { + if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false) + { + return; + } + + var userIds = userGroupSave.Users?.ToList(); + if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser is null || + userIds is null || + userIds.Contains(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id)) + { + return; + } + + userIds.Add(_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id); + userGroupSave.Users = userIds; + } + + /// + /// Returns the scaffold for creating a new user group + /// + /// + public UserGroupDisplay? GetEmptyUserGroup() => + _umbracoMapper.Map(new UserGroup(_shortStringHelper)); + + /// + /// Returns all user groups + /// + /// + public IEnumerable GetUserGroups(bool onlyCurrentUserGroups = true) + { + var allGroups = _umbracoMapper.MapEnumerable(_userService.GetAllUserGroups()) + .ToList(); + + var isAdmin = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin() ?? false; + if (isAdmin) + { + return allGroups; + } + + if (onlyCurrentUserGroups == false) + { + //this user is not an admin so in that case we need to exclude all admin users + allGroups.RemoveAt( + allGroups.IndexOf(allGroups.Find(basic => basic.Alias == Constants.Security.AdminGroupAlias)!)); + return allGroups; + } + + //we cannot return user groups that this user does not have access to + var currentUserGroups = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Groups.Select(x => x.Alias) + .ToArray(); + return allGroups.WhereNotNull().Where(x => currentUserGroups?.Contains(x.Alias) ?? false).ToArray(); + } + + /// + /// Return a user group + /// + /// + [Authorize(Policy = AuthorizationPolicies.UserBelongsToUserGroupInRequest)] + public ActionResult GetUserGroup(int id) + { + IUserGroup? found = _userService.GetUserGroupById(id); + if (found == null) + { + return NotFound(); + } + + UserGroupDisplay? display = _umbracoMapper.Map(found); + + return display; + } + + [HttpPost] + [HttpDelete] + [Authorize(Policy = AuthorizationPolicies.UserBelongsToUserGroupInRequest)] + public IActionResult PostDeleteUserGroups([FromQuery] int[] userGroupIds) + { + IUserGroup[] userGroups = _userService.GetAllUserGroups(userGroupIds) + //never delete the admin group, sensitive data or translators group + .Where(x => !x.IsSystemUserGroup()) + .ToArray(); + foreach (IUserGroup userGroup in userGroups) + { + _userService.DeleteUserGroup(userGroup); + } + + if (userGroups.Length > 1) + { + return Ok(_localizedTextService.Localize("speechBubbles", "deleteUserGroupsSuccess", new[] { userGroups.Length.ToString() })); + } + + return Ok(_localizedTextService.Localize("speechBubbles", "deleteUserGroupSuccess", new[] { userGroups[0].Name })); } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index 734f1d6062..24e5a77a23 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -1,16 +1,10 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.Globalization; -using System.IO; -using System.Linq; using System.Net; using System.Runtime.Serialization; using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -30,863 +24,913 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Email; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; -using Umbraco.Cms.Infrastructure; using Umbraco.Cms.Infrastructure.Persistence; -using Umbraco.Cms.Web.BackOffice.ActionResults; -using Umbraco.Cms.Web.BackOffice.Extensions; using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.BackOffice.ModelBinders; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Controllers +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessUsers)] +[PrefixlessBodyModelValidator] +[IsCurrentUserModelFilter] +public class UsersController : BackOfficeNotificationsController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessUsers)] - [PrefixlessBodyModelValidator] - [IsCurrentUserModelFilter] - public class UsersController : BackOfficeNotificationsController + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly ContentSettings _contentSettings; + private readonly IEmailSender _emailSender; + private readonly IBackOfficeExternalLoginProviders _externalLogins; + private readonly GlobalSettings _globalSettings; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly LinkGenerator _linkGenerator; + private readonly ILocalizedTextService _localizedTextService; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + private readonly MediaFileManager _mediaFileManager; + private readonly IPasswordChanger _passwordChanger; + private readonly SecuritySettings _securitySettings; + private readonly IShortStringHelper _shortStringHelper; + private readonly ISqlContext _sqlContext; + private readonly IUmbracoMapper _umbracoMapper; + private readonly UserEditorAuthorizationHelper _userEditorAuthorizationHelper; + private readonly IBackOfficeUserManager _userManager; + private readonly IUserService _userService; + private readonly WebRoutingSettings _webRoutingSettings; + + [ActivatorUtilitiesConstructor] + public UsersController( + MediaFileManager mediaFileManager, + IOptionsSnapshot contentSettings, + IHostingEnvironment hostingEnvironment, + ISqlContext sqlContext, + IImageUrlGenerator imageUrlGenerator, + IOptionsSnapshot securitySettings, + IEmailSender emailSender, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + AppCaches appCaches, + IShortStringHelper shortStringHelper, + IUserService userService, + ILocalizedTextService localizedTextService, + IUmbracoMapper umbracoMapper, + IOptionsSnapshot globalSettings, + IBackOfficeUserManager backOfficeUserManager, + ILoggerFactory loggerFactory, + LinkGenerator linkGenerator, + IBackOfficeExternalLoginProviders externalLogins, + UserEditorAuthorizationHelper userEditorAuthorizationHelper, + IPasswordChanger passwordChanger, + IHttpContextAccessor httpContextAccessor, + IOptions webRoutingSettings) { - private readonly MediaFileManager _mediaFileManager; - private readonly ContentSettings _contentSettings; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly ISqlContext _sqlContext; - private readonly IImageUrlGenerator _imageUrlGenerator; - private readonly SecuritySettings _securitySettings; - private readonly IEmailSender _emailSender; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly AppCaches _appCaches; - private readonly IShortStringHelper _shortStringHelper; - private readonly IUserService _userService; - private readonly ILocalizedTextService _localizedTextService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly GlobalSettings _globalSettings; - private readonly IBackOfficeUserManager _userManager; - private readonly ILoggerFactory _loggerFactory; - private readonly LinkGenerator _linkGenerator; - private readonly IBackOfficeExternalLoginProviders _externalLogins; - private readonly UserEditorAuthorizationHelper _userEditorAuthorizationHelper; - private readonly IPasswordChanger _passwordChanger; - private readonly ILogger _logger; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly WebRoutingSettings _webRoutingSettings; + _mediaFileManager = mediaFileManager; + _contentSettings = contentSettings.Value; + _hostingEnvironment = hostingEnvironment; + _sqlContext = sqlContext; + _imageUrlGenerator = imageUrlGenerator; + _securitySettings = securitySettings.Value; + _emailSender = emailSender; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _appCaches = appCaches; + _shortStringHelper = shortStringHelper; + _userService = userService; + _localizedTextService = localizedTextService; + _umbracoMapper = umbracoMapper; + _globalSettings = globalSettings.Value; + _userManager = backOfficeUserManager; + _loggerFactory = loggerFactory; + _linkGenerator = linkGenerator; + _externalLogins = externalLogins; + _userEditorAuthorizationHelper = userEditorAuthorizationHelper; + _passwordChanger = passwordChanger; + _logger = _loggerFactory.CreateLogger(); + _httpContextAccessor = httpContextAccessor; + _webRoutingSettings = webRoutingSettings.Value; + } - [ActivatorUtilitiesConstructor] - public UsersController( - MediaFileManager mediaFileManager, - IOptionsSnapshot contentSettings, - IHostingEnvironment hostingEnvironment, - ISqlContext sqlContext, - IImageUrlGenerator imageUrlGenerator, - IOptionsSnapshot securitySettings, - IEmailSender emailSender, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - AppCaches appCaches, - IShortStringHelper shortStringHelper, - IUserService userService, - ILocalizedTextService localizedTextService, - IUmbracoMapper umbracoMapper, - IOptionsSnapshot globalSettings, - IBackOfficeUserManager backOfficeUserManager, - ILoggerFactory loggerFactory, - LinkGenerator linkGenerator, - IBackOfficeExternalLoginProviders externalLogins, - UserEditorAuthorizationHelper userEditorAuthorizationHelper, - IPasswordChanger passwordChanger, - IHttpContextAccessor httpContextAccessor, - IOptions webRoutingSettings) + /// + /// Returns a list of the sizes of gravatar URLs for the user or null if the gravatar server cannot be reached + /// + /// + public ActionResult GetCurrentUserAvatarUrls() + { + var urls = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetUserAvatarUrls( + _appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator); + if (urls == null) { - _mediaFileManager = mediaFileManager; - _contentSettings = contentSettings.Value; - _hostingEnvironment = hostingEnvironment; - _sqlContext = sqlContext; - _imageUrlGenerator = imageUrlGenerator; - _securitySettings = securitySettings.Value; - _emailSender = emailSender; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _appCaches = appCaches; - _shortStringHelper = shortStringHelper; - _userService = userService; - _localizedTextService = localizedTextService; - _umbracoMapper = umbracoMapper; - _globalSettings = globalSettings.Value; - _userManager = backOfficeUserManager; - _loggerFactory = loggerFactory; - _linkGenerator = linkGenerator; - _externalLogins = externalLogins; - _userEditorAuthorizationHelper = userEditorAuthorizationHelper; - _passwordChanger = passwordChanger; - _logger = _loggerFactory.CreateLogger(); - _httpContextAccessor = httpContextAccessor; - _webRoutingSettings = webRoutingSettings.Value; + return ValidationProblem("Could not access Gravatar endpoint"); } - /// - /// Returns a list of the sizes of gravatar URLs for the user or null if the gravatar server cannot be reached - /// - /// - public ActionResult GetCurrentUserAvatarUrls() - { - var urls = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator); - if (urls == null) - return ValidationProblem("Could not access Gravatar endpoint"); + return urls; + } - return urls; + [AppendUserModifiedHeader("id")] + [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] + public IActionResult PostSetAvatar(int id, IList file) => PostSetAvatarInternal(file, _userService, + _appCaches.RuntimeCache, _mediaFileManager, _shortStringHelper, _contentSettings, _hostingEnvironment, + _imageUrlGenerator, id); + + internal static IActionResult PostSetAvatarInternal(IList files, IUserService userService, + IAppCache cache, MediaFileManager mediaFileManager, IShortStringHelper shortStringHelper, + ContentSettings contentSettings, IHostingEnvironment hostingEnvironment, IImageUrlGenerator imageUrlGenerator, + int id) + { + if (files is null) + { + return new UnsupportedMediaTypeResult(); } - [AppendUserModifiedHeader("id")] - [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public IActionResult PostSetAvatar(int id, IList file) + var root = hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); + //ensure it exists + Directory.CreateDirectory(root); + + //must have a file + if (files.Count == 0) { - return PostSetAvatarInternal(file, _userService, _appCaches.RuntimeCache, _mediaFileManager, _shortStringHelper, _contentSettings, _hostingEnvironment, _imageUrlGenerator, id); + return new NotFoundResult(); } - internal static IActionResult PostSetAvatarInternal(IList files, IUserService userService, IAppCache cache, MediaFileManager mediaFileManager, IShortStringHelper shortStringHelper, ContentSettings contentSettings, IHostingEnvironment hostingEnvironment, IImageUrlGenerator imageUrlGenerator, int id) + IUser? user = userService.GetUserById(id); + if (user == null) { - if (files is null) - { - return new UnsupportedMediaTypeResult(); - } - - var root = hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); - //ensure it exists - Directory.CreateDirectory(root); - - //must have a file - if (files.Count == 0) - { - return new NotFoundResult(); - } - - var user = userService.GetUserById(id); - if (user == null) - return new NotFoundResult(); - - if (files.Count > 1) - return new ValidationErrorResult("The request was not formatted correctly, only one file can be attached to the request"); - - //get the file info - var file = files.First(); - var fileName = file.FileName.Trim(new[] { '\"' }).TrimEnd(); - var safeFileName = fileName.ToSafeFileName(shortStringHelper); - var ext = safeFileName.Substring(safeFileName.LastIndexOf('.') + 1).ToLower(); - - if (contentSettings.DisallowedUploadFiles.Contains(ext) == false) - { - //generate a path of known data, we don't want this path to be guessable - user.Avatar = "UserAvatars/" + (user.Id + safeFileName).GenerateHash() + "." + ext; - - using (var fs = file.OpenReadStream()) - { - mediaFileManager.FileSystem.AddFile(user.Avatar, fs, true); - } - - userService.Save(user); - } - - return new OkObjectResult(user.GetUserAvatarUrls(cache, mediaFileManager, imageUrlGenerator)); + return new NotFoundResult(); } - [AppendUserModifiedHeader("id")] - [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public ActionResult PostClearAvatar(int id) + if (files.Count > 1) { - var found = _userService.GetUserById(id); - if (found == null) - return NotFound(); - - var filePath = found.Avatar; - - //if the filePath is already null it will mean that the user doesn't have a custom avatar and their gravatar is currently - //being used (if they have one). This means they want to remove their gravatar too which we can do by setting a special value - //for the avatar. - if (filePath.IsNullOrWhiteSpace() == false) - { - found.Avatar = null; - } - else - { - //set a special value to indicate to not have any avatar - found.Avatar = "none"; - } - - _userService.Save(found); - - if (filePath.IsNullOrWhiteSpace() == false) - { - if (_mediaFileManager.FileSystem.FileExists(filePath!)) - _mediaFileManager.FileSystem.DeleteFile(filePath!); - } - - return found.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator); + return new ValidationErrorResult( + "The request was not formatted correctly, only one file can be attached to the request"); } - /// - /// Gets a user by Id - /// - /// - /// - [OutgoingEditorModelEvent] - [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public ActionResult GetById(int id) + //get the file info + IFormFile file = files.First(); + var fileName = file.FileName.Trim(new[] { '\"' }).TrimEnd(); + var safeFileName = fileName.ToSafeFileName(shortStringHelper); + var ext = safeFileName.Substring(safeFileName.LastIndexOf('.') + 1).ToLower(); + + if (contentSettings.DisallowedUploadFiles.Contains(ext) == false) { - var user = _userService.GetUserById(id); - if (user == null) + //generate a path of known data, we don't want this path to be guessable + user.Avatar = "UserAvatars/" + (user.Id + safeFileName).GenerateHash() + "." + ext; + + using (Stream fs = file.OpenReadStream()) { - return NotFound(); + mediaFileManager.FileSystem.AddFile(user.Avatar, fs, true); } - var result = _umbracoMapper.Map(user); - return result; + + userService.Save(user); } - /// - /// Get users by integer ids - /// - /// - /// - [OutgoingEditorModelEvent] - [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public ActionResult> GetByIds([FromJsonPath]int[] ids) + return new OkObjectResult(user.GetUserAvatarUrls(cache, mediaFileManager, imageUrlGenerator)); + } + + [AppendUserModifiedHeader("id")] + [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] + public ActionResult PostClearAvatar(int id) + { + IUser? found = _userService.GetUserById(id); + if (found == null) { - if (ids == null) - { - return NotFound(); - } - - if (ids.Length == 0) - return Enumerable.Empty().ToList(); - - var users = _userService.GetUsersById(ids); - if (users == null) - { - return NotFound(); - } - - var result = _umbracoMapper.MapEnumerable(users); - return result; + return NotFound(); } - /// - /// Returns a paged users collection - /// - /// - /// - /// - /// - /// - /// - /// - /// - public PagedUserResult GetPagedUsers( - int pageNumber = 1, - int pageSize = 10, - string orderBy = "username", - Direction orderDirection = Direction.Ascending, - [FromQuery]string[]? userGroups = null, - [FromQuery]UserState[]? userStates = null, - string filter = "") + var filePath = found.Avatar; + + //if the filePath is already null it will mean that the user doesn't have a custom avatar and their gravatar is currently + //being used (if they have one). This means they want to remove their gravatar too which we can do by setting a special value + //for the avatar. + if (filePath.IsNullOrWhiteSpace() == false) { - //following the same principle we had in previous versions, we would only show admins to admins, see - // https://github.com/umbraco/Umbraco-CMS/blob/dev-v7/src/Umbraco.Web/umbraco.presentation/umbraco/Trees/loadUsers.cs#L91 - // so to do that here, we'll need to check if this current user is an admin and if not we should exclude all user who are - // also admins - - var hideDisabledUsers = _securitySettings.HideDisabledUsersInBackOffice; - var excludeUserGroups = new string[0]; - var isAdmin = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin(); - if (isAdmin == false) - { - //this user is not an admin so in that case we need to exclude all admin users - excludeUserGroups = new[] {Constants.Security.AdminGroupAlias}; - } - - var filterQuery = _sqlContext.Query(); - - if (!_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsSuper() ?? false) - { - // only super can see super - but don't use IsSuper, cannot be mapped to SQL - //filterQuery.Where(x => !x.IsSuper()); - filterQuery.Where(x => x.Id != Constants.Security.SuperUserId); - } - - if (filter.IsNullOrWhiteSpace() == false) - { - filterQuery.Where(x => x.Name!.Contains(filter) || x.Username.Contains(filter)); - } - - if (hideDisabledUsers) - { - if (userStates == null || userStates.Any() == false) - { - userStates = new[] { UserState.Active, UserState.Invited, UserState.LockedOut, UserState.Inactive }; - } - } - - long pageIndex = pageNumber - 1; - long total; - var result = _userService.GetAll(pageIndex, pageSize, out total, orderBy, orderDirection, userStates, userGroups, excludeUserGroups, filterQuery); - - var paged = new PagedUserResult(total, pageNumber, pageSize) - { - Items = _umbracoMapper.MapEnumerable(result).WhereNotNull(), - UserStates = _userService.GetUserStates() - }; - - return paged; + found.Avatar = null; + } + else + { + //set a special value to indicate to not have any avatar + found.Avatar = "none"; } - /// - /// Creates a new user - /// - /// - /// - public async Task> PostCreateUser(UserInvite userSave) + _userService.Save(found); + + if (filePath.IsNullOrWhiteSpace() == false) { - if (userSave == null) throw new ArgumentNullException("userSave"); - - if (ModelState.IsValid == false) + if (_mediaFileManager.FileSystem.FileExists(filePath!)) { - return ValidationProblem(ModelState); + _mediaFileManager.FileSystem.DeleteFile(filePath!); } + } - if (_securitySettings.UsernameIsEmail) - { - //ensure they are the same if we're using it - userSave.Username = userSave.Email; - } - else - { - //first validate the username if were showing it - CheckUniqueUsername(userSave.Username, null); - } - CheckUniqueEmail(userSave.Email, null); + return found.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator); + } - if (ModelState.IsValid == false) - { - return ValidationProblem(ModelState); - } + /// + /// Gets a user by Id + /// + /// + /// + [OutgoingEditorModelEvent] + [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] + public ActionResult GetById(int id) + { + IUser? user = _userService.GetUserById(id); + if (user == null) + { + return NotFound(); + } - //Perform authorization here to see if the current user can actually save this user with the info being requested - var canSaveUser = _userEditorAuthorizationHelper.IsAuthorized(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, null, null, null, userSave.UserGroups); - if (canSaveUser == false) - { - return Unauthorized(canSaveUser.Result); - } + UserDisplay? result = _umbracoMapper.Map(user); + return result; + } - //we want to create the user with the UserManager, this ensures the 'empty' (special) password - //format is applied without us having to duplicate that logic - var identityUser = BackOfficeIdentityUser.CreateNew(_globalSettings, userSave.Username, userSave.Email, _globalSettings.DefaultUILanguage); + /// + /// Get users by integer ids + /// + /// + /// + [OutgoingEditorModelEvent] + [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] + public ActionResult> GetByIds([FromJsonPath] int[] ids) + { + if (ids == null) + { + return NotFound(); + } + + if (ids.Length == 0) + { + return Enumerable.Empty().ToList(); + } + + IEnumerable? users = _userService.GetUsersById(ids); + if (users == null) + { + return NotFound(); + } + + List result = _umbracoMapper.MapEnumerable(users); + return result; + } + + /// + /// Returns a paged users collection + /// + /// + /// + /// + /// + /// + /// + /// + /// + public PagedUserResult GetPagedUsers( + int pageNumber = 1, + int pageSize = 10, + string orderBy = "username", + Direction orderDirection = Direction.Ascending, + [FromQuery] string[]? userGroups = null, + [FromQuery] UserState[]? userStates = null, + string filter = "") + { + //following the same principle we had in previous versions, we would only show admins to admins, see + // https://github.com/umbraco/Umbraco-CMS/blob/dev-v7/src/Umbraco.Web/umbraco.presentation/umbraco/Trees/loadUsers.cs#L91 + // so to do that here, we'll need to check if this current user is an admin and if not we should exclude all user who are + // also admins + + var hideDisabledUsers = _securitySettings.HideDisabledUsersInBackOffice; + var excludeUserGroups = new string[0]; + var isAdmin = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin(); + if (isAdmin == false) + { + //this user is not an admin so in that case we need to exclude all admin users + excludeUserGroups = new[] { Constants.Security.AdminGroupAlias }; + } + + IQuery filterQuery = _sqlContext.Query(); + + if (!_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsSuper() ?? false) + { + // only super can see super - but don't use IsSuper, cannot be mapped to SQL + //filterQuery.Where(x => !x.IsSuper()); + filterQuery.Where(x => x.Id != Constants.Security.SuperUserId); + } + + if (filter.IsNullOrWhiteSpace() == false) + { + filterQuery.Where(x => x.Name!.Contains(filter) || x.Username.Contains(filter)); + } + + if (hideDisabledUsers) + { + if (userStates == null || userStates.Any() == false) + { + userStates = new[] { UserState.Active, UserState.Invited, UserState.LockedOut, UserState.Inactive }; + } + } + + long pageIndex = pageNumber - 1; + IEnumerable result = _userService.GetAll(pageIndex, pageSize, out long total, orderBy, orderDirection, + userStates, userGroups, excludeUserGroups, filterQuery); + + var paged = new PagedUserResult(total, pageNumber, pageSize) + { + Items = _umbracoMapper.MapEnumerable(result).WhereNotNull(), + UserStates = _userService.GetUserStates() + }; + + return paged; + } + + /// + /// Creates a new user + /// + /// + /// + public async Task> PostCreateUser(UserInvite userSave) + { + if (userSave == null) + { + throw new ArgumentNullException("userSave"); + } + + if (ModelState.IsValid == false) + { + return ValidationProblem(ModelState); + } + + if (_securitySettings.UsernameIsEmail) + { + //ensure they are the same if we're using it + userSave.Username = userSave.Email; + } + else + { + //first validate the username if were showing it + CheckUniqueUsername(userSave.Username, null); + } + + CheckUniqueEmail(userSave.Email, null); + + if (ModelState.IsValid == false) + { + return ValidationProblem(ModelState); + } + + //Perform authorization here to see if the current user can actually save this user with the info being requested + Attempt canSaveUser = _userEditorAuthorizationHelper.IsAuthorized( + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, null, null, null, userSave.UserGroups); + if (canSaveUser == false) + { + return Unauthorized(canSaveUser.Result); + } + + //we want to create the user with the UserManager, this ensures the 'empty' (special) password + //format is applied without us having to duplicate that logic + var identityUser = BackOfficeIdentityUser.CreateNew(_globalSettings, userSave.Username, userSave.Email, + _globalSettings.DefaultUILanguage); + identityUser.Name = userSave.Name; + + IdentityResult created = await _userManager.CreateAsync(identityUser); + if (created.Succeeded == false) + { + return ValidationProblem(created.Errors.ToErrorMessage()); + } + + string resetPassword; + var password = _userManager.GeneratePassword(); + + IdentityResult result = await _userManager.AddPasswordAsync(identityUser, password); + if (result.Succeeded == false) + { + return ValidationProblem(created.Errors.ToErrorMessage()); + } + + resetPassword = password; + + //now re-look the user back up which will now exist + IUser? user = _userService.GetByEmail(userSave.Email); + + //map the save info over onto the user + user = _umbracoMapper.Map(userSave, user); + + if (user is not null) + { + // Since the back office user is creating this user, they will be set to approved + user.IsApproved = true; + + _userService.Save(user); + } + + UserDisplay? display = _umbracoMapper.Map(user); + + if (display is not null) + { + display.ResetPasswordValue = resetPassword; + } + + return display; + } + + /// + /// Invites a user + /// + /// + /// + /// + /// This will email the user an invite and generate a token that will be validated in the email + /// + public async Task> PostInviteUser(UserInvite userSave) + { + if (userSave == null) + { + throw new ArgumentNullException(nameof(userSave)); + } + + if (userSave.Message.IsNullOrWhiteSpace()) + { + ModelState.AddModelError("Message", "Message cannot be empty"); + } + + if (_securitySettings.UsernameIsEmail) + { + // ensure it's the same + userSave.Username = userSave.Email; + } + else + { + // first validate the username if we're showing it + ActionResult userResult = CheckUniqueUsername(userSave.Username, + u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue); + if (userResult.Result is not null) + { + return userResult.Result; + } + } + + IUser? user = CheckUniqueEmail(userSave.Email, + u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue); + + if (ModelState.IsValid == false) + { + return ValidationProblem(ModelState); + } + + if (!_emailSender.CanSendRequiredEmail()) + { + return ValidationProblem("No Email server is configured"); + } + + // Perform authorization here to see if the current user can actually save this user with the info being requested + Attempt canSaveUser = _userEditorAuthorizationHelper.IsAuthorized( + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, user, null, null, userSave.UserGroups); + if (canSaveUser == false) + { + return ValidationProblem(canSaveUser.Result, StatusCodes.Status401Unauthorized); + } + + if (user == null) + { + // we want to create the user with the UserManager, this ensures the 'empty' (special) password + // format is applied without us having to duplicate that logic + var identityUser = BackOfficeIdentityUser.CreateNew(_globalSettings, userSave.Username, userSave.Email, + _globalSettings.DefaultUILanguage); identityUser.Name = userSave.Name; - var created = await _userManager.CreateAsync(identityUser); + IdentityResult created = await _userManager.CreateAsync(identityUser); if (created.Succeeded == false) { return ValidationProblem(created.Errors.ToErrorMessage()); } - string resetPassword; - var password = _userManager.GeneratePassword(); - - var result = await _userManager.AddPasswordAsync(identityUser, password); - if (result.Succeeded == false) - { - return ValidationProblem(created.Errors.ToErrorMessage()); - } - - resetPassword = password; - - //now re-look the user back up which will now exist - var user = _userService.GetByEmail(userSave.Email); - - //map the save info over onto the user - user = _umbracoMapper.Map(userSave, user); - - if (user is not null) - { - // Since the back office user is creating this user, they will be set to approved - user.IsApproved = true; - - _userService.Save(user); - } - - var display = _umbracoMapper.Map(user); - - if (display is not null) - { - display.ResetPasswordValue = resetPassword; - } - - return display; + // now re-look the user back up + user = _userService.GetByEmail(userSave.Email); } - /// - /// Invites a user - /// - /// - /// - /// - /// This will email the user an invite and generate a token that will be validated in the email - /// - public async Task> PostInviteUser(UserInvite userSave) + // map the save info over onto the user + user = _umbracoMapper.Map(userSave, user); + + if (user is not null) { - if (userSave == null) - { - throw new ArgumentNullException(nameof(userSave)); - } - - if (userSave.Message.IsNullOrWhiteSpace()) - { - ModelState.AddModelError("Message", "Message cannot be empty"); - } - - if (_securitySettings.UsernameIsEmail) - { - // ensure it's the same - userSave.Username = userSave.Email; - } - else - { - // first validate the username if we're showing it - ActionResult userResult = CheckUniqueUsername(userSave.Username, u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue); - if (userResult.Result is not null) - { - return userResult.Result; - } - } - - IUser? user = CheckUniqueEmail(userSave.Email, u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue); - - if (ModelState.IsValid == false) - { - return ValidationProblem(ModelState); - } - - if (!_emailSender.CanSendRequiredEmail()) - { - return ValidationProblem("No Email server is configured"); - } - - // Perform authorization here to see if the current user can actually save this user with the info being requested - var canSaveUser = _userEditorAuthorizationHelper.IsAuthorized(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, user, null, null, userSave.UserGroups); - if (canSaveUser == false) - { - return ValidationProblem(canSaveUser.Result, StatusCodes.Status401Unauthorized); - } - - if (user == null) - { - // we want to create the user with the UserManager, this ensures the 'empty' (special) password - // format is applied without us having to duplicate that logic - var identityUser = BackOfficeIdentityUser.CreateNew(_globalSettings, userSave.Username, userSave.Email, _globalSettings.DefaultUILanguage); - identityUser.Name = userSave.Name; - - var created = await _userManager.CreateAsync(identityUser); - if (created.Succeeded == false) - { - return ValidationProblem(created.Errors.ToErrorMessage()); - } - - // now re-look the user back up - user = _userService.GetByEmail(userSave.Email); - } - - // map the save info over onto the user - user = _umbracoMapper.Map(userSave, user); - - if (user is not null) - { - // ensure the invited date is set - user.InvitedDate = DateTime.Now; - - // Save the updated user (which will process the user groups too) - _userService.Save(user); - } - - var display = _umbracoMapper.Map(user); - - // send the email - await SendUserInviteEmailAsync(display, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Name, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Email, user, userSave.Message); - - display?.AddSuccessNotification(_localizedTextService.Localize("speechBubbles","resendInviteHeader"), _localizedTextService.Localize("speechBubbles","resendInviteSuccess", new[] { user?.Name })); - return display; - } - - private IUser? CheckUniqueEmail(string email, Func? extraCheck) - { - var user = _userService.GetByEmail(email); - if (user != null && (extraCheck == null || extraCheck(user))) - { - ModelState.AddModelError("Email", "A user with the email already exists"); - } - return user; - } - - private ActionResult CheckUniqueUsername(string? username, Func? extraCheck) - { - var user = _userService.GetByUsername(username); - if (user != null && (extraCheck == null || extraCheck(user))) - { - ModelState.AddModelError( - _securitySettings.UsernameIsEmail ? "Email" : "Username", - "A user with the username already exists"); - return ValidationProblem(ModelState); - } - - return new ActionResult(user); - } - - private async Task SendUserInviteEmailAsync(UserBasic? userDisplay, string? from, string? fromEmail, IUser? to, string? message) - { - var user = await _userManager.FindByIdAsync(((int?) userDisplay?.Id).ToString()); - var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - - // Use info from SMTP Settings if configured, otherwise set fromEmail as fallback - var senderEmail = !string.IsNullOrEmpty(_globalSettings.Smtp?.From) ? _globalSettings.Smtp.From : fromEmail; - - var inviteToken = string.Format("{0}{1}{2}", - (int?)userDisplay?.Id, - WebUtility.UrlEncode("|"), - token.ToUrlBase64()); - - // Get an mvc helper to get the URL - var action = _linkGenerator.GetPathByAction( - nameof(BackOfficeController.VerifyInvite), - ControllerExtensions.GetControllerName(), - new - { - area = Constants.Web.Mvc.BackOfficeArea, - invite = inviteToken - }); - - // Construct full URL using configured application URL (which will fall back to request) - Uri applicationUri = _httpContextAccessor.GetRequiredHttpContext().Request.GetApplicationUri(_webRoutingSettings); - var inviteUri = new Uri(applicationUri, action); - - var emailSubject = _localizedTextService.Localize("user","inviteEmailCopySubject", - // Ensure the culture of the found user is used for the email! - UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings)); - var emailBody = _localizedTextService.Localize("user","inviteEmailCopyFormat", - // Ensure the culture of the found user is used for the email! - UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings), - new[] { userDisplay?.Name, from, message, inviteUri.ToString(), senderEmail }); - - // This needs to be in the correct mailto format including the name, else - // the name cannot be captured in the email sending notification. - // i.e. "Some Person" - var toMailBoxAddress = new MailboxAddress(to?.Name, to?.Email); - - var mailMessage = new EmailMessage(senderEmail, toMailBoxAddress.ToString(), emailSubject, emailBody, true); - - await _emailSender.SendAsync(mailMessage, Constants.Web.EmailTypes.UserInvite, true); - } - - /// - /// Saves a user - /// - /// - /// - [OutgoingEditorModelEvent] - public ActionResult PostSaveUser(UserSave userSave) - { - if (userSave == null) throw new ArgumentNullException(nameof(userSave)); - - if (ModelState.IsValid == false) - { - return ValidationProblem(ModelState); - } - - var found = _userService.GetUserById(userSave.Id); - if (found == null) - return NotFound(); - - //Perform authorization here to see if the current user can actually save this user with the info being requested - var canSaveUser = _userEditorAuthorizationHelper.IsAuthorized(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, found, userSave.StartContentIds, userSave.StartMediaIds, userSave.UserGroups); - if (canSaveUser == false) - { - return Unauthorized(canSaveUser.Result); - } - - var hasErrors = false; - - // we need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed - var hasDenyLocalLogin = _externalLogins.HasDenyLocalLogin(); - if (hasDenyLocalLogin) - { - userSave.Email = found.Email; // it cannot change, this would only happen if people are mucking around with the request - } - - var existing = _userService.GetByEmail(userSave.Email); - if (existing != null && existing.Id != userSave.Id) - { - ModelState.AddModelError("Email", "A user with the email already exists"); - hasErrors = true; - } - existing = _userService.GetByUsername(userSave.Username); - if (existing != null && existing.Id != userSave.Id) - { - ModelState.AddModelError("Username", "A user with the username already exists"); - hasErrors = true; - } - // going forward we prefer to align usernames with email, so we should cross-check to make sure - // the email or username isn't somehow being used by anyone. - existing = _userService.GetByEmail(userSave.Username); - if (existing != null && existing.Id != userSave.Id) - { - ModelState.AddModelError("Username", "A user using this as their email already exists"); - hasErrors = true; - } - existing = _userService.GetByUsername(userSave.Email); - if (existing != null && existing.Id != userSave.Id) - { - ModelState.AddModelError("Email", "A user using this as their username already exists"); - hasErrors = true; - } - - // if the found user has their email for username, we want to keep this synced when changing the email. - // we have already cross-checked above that the email isn't colliding with anything, so we can safely assign it here. - if (_securitySettings.UsernameIsEmail && found.Username == found.Email && userSave.Username != userSave.Email) - { - userSave.Username = userSave.Email; - } - - if (hasErrors) - return ValidationProblem(ModelState); - - //merge the save data onto the user - var user = _umbracoMapper.Map(userSave, found); + // ensure the invited date is set + user.InvitedDate = DateTime.Now; + // Save the updated user (which will process the user groups too) _userService.Save(user); - - var display = _umbracoMapper.Map(user); - - // determine if the user has changed their own language; - var currentUser = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - var userHasChangedOwnLanguage = - user.Id == currentUser?.Id && currentUser.Language != user.Language; - - var textToLocalise = userHasChangedOwnLanguage ? "operationSavedHeaderReloadUser" : "operationSavedHeader"; - var culture = userHasChangedOwnLanguage - ? CultureInfo.GetCultureInfo(user.Language!) - : Thread.CurrentThread.CurrentUICulture; - display?.AddSuccessNotification(_localizedTextService.Localize("speechBubbles", textToLocalise, culture), _localizedTextService.Localize("speechBubbles","editUserSaved", culture)); - return display; } - /// - /// - /// - /// - /// - public async Task>> PostChangePassword(ChangingPasswordModel changingPasswordModel) + UserDisplay? display = _umbracoMapper.Map(user); + + // send the email + await SendUserInviteEmailAsync(display, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Name, + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Email, user, userSave.Message); + + display?.AddSuccessNotification(_localizedTextService.Localize("speechBubbles", "resendInviteHeader"), + _localizedTextService.Localize("speechBubbles", "resendInviteSuccess", new[] { user?.Name })); + return display; + } + + private IUser? CheckUniqueEmail(string email, Func? extraCheck) + { + IUser? user = _userService.GetByEmail(email); + if (user != null && (extraCheck == null || extraCheck(user))) { - changingPasswordModel = changingPasswordModel ?? throw new ArgumentNullException(nameof(changingPasswordModel)); + ModelState.AddModelError("Email", "A user with the email already exists"); + } - if (ModelState.IsValid == false) - { - return ValidationProblem(ModelState); - } - - IUser? found = _userService.GetUserById(changingPasswordModel.Id); - if (found == null) - { - return NotFound(); - } - - IUser? currentUser = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - - // if it's the current user, the current user cannot reset their own password without providing their old password - if (currentUser?.Username == found.Username && string.IsNullOrEmpty(changingPasswordModel.OldPassword)) - { - return ValidationProblem("Password reset is not allowed without providing old password"); - } - - if ((!currentUser?.IsAdmin() ?? false) && found.IsAdmin()) - { - return ValidationProblem("The current user cannot change the password for the specified user"); - } - - Attempt passwordChangeResult = await _passwordChanger.ChangePasswordWithIdentityAsync(changingPasswordModel, _userManager); - - if (passwordChangeResult.Success) - { - var result = new ModelWithNotifications(passwordChangeResult.Result?.ResetPassword); - result.AddSuccessNotification(_localizedTextService.Localize("general","success"), _localizedTextService.Localize("user","passwordChangedGeneric")); - return result; - } - - if (passwordChangeResult.Result?.ChangeError is not null) - { - foreach (string memberName in passwordChangeResult.Result.ChangeError.MemberNames) - { - ModelState.AddModelError(memberName, passwordChangeResult.Result.ChangeError.ErrorMessage ?? string.Empty); - } - } + return user; + } + private ActionResult CheckUniqueUsername(string? username, Func? extraCheck) + { + IUser? user = _userService.GetByUsername(username); + if (user != null && (extraCheck == null || extraCheck(user))) + { + ModelState.AddModelError( + _securitySettings.UsernameIsEmail ? "Email" : "Username", + "A user with the username already exists"); return ValidationProblem(ModelState); } + return new ActionResult(user); + } - /// - /// Disables the users with the given user ids - /// - /// - [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public IActionResult PostDisableUsers([FromQuery]int[] userIds) + private async Task SendUserInviteEmailAsync(UserBasic? userDisplay, string? from, string? fromEmail, IUser? to, + string? message) + { + BackOfficeIdentityUser user = await _userManager.FindByIdAsync(((int?)userDisplay?.Id).ToString()); + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + + // Use info from SMTP Settings if configured, otherwise set fromEmail as fallback + var senderEmail = !string.IsNullOrEmpty(_globalSettings.Smtp?.From) ? _globalSettings.Smtp.From : fromEmail; + + var inviteToken = string.Format("{0}{1}{2}", + (int?)userDisplay?.Id, + WebUtility.UrlEncode("|"), + token.ToUrlBase64()); + + // Get an mvc helper to get the URL + var action = _linkGenerator.GetPathByAction( + nameof(BackOfficeController.VerifyInvite), + ControllerExtensions.GetControllerName(), + new { area = Constants.Web.Mvc.BackOfficeArea, invite = inviteToken }); + + // Construct full URL using configured application URL (which will fall back to request) + Uri applicationUri = _httpContextAccessor.GetRequiredHttpContext().Request + .GetApplicationUri(_webRoutingSettings); + var inviteUri = new Uri(applicationUri, action); + + var emailSubject = _localizedTextService.Localize("user", "inviteEmailCopySubject", + // Ensure the culture of the found user is used for the email! + UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings)); + var emailBody = _localizedTextService.Localize("user", "inviteEmailCopyFormat", + // Ensure the culture of the found user is used for the email! + UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings), + new[] { userDisplay?.Name, from, message, inviteUri.ToString(), senderEmail }); + + // This needs to be in the correct mailto format including the name, else + // the name cannot be captured in the email sending notification. + // i.e. "Some Person" + var toMailBoxAddress = new MailboxAddress(to?.Name, to?.Email); + + var mailMessage = new EmailMessage(senderEmail, toMailBoxAddress.ToString(), emailSubject, emailBody, true); + + await _emailSender.SendAsync(mailMessage, Constants.Web.EmailTypes.UserInvite, true); + } + + /// + /// Saves a user + /// + /// + /// + [OutgoingEditorModelEvent] + public ActionResult PostSaveUser(UserSave userSave) + { + if (userSave == null) { - var tryGetCurrentUserId = _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId() ?? Attempt.Fail(); - if (tryGetCurrentUserId.Success && userIds.Contains(tryGetCurrentUserId.Result)) - { - return ValidationProblem("The current user cannot disable itself"); - } - - var users = _userService.GetUsersById(userIds).ToArray(); - foreach (var u in users) - { - u.IsApproved = false; - u.InvitedDate = null; - } - _userService.Save(users); - - if (users.Length > 1) - { - return Ok(_localizedTextService.Localize("speechBubbles","disableUsersSuccess", new[] {userIds.Length.ToString()})); - } - - return Ok(_localizedTextService.Localize("speechBubbles","disableUserSuccess", new[] { users[0].Name })); + throw new ArgumentNullException(nameof(userSave)); } - /// - /// Enables the users with the given user ids - /// - /// - [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public IActionResult PostEnableUsers([FromQuery]int[] userIds) + if (ModelState.IsValid == false) { - var users = _userService.GetUsersById(userIds).ToArray(); - foreach (var u in users) - { - u.IsApproved = true; - } - _userService.Save(users); + return ValidationProblem(ModelState); + } - if (users.Length > 1) - { - return Ok( - _localizedTextService.Localize("speechBubbles","enableUsersSuccess", new[] { userIds.Length.ToString() })); - } + IUser? found = _userService.GetUserById(userSave.Id); + if (found == null) + { + return NotFound(); + } + //Perform authorization here to see if the current user can actually save this user with the info being requested + Attempt canSaveUser = _userEditorAuthorizationHelper.IsAuthorized( + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, found, userSave.StartContentIds, + userSave.StartMediaIds, userSave.UserGroups); + if (canSaveUser == false) + { + return Unauthorized(canSaveUser.Result); + } + + var hasErrors = false; + + // we need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed + var hasDenyLocalLogin = _externalLogins.HasDenyLocalLogin(); + if (hasDenyLocalLogin) + { + userSave.Email = + found.Email; // it cannot change, this would only happen if people are mucking around with the request + } + + IUser? existing = _userService.GetByEmail(userSave.Email); + if (existing != null && existing.Id != userSave.Id) + { + ModelState.AddModelError("Email", "A user with the email already exists"); + hasErrors = true; + } + + existing = _userService.GetByUsername(userSave.Username); + if (existing != null && existing.Id != userSave.Id) + { + ModelState.AddModelError("Username", "A user with the username already exists"); + hasErrors = true; + } + + // going forward we prefer to align usernames with email, so we should cross-check to make sure + // the email or username isn't somehow being used by anyone. + existing = _userService.GetByEmail(userSave.Username); + if (existing != null && existing.Id != userSave.Id) + { + ModelState.AddModelError("Username", "A user using this as their email already exists"); + hasErrors = true; + } + + existing = _userService.GetByUsername(userSave.Email); + if (existing != null && existing.Id != userSave.Id) + { + ModelState.AddModelError("Email", "A user using this as their username already exists"); + hasErrors = true; + } + + // if the found user has their email for username, we want to keep this synced when changing the email. + // we have already cross-checked above that the email isn't colliding with anything, so we can safely assign it here. + if (_securitySettings.UsernameIsEmail && found.Username == found.Email && userSave.Username != userSave.Email) + { + userSave.Username = userSave.Email; + } + + if (hasErrors) + { + return ValidationProblem(ModelState); + } + + //merge the save data onto the user + IUser user = _umbracoMapper.Map(userSave, found); + + _userService.Save(user); + + UserDisplay? display = _umbracoMapper.Map(user); + + // determine if the user has changed their own language; + IUser? currentUser = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + var userHasChangedOwnLanguage = + user.Id == currentUser?.Id && currentUser.Language != user.Language; + + var textToLocalise = userHasChangedOwnLanguage ? "operationSavedHeaderReloadUser" : "operationSavedHeader"; + CultureInfo culture = userHasChangedOwnLanguage + ? CultureInfo.GetCultureInfo(user.Language!) + : Thread.CurrentThread.CurrentUICulture; + display?.AddSuccessNotification(_localizedTextService.Localize("speechBubbles", textToLocalise, culture), + _localizedTextService.Localize("speechBubbles", "editUserSaved", culture)); + return display; + } + + /// + /// + /// + /// + public async Task>> PostChangePassword( + ChangingPasswordModel changingPasswordModel) + { + changingPasswordModel = changingPasswordModel ?? throw new ArgumentNullException(nameof(changingPasswordModel)); + + if (ModelState.IsValid == false) + { + return ValidationProblem(ModelState); + } + + IUser? found = _userService.GetUserById(changingPasswordModel.Id); + if (found == null) + { + return NotFound(); + } + + IUser? currentUser = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + + // if it's the current user, the current user cannot reset their own password without providing their old password + if (currentUser?.Username == found.Username && string.IsNullOrEmpty(changingPasswordModel.OldPassword)) + { + return ValidationProblem("Password reset is not allowed without providing old password"); + } + + if ((!currentUser?.IsAdmin() ?? false) && found.IsAdmin()) + { + return ValidationProblem("The current user cannot change the password for the specified user"); + } + + Attempt passwordChangeResult = + await _passwordChanger.ChangePasswordWithIdentityAsync(changingPasswordModel, _userManager); + + if (passwordChangeResult.Success) + { + var result = new ModelWithNotifications(passwordChangeResult.Result?.ResetPassword); + result.AddSuccessNotification(_localizedTextService.Localize("general", "success"), + _localizedTextService.Localize("user", "passwordChangedGeneric")); + return result; + } + + if (passwordChangeResult.Result?.ChangeError is not null) + { + foreach (var memberName in passwordChangeResult.Result.ChangeError.MemberNames) + { + ModelState.AddModelError(memberName, + passwordChangeResult.Result.ChangeError.ErrorMessage ?? string.Empty); + } + } + + return ValidationProblem(ModelState); + } + + + /// + /// Disables the users with the given user ids + /// + /// + [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] + public IActionResult PostDisableUsers([FromQuery] int[] userIds) + { + Attempt tryGetCurrentUserId = + _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId() ?? Attempt.Fail(); + if (tryGetCurrentUserId.Success && userIds.Contains(tryGetCurrentUserId.Result)) + { + return ValidationProblem("The current user cannot disable itself"); + } + + IUser[] users = _userService.GetUsersById(userIds).ToArray(); + foreach (IUser u in users) + { + u.IsApproved = false; + u.InvitedDate = null; + } + + _userService.Save(users); + + if (users.Length > 1) + { + return Ok(_localizedTextService.Localize("speechBubbles", "disableUsersSuccess", + new[] { userIds.Length.ToString() })); + } + + return Ok(_localizedTextService.Localize("speechBubbles", "disableUserSuccess", new[] { users[0].Name })); + } + + /// + /// Enables the users with the given user ids + /// + /// + [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] + public IActionResult PostEnableUsers([FromQuery] int[] userIds) + { + IUser[] users = _userService.GetUsersById(userIds).ToArray(); + foreach (IUser u in users) + { + u.IsApproved = true; + } + + _userService.Save(users); + + if (users.Length > 1) + { return Ok( - _localizedTextService.Localize("speechBubbles","enableUserSuccess", new[] { users[0].Name })); + _localizedTextService.Localize("speechBubbles", "enableUsersSuccess", + new[] { userIds.Length.ToString() })); } - /// - /// Unlocks the users with the given user ids - /// - /// - [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public async Task PostUnlockUsers([FromQuery]int[] userIds) + return Ok( + _localizedTextService.Localize("speechBubbles", "enableUserSuccess", new[] { users[0].Name })); + } + + /// + /// Unlocks the users with the given user ids + /// + /// + [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] + public async Task PostUnlockUsers([FromQuery] int[] userIds) + { + if (userIds.Length <= 0) { - if (userIds.Length <= 0) return Ok(); - var notFound = new List(); - - foreach (var u in userIds) - { - var user = await _userManager.FindByIdAsync(u.ToString()); - if (user == null) - { - notFound.Add(u); - continue; - } - - var unlockResult = await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.Now.AddMinutes(-1)); - if (unlockResult.Succeeded == false) - { - return ValidationProblem( - $"Could not unlock for user {u} - error {unlockResult.Errors.ToErrorMessage()}"); - } - - if (userIds.Length == 1) - { - return Ok( - _localizedTextService.Localize("speechBubbles","unlockUserSuccess", new[] {user.Name})); - } - } - - return Ok( - _localizedTextService.Localize("speechBubbles","unlockUsersSuccess", new[] {(userIds.Length - notFound.Count).ToString()})); + return Ok(); } - [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public IActionResult PostSetUserGroupsOnUsers([FromQuery]string[] userGroupAliases, [FromQuery]int[] userIds) - { - var users = _userService.GetUsersById(userIds).ToArray(); - var userGroups = _userService.GetUserGroupsByAlias(userGroupAliases).Select(x => x.ToReadOnlyGroup()).ToArray(); - foreach (var u in users) - { - u.ClearGroups(); - foreach (var userGroup in userGroups) - { - u.AddGroup(userGroup); - } - } - _userService.Save(users); - return Ok( - _localizedTextService.Localize("speechBubbles","setUserGroupOnUsersSuccess")); - } + var notFound = new List(); - /// - /// Deletes the non-logged in user provided id - /// - /// User Id - /// - /// Limited to users that haven't logged in to avoid issues with related records constrained - /// with a foreign key on the user Id - /// - [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public IActionResult PostDeleteNonLoggedInUser(int id) + foreach (var u in userIds) { - var user = _userService.GetUserById(id); + BackOfficeIdentityUser? user = await _userManager.FindByIdAsync(u.ToString()); if (user == null) { - return NotFound(); + notFound.Add(u); + continue; } - // Check user hasn't logged in. If they have they may have made content changes which will mean - // the Id is associated with audit trails, versions etc. and can't be removed. - if (user.LastLoginDate is not null && user.LastLoginDate != default(DateTime)) + IdentityResult unlockResult = + await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.Now.AddMinutes(-1)); + if (unlockResult.Succeeded == false) { - return BadRequest(); + return ValidationProblem( + $"Could not unlock for user {u} - error {unlockResult.Errors.ToErrorMessage()}"); } - var userName = user.Name; - _userService.Delete(user, true); - - return Ok( - _localizedTextService.Localize("speechBubbles","deleteUserSuccess", new[] { userName })); + if (userIds.Length == 1) + { + return Ok( + _localizedTextService.Localize("speechBubbles", "unlockUserSuccess", new[] { user.Name })); + } } - public class PagedUserResult : PagedResult + return Ok( + _localizedTextService.Localize("speechBubbles", "unlockUsersSuccess", + new[] { (userIds.Length - notFound.Count).ToString() })); + } + + [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] + public IActionResult PostSetUserGroupsOnUsers([FromQuery] string[] userGroupAliases, [FromQuery] int[] userIds) + { + IUser[] users = _userService.GetUsersById(userIds).ToArray(); + IReadOnlyUserGroup[] userGroups = _userService.GetUserGroupsByAlias(userGroupAliases) + .Select(x => x.ToReadOnlyGroup()).ToArray(); + foreach (IUser u in users) { - public PagedUserResult(long totalItems, long pageNumber, long pageSize) : base(totalItems, pageNumber, pageSize) + u.ClearGroups(); + foreach (IReadOnlyUserGroup userGroup in userGroups) { - UserStates = new Dictionary(); + u.AddGroup(userGroup); } - - /// - /// This is basically facets of UserStates key = state, value = count - /// - [DataMember(Name = "userStates")] - public IDictionary UserStates { get; set; } } + _userService.Save(users); + return Ok( + _localizedTextService.Localize("speechBubbles", "setUserGroupOnUsersSuccess")); + } + + /// + /// Deletes the non-logged in user provided id + /// + /// User Id + /// + /// Limited to users that haven't logged in to avoid issues with related records constrained + /// with a foreign key on the user Id + /// + [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] + public IActionResult PostDeleteNonLoggedInUser(int id) + { + IUser? user = _userService.GetUserById(id); + if (user == null) + { + return NotFound(); + } + + // Check user hasn't logged in. If they have they may have made content changes which will mean + // the Id is associated with audit trails, versions etc. and can't be removed. + if (user.LastLoginDate is not null && user.LastLoginDate != default(DateTime)) + { + return BadRequest(); + } + + var userName = user.Name; + _userService.Delete(user, true); + + return Ok( + _localizedTextService.Localize("speechBubbles", "deleteUserSuccess", new[] { userName })); + } + + public class PagedUserResult : PagedResult + { + public PagedUserResult(long totalItems, long pageNumber, long pageSize) : + base(totalItems, pageNumber, pageSize) => UserStates = new Dictionary(); + + /// + /// This is basically facets of UserStates key = state, value = count + /// + [DataMember(Name = "userStates")] + public IDictionary UserStates { get; set; } } } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs index 0e878aef8b..97722d8305 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs @@ -1,427 +1,432 @@ -using System; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.BackOffice.Authorization; using Umbraco.Cms.Web.BackOffice.Middleware; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Security; -using Umbraco.Cms.Core.Actions; -using Umbraco.Cms.Core.Notifications; -using Umbraco.Cms.Web.BackOffice.Authorization; -using Umbraco.Cms.Web.Common.Middleware; +namespace Umbraco.Extensions; -namespace Umbraco.Extensions +/// +/// Extension methods for for the Umbraco back office +/// +public static partial class UmbracoBuilderExtensions { /// - /// Extension methods for for the Umbraco back office + /// Adds Umbraco back office authentication requirements /// - public static partial class UmbracoBuilderExtensions + public static IUmbracoBuilder AddBackOfficeAuthentication(this IUmbracoBuilder builder) { - /// - /// Adds Umbraco back office authentication requirements - /// - public static IUmbracoBuilder AddBackOfficeAuthentication(this IUmbracoBuilder builder) + builder.Services + + // This just creates a builder, nothing more + .AddAuthentication() + + // Add our custom schemes which are cookie handlers + .AddCookie(Constants.Security.BackOfficeAuthenticationType) + .AddCookie(Constants.Security.BackOfficeExternalAuthenticationType, o => + { + o.Cookie.Name = Constants.Security.BackOfficeExternalAuthenticationType; + o.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }) + + // Although we don't natively support this, we add it anyways so that if end-users implement the required logic + // they don't have to worry about manually adding this scheme or modifying the sign in manager + .AddCookie(Constants.Security.BackOfficeTwoFactorAuthenticationType, o => + { + o.Cookie.Name = Constants.Security.BackOfficeTwoFactorAuthenticationType; + o.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }) + .AddCookie(Constants.Security.BackOfficeTwoFactorRememberMeAuthenticationType, o => + { + o.Cookie.Name = Constants.Security.BackOfficeTwoFactorRememberMeAuthenticationType; + o.ExpireTimeSpan = TimeSpan.FromMinutes(5); + }); + + builder.Services.ConfigureOptions(); + + builder.Services.AddSingleton(); + + builder.Services.AddUnique(); + builder.Services.AddUnique, PasswordChanger>(); + builder.Services.AddUnique, PasswordChanger>(); + + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + + return builder; + } + + /// + /// Adds Umbraco back office authorization policies + /// + public static IUmbracoBuilder AddBackOfficeAuthorizationPolicies(this IUmbracoBuilder builder, + string backOfficeAuthenticationScheme = Constants.Security.BackOfficeAuthenticationType) + { + builder.AddBackOfficeAuthorizationPoliciesInternal(backOfficeAuthenticationScheme); + + builder.Services.AddSingleton(); + + builder.Services.AddAuthorization(options + => options.AddPolicy(AuthorizationPolicies.UmbracoFeatureEnabled, policy + => policy.Requirements.Add(new FeatureAuthorizeRequirement()))); + + return builder; + } + + /// + /// Add authorization handlers and policies + /// + private static void AddBackOfficeAuthorizationPoliciesInternal(this IUmbracoBuilder builder, + string backOfficeAuthenticationScheme = Constants.Security.BackOfficeAuthenticationType) + { + // NOTE: Even though we are registering these handlers globally they will only actually execute their logic for + // any auth defining a matching requirement and scheme. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.AddAuthorization(o => CreatePolicies(o, backOfficeAuthenticationScheme)); + } + + private static void CreatePolicies(AuthorizationOptions options, string backOfficeAuthenticationScheme) + { + options.AddPolicy(AuthorizationPolicies.MediaPermissionByResource, policy => { - builder.Services + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new MediaPermissionsResourceRequirement()); + }); - // This just creates a builder, nothing more - .AddAuthentication() - - // Add our custom schemes which are cookie handlers - .AddCookie(Constants.Security.BackOfficeAuthenticationType) - .AddCookie(Constants.Security.BackOfficeExternalAuthenticationType, o => - { - o.Cookie.Name = Constants.Security.BackOfficeExternalAuthenticationType; - o.ExpireTimeSpan = TimeSpan.FromMinutes(5); - }) - - // Although we don't natively support this, we add it anyways so that if end-users implement the required logic - // they don't have to worry about manually adding this scheme or modifying the sign in manager - .AddCookie(Constants.Security.BackOfficeTwoFactorAuthenticationType, o => - { - o.Cookie.Name = Constants.Security.BackOfficeTwoFactorAuthenticationType; - o.ExpireTimeSpan = TimeSpan.FromMinutes(5); - }) - .AddCookie(Constants.Security.BackOfficeTwoFactorRememberMeAuthenticationType, o => - { - o.Cookie.Name = Constants.Security.BackOfficeTwoFactorRememberMeAuthenticationType; - o.ExpireTimeSpan = TimeSpan.FromMinutes(5); - }); - - builder.Services.ConfigureOptions(); - - builder.Services.AddSingleton(); - - builder.Services.AddUnique(); - builder.Services.AddUnique, PasswordChanger>(); - builder.Services.AddUnique, PasswordChanger>(); - - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - - return builder; - } - - /// - /// Adds Umbraco back office authorization policies - /// - public static IUmbracoBuilder AddBackOfficeAuthorizationPolicies(this IUmbracoBuilder builder, string backOfficeAuthenticationScheme = Constants.Security.BackOfficeAuthenticationType) + options.AddPolicy(AuthorizationPolicies.MediaPermissionPathById, policy => { - builder.AddBackOfficeAuthorizationPoliciesInternal(backOfficeAuthenticationScheme); + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new MediaPermissionsQueryStringRequirement("id")); + }); - builder.Services.AddSingleton(); - - builder.Services.AddAuthorization(options - => options.AddPolicy(AuthorizationPolicies.UmbracoFeatureEnabled, policy - => policy.Requirements.Add(new FeatureAuthorizeRequirement()))); - - return builder; - } - - /// - /// Add authorization handlers and policies - /// - private static void AddBackOfficeAuthorizationPoliciesInternal(this IUmbracoBuilder builder, string backOfficeAuthenticationScheme = Constants.Security.BackOfficeAuthenticationType) + options.AddPolicy(AuthorizationPolicies.ContentPermissionByResource, policy => { - // NOTE: Even though we are registering these handlers globally they will only actually execute their logic for - // any auth defining a matching requirement and scheme. - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new ContentPermissionsResourceRequirement()); + }); - builder.Services.AddAuthorization(o => CreatePolicies(o, backOfficeAuthenticationScheme)); - } - - private static void CreatePolicies(AuthorizationOptions options, string backOfficeAuthenticationScheme) + options.AddPolicy(AuthorizationPolicies.ContentPermissionEmptyRecycleBin, policy => { - options.AddPolicy(AuthorizationPolicies.MediaPermissionByResource, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new MediaPermissionsResourceRequirement()); - }); + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(Constants.System.RecycleBinContent, + ActionDelete.ActionLetter)); + }); - options.AddPolicy(AuthorizationPolicies.MediaPermissionPathById, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new MediaPermissionsQueryStringRequirement("id")); - }); + options.AddPolicy(AuthorizationPolicies.ContentPermissionAdministrationById, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionRights.ActionLetter)); + policy.Requirements.Add( + new ContentPermissionsQueryStringRequirement(ActionRights.ActionLetter, "contentId")); + }); - options.AddPolicy(AuthorizationPolicies.ContentPermissionByResource, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new ContentPermissionsResourceRequirement()); - }); + options.AddPolicy(AuthorizationPolicies.ContentPermissionProtectById, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionProtect.ActionLetter)); + policy.Requirements.Add( + new ContentPermissionsQueryStringRequirement(ActionProtect.ActionLetter, "contentId")); + }); - options.AddPolicy(AuthorizationPolicies.ContentPermissionEmptyRecycleBin, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(Constants.System.RecycleBinContent, ActionDelete.ActionLetter)); - }); + options.AddPolicy(AuthorizationPolicies.ContentPermissionRollbackById, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionRollback.ActionLetter)); + policy.Requirements.Add( + new ContentPermissionsQueryStringRequirement(ActionRollback.ActionLetter, "contentId")); + }); - options.AddPolicy(AuthorizationPolicies.ContentPermissionAdministrationById, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionRights.ActionLetter)); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionRights.ActionLetter, "contentId")); - }); + options.AddPolicy(AuthorizationPolicies.ContentPermissionPublishById, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionPublish.ActionLetter)); + }); - options.AddPolicy(AuthorizationPolicies.ContentPermissionProtectById, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionProtect.ActionLetter)); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionProtect.ActionLetter, "contentId")); - }); + options.AddPolicy(AuthorizationPolicies.ContentPermissionBrowseById, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionBrowse.ActionLetter)); + policy.Requirements.Add( + new ContentPermissionsQueryStringRequirement(ActionBrowse.ActionLetter, "contentId")); + }); - options.AddPolicy(AuthorizationPolicies.ContentPermissionRollbackById, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionRollback.ActionLetter)); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionRollback.ActionLetter, "contentId")); - }); + options.AddPolicy(AuthorizationPolicies.ContentPermissionDeleteById, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionDelete.ActionLetter)); + }); - options.AddPolicy(AuthorizationPolicies.ContentPermissionPublishById, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionPublish.ActionLetter)); - }); + options.AddPolicy(AuthorizationPolicies.BackOfficeAccess, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new BackOfficeRequirement()); + }); - options.AddPolicy(AuthorizationPolicies.ContentPermissionBrowseById, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionBrowse.ActionLetter)); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionBrowse.ActionLetter, "contentId")); - }); + options.AddPolicy(AuthorizationPolicies.BackOfficeAccessWithoutApproval, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new BackOfficeRequirement(false)); + }); - options.AddPolicy(AuthorizationPolicies.ContentPermissionDeleteById, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new ContentPermissionsQueryStringRequirement(ActionDelete.ActionLetter)); - }); + options.AddPolicy(AuthorizationPolicies.AdminUserEditsRequireAdmin, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new AdminUsersRequirement()); + policy.Requirements.Add(new AdminUsersRequirement("userIds")); + }); - options.AddPolicy(AuthorizationPolicies.BackOfficeAccess, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new BackOfficeRequirement()); - }); + options.AddPolicy(AuthorizationPolicies.UserBelongsToUserGroupInRequest, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new UserGroupRequirement()); + policy.Requirements.Add(new UserGroupRequirement("userGroupIds")); + }); - options.AddPolicy(AuthorizationPolicies.BackOfficeAccessWithoutApproval, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new BackOfficeRequirement(false)); - }); + options.AddPolicy(AuthorizationPolicies.DenyLocalLoginIfConfigured, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new DenyLocalLoginRequirement()); + }); - options.AddPolicy(AuthorizationPolicies.AdminUserEditsRequireAdmin, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new AdminUsersRequirement()); - policy.Requirements.Add(new AdminUsersRequirement("userIds")); - }); + options.AddPolicy(AuthorizationPolicies.SectionAccessContent, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement(Constants.Applications.Content)); + }); - options.AddPolicy(AuthorizationPolicies.UserBelongsToUserGroupInRequest, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new UserGroupRequirement()); - policy.Requirements.Add(new UserGroupRequirement("userGroupIds")); - }); + options.AddPolicy(AuthorizationPolicies.SectionAccessContentOrMedia, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add( + new SectionRequirement(Constants.Applications.Content, Constants.Applications.Media)); + }); - options.AddPolicy(AuthorizationPolicies.DenyLocalLoginIfConfigured, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new DenyLocalLoginRequirement()); - }); + options.AddPolicy(AuthorizationPolicies.SectionAccessUsers, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement(Constants.Applications.Users)); + }); - options.AddPolicy(AuthorizationPolicies.SectionAccessContent, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement(Constants.Applications.Content)); - }); + options.AddPolicy(AuthorizationPolicies.SectionAccessForTinyMce, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement( + Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Members)); + }); - options.AddPolicy(AuthorizationPolicies.SectionAccessContentOrMedia, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement(Constants.Applications.Content, Constants.Applications.Media)); - }); + options.AddPolicy(AuthorizationPolicies.SectionAccessMedia, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement(Constants.Applications.Media)); + }); - options.AddPolicy(AuthorizationPolicies.SectionAccessUsers, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement(Constants.Applications.Users)); - }); + options.AddPolicy(AuthorizationPolicies.SectionAccessMembers, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement(Constants.Applications.Members)); + }); - options.AddPolicy(AuthorizationPolicies.SectionAccessForTinyMce, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement( - Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Members)); - }); + options.AddPolicy(AuthorizationPolicies.SectionAccessPackages, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement(Constants.Applications.Packages)); + }); - options.AddPolicy(AuthorizationPolicies.SectionAccessMedia, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement(Constants.Applications.Media)); - }); + options.AddPolicy(AuthorizationPolicies.SectionAccessSettings, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement(Constants.Applications.Settings)); + }); - options.AddPolicy(AuthorizationPolicies.SectionAccessMembers, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement(Constants.Applications.Members)); - }); + // We will not allow the tree to render unless the user has access to any of the sections that the tree gets rendered + // this is not ideal but until we change permissions to be tree based (not section) there's not much else we can do here. + options.AddPolicy(AuthorizationPolicies.SectionAccessForContentTree, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement( + Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Users, + Constants.Applications.Settings, Constants.Applications.Packages, Constants.Applications.Members)); + }); + options.AddPolicy(AuthorizationPolicies.SectionAccessForMediaTree, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement( + Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Users, + Constants.Applications.Settings, Constants.Applications.Packages, Constants.Applications.Members)); + }); + options.AddPolicy(AuthorizationPolicies.SectionAccessForMemberTree, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement( + Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Members)); + }); - options.AddPolicy(AuthorizationPolicies.SectionAccessPackages, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement(Constants.Applications.Packages)); - }); + // Permission is granted to this policy if the user has access to any of these sections: Content, media, settings, developer, members + options.AddPolicy(AuthorizationPolicies.SectionAccessForDataTypeReading, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new SectionRequirement( + Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Members, + Constants.Applications.Settings, Constants.Applications.Packages)); + }); - options.AddPolicy(AuthorizationPolicies.SectionAccessSettings, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement(Constants.Applications.Settings)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessDocuments, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.Content)); + }); - // We will not allow the tree to render unless the user has access to any of the sections that the tree gets rendered - // this is not ideal but until we change permissions to be tree based (not section) there's not much else we can do here. - options.AddPolicy(AuthorizationPolicies.SectionAccessForContentTree, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement( - Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Users, - Constants.Applications.Settings, Constants.Applications.Packages, Constants.Applications.Members)); - }); - options.AddPolicy(AuthorizationPolicies.SectionAccessForMediaTree, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement( - Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Users, - Constants.Applications.Settings, Constants.Applications.Packages, Constants.Applications.Members)); - }); - options.AddPolicy(AuthorizationPolicies.SectionAccessForMemberTree, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement( - Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Members)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessUsers, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.Users)); + }); - // Permission is granted to this policy if the user has access to any of these sections: Content, media, settings, developer, members - options.AddPolicy(AuthorizationPolicies.SectionAccessForDataTypeReading, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new SectionRequirement( - Constants.Applications.Content, Constants.Applications.Media, Constants.Applications.Members, - Constants.Applications.Settings, Constants.Applications.Packages)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessPartialViews, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.PartialViews)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessDocuments, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.Content)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessPartialViewMacros, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.PartialViewMacros)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessUsers, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.Users)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessPackages, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.Packages)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessPartialViews, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.PartialViews)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessLogs, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.LogViewer)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessPartialViewMacros, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.PartialViewMacros)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessDataTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.DataTypes)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessPackages, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.Packages)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessTemplates, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.Templates)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessLogs, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.LogViewer)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessMemberTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.MemberTypes)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessDataTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.DataTypes)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessRelationTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.RelationTypes)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessTemplates, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.Templates)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessDocumentTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.DocumentTypes)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessMemberTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.MemberTypes)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessMemberGroups, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.MemberGroups)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessRelationTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.RelationTypes)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessMediaTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.MediaTypes)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessDocumentTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.DocumentTypes)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessMacros, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.Macros)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessMemberGroups, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.MemberGroups)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessLanguages, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.Languages)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessMediaTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.MediaTypes)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessDictionary, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.Dictionary)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessMacros, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.Macros)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessDictionaryOrTemplates, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.Dictionary, Constants.Trees.Templates)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessLanguages, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.Languages)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.DocumentTypes, Constants.Trees.Content)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessDictionary, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.Dictionary)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessMediaOrMediaTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.MediaTypes, Constants.Trees.Media)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessDictionaryOrTemplates, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.Dictionary, Constants.Trees.Templates)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessMembersOrMemberTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.MemberTypes, Constants.Trees.Members)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.DocumentTypes, Constants.Trees.Content)); - }); + options.AddPolicy(AuthorizationPolicies.TreeAccessAnySchemaTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement(Constants.Trees.DataTypes, Constants.Trees.DocumentTypes, + Constants.Trees.MediaTypes, Constants.Trees.MemberTypes)); + }); - options.AddPolicy(AuthorizationPolicies.TreeAccessMediaOrMediaTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.MediaTypes, Constants.Trees.Media)); - }); - - options.AddPolicy(AuthorizationPolicies.TreeAccessMembersOrMemberTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.MemberTypes, Constants.Trees.Members)); - }); - - options.AddPolicy(AuthorizationPolicies.TreeAccessAnySchemaTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement(Constants.Trees.DataTypes, Constants.Trees.DocumentTypes, Constants.Trees.MediaTypes, Constants.Trees.MemberTypes)); - }); - - options.AddPolicy(AuthorizationPolicies.TreeAccessAnyContentOrTypes, policy => - { - policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); - policy.Requirements.Add(new TreeRequirement( - Constants.Trees.DocumentTypes, Constants.Trees.Content, - Constants.Trees.MediaTypes, Constants.Trees.Media, - Constants.Trees.MemberTypes, Constants.Trees.Members)); - }); - } + options.AddPolicy(AuthorizationPolicies.TreeAccessAnyContentOrTypes, policy => + { + policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); + policy.Requirements.Add(new TreeRequirement( + Constants.Trees.DocumentTypes, Constants.Trees.Content, + Constants.Trees.MediaTypes, Constants.Trees.Media, + Constants.Trees.MemberTypes, Constants.Trees.Members)); + }); } } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index ccd764d85b..6d3ff7edda 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -16,77 +15,77 @@ using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.AspNetCore; using Umbraco.Cms.Web.Common.Security; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +/// +/// Extension methods for for the Umbraco back office +/// +public static partial class UmbracoBuilderExtensions { /// - /// Extension methods for for the Umbraco back office + /// Adds Identity support for Umbraco back office /// - public static partial class UmbracoBuilderExtensions + public static IUmbracoBuilder AddBackOfficeIdentity(this IUmbracoBuilder builder) { - /// - /// Adds Identity support for Umbraco back office - /// - public static IUmbracoBuilder AddBackOfficeIdentity(this IUmbracoBuilder builder) - { - IServiceCollection services = builder.Services; + IServiceCollection services = builder.Services; - services.AddDataProtection(); + services.AddDataProtection(); - builder.BuildUmbracoBackOfficeIdentity() - .AddDefaultTokenProviders() - .AddUserStore, BackOfficeUserStore>(factory => new BackOfficeUserStore( - factory.GetRequiredService(), - factory.GetRequiredService(), - factory.GetRequiredService(), - factory.GetRequiredService(), - factory.GetRequiredService>(), - factory.GetRequiredService(), - factory.GetRequiredService(), - factory.GetRequiredService(), - factory.GetRequiredService() - )) - .AddUserManager() - .AddSignInManager() - .AddClaimsPrincipalFactory() - .AddErrorDescriber(); + builder.BuildUmbracoBackOfficeIdentity() + .AddDefaultTokenProviders() + .AddUserStore, BackOfficeUserStore>(factory => new BackOfficeUserStore( + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService>(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService() + )) + .AddUserManager() + .AddSignInManager() + .AddClaimsPrincipalFactory() + .AddErrorDescriber(); - services.TryAddSingleton(); + services.TryAddSingleton(); - // Configure the options specifically for the UmbracoBackOfficeIdentityOptions instance - services.ConfigureOptions(); - services.ConfigureOptions(); + // Configure the options specifically for the UmbracoBackOfficeIdentityOptions instance + services.ConfigureOptions(); + services.ConfigureOptions(); - return builder; - } + return builder; + } - private static BackOfficeIdentityBuilder BuildUmbracoBackOfficeIdentity(this IUmbracoBuilder builder) - { - IServiceCollection services = builder.Services; + private static BackOfficeIdentityBuilder BuildUmbracoBackOfficeIdentity(this IUmbracoBuilder builder) + { + IServiceCollection services = builder.Services; - services.TryAddScoped(); - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddSingleton(); - return new BackOfficeIdentityBuilder(services); - } + return new BackOfficeIdentityBuilder(services); + } - /// - /// Adds support for external login providers in Umbraco - /// - public static IUmbracoBuilder AddBackOfficeExternalLogins(this IUmbracoBuilder umbracoBuilder, Action builder) - { - builder(new BackOfficeExternalLoginsBuilder(umbracoBuilder.Services)); - return umbracoBuilder; - } + /// + /// Adds support for external login providers in Umbraco + /// + public static IUmbracoBuilder AddBackOfficeExternalLogins(this IUmbracoBuilder umbracoBuilder, + Action builder) + { + builder(new BackOfficeExternalLoginsBuilder(umbracoBuilder.Services)); + 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; - } + 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/UmbracoBuilder.LocalizedText.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs index ff817e2f1c..6b8afa6626 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs @@ -1,113 +1,110 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; - using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; - +using Umbraco.Cms.Core; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +/// +/// Extension methods for for the Umbraco back office +/// +public static partial class UmbracoBuilderExtensions { /// - /// Extension methods for for the Umbraco back office + /// Adds the supplementary localized texxt file sources from the various physical and virtual locations supported. /// - public static partial class UmbracoBuilderExtensions + private static IUmbracoBuilder AddSupplemenataryLocalizedTextFileSources(this IUmbracoBuilder builder) { - /// - /// Adds the supplementary localized texxt file sources from the various physical and virtual locations supported. - /// - private static IUmbracoBuilder AddSupplemenataryLocalizedTextFileSources(this IUmbracoBuilder builder) + builder.Services.AddTransient(sp => { - builder.Services.AddTransient(sp => + return GetSupplementaryFileSources( + sp.GetRequiredService()); + }); + + return builder; + } + + + /// + /// Loads the suplimentary localization files from plugins and user config + /// + private static IEnumerable GetSupplementaryFileSources( + IWebHostEnvironment webHostEnvironment) + { + IFileProvider webFileProvider = webHostEnvironment.WebRootFileProvider; + IFileProvider contentFileProvider = webHostEnvironment.ContentRootFileProvider; + + // gets all langs files in /app_plugins real or virtual locations + IEnumerable pluginLangFileSources = + GetPluginLanguageFileSources(webFileProvider, Constants.SystemDirectories.AppPlugins, false); + + // user defined langs that overwrite the default, these should not be used by plugin creators + var userConfigLangFolder = Constants.SystemDirectories.Config + .TrimStart(Constants.CharArrays.Tilde); + + IEnumerable userLangFileSources = contentFileProvider + .GetDirectoryContents(userConfigLangFolder) + .Where(x => x.IsDirectory && x.Name.InvariantEquals("lang")) + .Select(x => new DirectoryInfo(x.PhysicalPath)) + .SelectMany(x => x.GetFiles("*.user.xml", SearchOption.TopDirectoryOnly)) + .Select(x => new LocalizedTextServiceSupplementaryFileSource(x, true)); + + return pluginLangFileSources + .Concat(userLangFileSources); + } + + + /// + /// Loads the suplimentary localaization files via the file provider. + /// + /// + /// locates all *.xml files in the lang folder of any sub folder of the one provided. + /// e.g /app_plugins/plugin-name/lang/*.xml + /// + private static IEnumerable GetPluginLanguageFileSources( + IFileProvider fileProvider, string folder, bool overwriteCoreKeys) + { + // locate all the *.xml files inside Lang folders inside folders of the main folder + // e.g. /app_plugins/plugin-name/lang/*.xml + var fileSources = new List(); + + var pluginFolders = fileProvider.GetDirectoryContents(folder) + .Where(x => x.IsDirectory).ToList(); + + foreach (IFileInfo pluginFolder in pluginFolders) + { + // get the full virtual path for the plugin folder + var pluginFolderPath = WebPath.Combine(folder, pluginFolder.Name); + + // get any lang folders in this plugin + IEnumerable langFolders = fileProvider.GetDirectoryContents(pluginFolderPath) + .Where(x => x.IsDirectory && x.Name.InvariantEquals("lang")); + + // loop through the lang folder(s) + // - there could be multiple on case sensitive file system + foreach (IFileInfo langFolder in langFolders) { - return GetSupplementaryFileSources( - sp.GetRequiredService()); - }); + // get the full 'virtual' path of the lang folder + var langFolderPath = WebPath.Combine(pluginFolderPath, langFolder.Name); - return builder; - } + // request all the files out of the path, these will have physicalPath set. + var files = fileProvider.GetDirectoryContents(langFolderPath) + .Where(x => x.Name.InvariantEndsWith(".xml") && !string.IsNullOrEmpty(x.PhysicalPath)) + .Select(x => new FileInfo(x.PhysicalPath)) + .Select(x => new LocalizedTextServiceSupplementaryFileSource(x, overwriteCoreKeys)) + .ToList(); - - /// - /// Loads the suplimentary localization files from plugins and user config - /// - private static IEnumerable GetSupplementaryFileSources( - IWebHostEnvironment webHostEnvironment) - { - IFileProvider webFileProvider = webHostEnvironment.WebRootFileProvider; - IFileProvider contentFileProvider = webHostEnvironment.ContentRootFileProvider; - - // gets all langs files in /app_plugins real or virtual locations - IEnumerable pluginLangFileSources = GetPluginLanguageFileSources(webFileProvider, Cms.Core.Constants.SystemDirectories.AppPlugins, false); - - // user defined langs that overwrite the default, these should not be used by plugin creators - var userConfigLangFolder = Cms.Core.Constants.SystemDirectories.Config - .TrimStart(Cms.Core.Constants.CharArrays.Tilde); - - IEnumerable userLangFileSources = contentFileProvider.GetDirectoryContents(userConfigLangFolder) - .Where(x => x.IsDirectory && x.Name.InvariantEquals("lang")) - .Select(x => new DirectoryInfo(x.PhysicalPath)) - .SelectMany(x => x.GetFiles("*.user.xml", SearchOption.TopDirectoryOnly)) - .Select(x => new LocalizedTextServiceSupplementaryFileSource(x, true)); - - return pluginLangFileSources - .Concat(userLangFileSources); - } - - - /// - /// Loads the suplimentary localaization files via the file provider. - /// - /// - /// locates all *.xml files in the lang folder of any sub folder of the one provided. - /// e.g /app_plugins/plugin-name/lang/*.xml - /// - private static IEnumerable GetPluginLanguageFileSources( - IFileProvider fileProvider, string folder, bool overwriteCoreKeys) - { - // locate all the *.xml files inside Lang folders inside folders of the main folder - // e.g. /app_plugins/plugin-name/lang/*.xml - var fileSources = new List(); - - var pluginFolders = fileProvider.GetDirectoryContents(folder) - .Where(x => x.IsDirectory).ToList(); - - foreach (IFileInfo pluginFolder in pluginFolders) - { - // get the full virtual path for the plugin folder - var pluginFolderPath = WebPath.Combine(folder, pluginFolder.Name); - - // get any lang folders in this plugin - IEnumerable langFolders = fileProvider.GetDirectoryContents(pluginFolderPath) - .Where(x => x.IsDirectory && x.Name.InvariantEquals("lang")); - - // loop through the lang folder(s) - // - there could be multiple on case sensitive file system - foreach (var langFolder in langFolders) + // add any to our results + if (files.Count > 0) { - // get the full 'virtual' path of the lang folder - var langFolderPath = WebPath.Combine(pluginFolderPath, langFolder.Name); - - // request all the files out of the path, these will have physicalPath set. - var files = fileProvider.GetDirectoryContents(langFolderPath) - .Where(x => x.Name.InvariantEndsWith(".xml") && !string.IsNullOrEmpty(x.PhysicalPath)) - .Select(x => new FileInfo(x.PhysicalPath)) - .Select(x => new LocalizedTextServiceSupplementaryFileSource(x, overwriteCoreKeys)) - .ToList(); - - // add any to our results - if (files.Count > 0) - { - fileSources.AddRange(files); - } + fileSources.AddRange(files); } } - - return fileSources; } + + return fileSources; } } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs index bc0a9f399d..b72de65682 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,9 +1,6 @@ -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; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; @@ -23,117 +20,117 @@ using Umbraco.Cms.Web.BackOffice.Services; using Umbraco.Cms.Web.BackOffice.SignalR; using Umbraco.Cms.Web.BackOffice.Trees; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +/// +/// Extension methods for for the Umbraco back office +/// +public static partial class UmbracoBuilderExtensions { /// - /// Extension methods for for the Umbraco back office + /// Adds all required components to run the Umbraco back office /// - public static partial class UmbracoBuilderExtensions + public static IUmbracoBuilder + AddBackOffice(this IUmbracoBuilder builder, Action? configureMvc = null) => builder + .AddConfiguration() + .AddUmbracoCore() + .AddWebComponents() + .AddRuntimeMinifier() + .AddBackOfficeCore() + .AddBackOfficeAuthentication() + .AddBackOfficeIdentity() + .AddMembersIdentity() + .AddBackOfficeAuthorizationPolicies() + .AddUmbracoProfiler() + .AddMvcAndRazor(configureMvc) + .AddWebServer() + .AddPreviewSupport() + .AddHostedServices() + .AddNuCache() + .AddDistributedCache() + .AddModelsBuilderDashboard() + .AddUnattendedInstallInstallCreateUser() + .AddCoreNotifications() + .AddLogViewer() + .AddExamine() + .AddExamineIndexes() + .AddControllersWithAmbiguousConstructors() + .AddSupplemenataryLocalizedTextFileSources(); + + public static IUmbracoBuilder AddUnattendedInstallInstallCreateUser(this IUmbracoBuilder builder) { - /// - /// Adds all required components to run the Umbraco back office - /// - public static IUmbracoBuilder AddBackOffice(this IUmbracoBuilder builder, Action? configureMvc = null) => builder - .AddConfiguration() - .AddUmbracoCore() - .AddWebComponents() - .AddRuntimeMinifier() - .AddBackOfficeCore() - .AddBackOfficeAuthentication() - .AddBackOfficeIdentity() - .AddMembersIdentity() - .AddBackOfficeAuthorizationPolicies() - .AddUmbracoProfiler() - .AddMvcAndRazor(configureMvc) - .AddWebServer() - .AddPreviewSupport() - .AddHostedServices() - .AddNuCache() - .AddDistributedCache() - .AddModelsBuilderDashboard() - .AddUnattendedInstallInstallCreateUser() - .AddCoreNotifications() - .AddLogViewer() - .AddExamine() - .AddExamineIndexes() - .AddControllersWithAmbiguousConstructors() - .AddSupplemenataryLocalizedTextFileSources(); + builder.AddNotificationAsyncHandler(); + return builder; + } - public static IUmbracoBuilder AddUnattendedInstallInstallCreateUser(this IUmbracoBuilder builder) + /// + /// Adds Umbraco preview support + /// + public static IUmbracoBuilder AddPreviewSupport(this IUmbracoBuilder builder) + { + builder.Services.AddSignalR(); + + return builder; + } + + /// + /// Gets the back office tree collection builder + /// + public static TreeCollectionBuilder? Trees(this IUmbracoBuilder builder) + => builder.WithCollectionBuilder(); + + public static IUmbracoBuilder AddBackOfficeCore(this IUmbracoBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.ConfigureOptions(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.AddNotificationAsyncHandler(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // register back office trees + // the collection builder only accepts types inheriting from TreeControllerBase + // and will filter out those that are not attributed with TreeAttribute + var umbracoApiControllerTypes = builder.TypeLoader.GetUmbracoApiControllers().ToList(); + builder.Trees()? + .AddTreeControllers(umbracoApiControllerTypes.Where(x => typeof(TreeControllerBase).IsAssignableFrom(x))); + + builder.AddWebMappingProfiles(); + + builder.Services.AddUnique(factory => { - builder.AddNotificationAsyncHandler(); - return builder; - } + var path = "~/"; + IHostingEnvironment hostingEnvironment = factory.GetRequiredService(); + return new PhysicalFileSystem( + factory.GetRequiredService(), + hostingEnvironment, + factory.GetRequiredService>(), + hostingEnvironment.MapPathContentRoot(path), + hostingEnvironment.ToAbsolute(path) + ); + }); - /// - /// Adds Umbraco preview support - /// - public static IUmbracoBuilder AddPreviewSupport(this IUmbracoBuilder builder) - { - builder.Services.AddSignalR(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddSingleton(); - return builder; - } + return builder; + } - /// - /// Gets the back office tree collection builder - /// - public static TreeCollectionBuilder? Trees(this IUmbracoBuilder builder) - => builder.WithCollectionBuilder(); + /// + /// 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)); - public static IUmbracoBuilder AddBackOfficeCore(this IUmbracoBuilder builder) - { - builder.Services.AddSingleton(); - builder.Services.ConfigureOptions(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.AddNotificationAsyncHandler(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // register back office trees - // the collection builder only accepts types inheriting from TreeControllerBase - // and will filter out those that are not attributed with TreeAttribute - var umbracoApiControllerTypes = builder.TypeLoader.GetUmbracoApiControllers().ToList(); - builder.Trees()? - .AddTreeControllers(umbracoApiControllerTypes.Where(x => typeof(TreeControllerBase).IsAssignableFrom(x))); - - builder.AddWebMappingProfiles(); - - builder.Services.AddUnique(factory => - { - var path = "~/"; - var hostingEnvironment = factory.GetRequiredService(); - return new PhysicalFileSystem( - factory.GetRequiredService(), - hostingEnvironment, - factory.GetRequiredService>(), - hostingEnvironment.MapPathContentRoot(path), - hostingEnvironment.ToAbsolute(path) - ); - }); - - builder.Services.AddUnique(); - builder.Services.AddUnique(); - builder.Services.AddSingleton(); - - 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; - } + return builder; } } diff --git a/src/Umbraco.Web.BackOffice/EmbeddedResources/Tours/getting-started.json b/src/Umbraco.Web.BackOffice/EmbeddedResources/Tours/getting-started.json index 837682c896..eba7e94d1e 100644 --- a/src/Umbraco.Web.BackOffice/EmbeddedResources/Tours/getting-started.json +++ b/src/Umbraco.Web.BackOffice/EmbeddedResources/Tours/getting-started.json @@ -1,490 +1,488 @@ [ - { - "name": "Email Marketing", - "alias": "umbEmailMarketing", - "group": "Email Marketing", - "groupOrder": 10, - "hidden": true, - "requiredSections": [ - "content" - ], - "steps": [ - { - "title": "Do you want to stay updated on everything Umbraco?", - "content": "

Thank you for using Umbraco! Would you like to stay up-to-date with Umbraco product updates, security advisories, community news and special offers? Sign up for our newsletter and never miss out on the latest Umbraco news.

By signing up, you agree that we can use your info according to our privacy policy.

", - "view": "emails", - "type": "promotion" - }, - { - "title": "Thank you for subscribing to our mailing list", - "view": "confirm" - } - ] - }, - { - "name": "Introduction", - "alias": "umbIntroIntroduction", - "group": "Getting Started", - "groupOrder": 100, - "allowDisable": true, - "requiredSections": [ - "content" - ], - "steps": [ - { - "title": "Welcome to Umbraco - The Friendly CMS", - "content": "

Thank you for choosing Umbraco - we think this could be the beginning of something beautiful. While it may feel overwhelming at first, we've done a lot to make the learning curve as smooth and fast as possible.

In this quick tour we will introduce you to the main areas of Umbraco and show you how to best get started.

If you don't want to take the tour now you can always start it by opening the Help drawer in the top right corner.

", - "type": "intro" - }, - { - "element": "[data-element='sections']", - "elementPreventClick": true, - "title": "Main Menu", - "content": "This is the main menu in Umbraco backoffice. Here you can navigate between the different sections, search for items, see your user profile and open the help drawer.", - "backdropOpacity": 0.6 - }, - { - "element": "[data-element='section-content']", - "elementPreventClick": true, - "title": "Sections", - "content": "Each area in Umbraco is called a Section. Right now you are in the Content section, when you want to go to another section simply click on the appropriate name in the main menu and you'll be there in no time.", - "backdropOpacity": 0.6 - }, - { - "element": "[data-element='section-content']", - "skipStepIfVisible": "[data-element='dashboard']", - "title": "Content section", - "content": "Try clicking Content to enter the content section.", - "event": "click", - "backdropOpacity": 0.6 - }, - { - "element": "#tree", - "elementPreventClick": true, - "title": "The Tree", - "content": "

This is the Tree and it is the main navigation inside a section.

In the Content section the tree is called the Content tree and here you can navigate the content of your website.

" - }, - { - "element": "[data-element='dashboard']", - "elementPreventClick": true, - "title": "Dashboards", - "content": "

A dashboard is the main view you are presented with when entering a section within the backoffice, and can be used to show valuable information to the users of the system.

Notice that some sections have multiple dashboards.

" - }, - { - "element": "[data-element='global-search']", - "title": "Search", - "content": "The search allows you to quickly find whatever you're looking for across sections within Umbraco." - }, - { - "element": "[data-element='global-user']", - "title": "User profile", - "content": "Now click on your user avatar to open the user profile dialog.", - "event": "click", - "backdropOpacity": 0.6 - }, - { - "element": "[data-element~='overlay-user']", - "elementPreventClick": true, - "title": "User profile", - "content": "

Here you can see details about your user, change your password and log out of Umbraco.

In the User section you will be able to do more advanced user management.

" - }, - { - "element": "[data-element~='overlay-user'] [data-element='button-overlayClose']", - "title": "User profile", - "content": "Let's close the user profile again.", - "event": "click" - }, - { - "element": "[data-element='global-help']", - "title": "Help", - "content": "If you ever find yourself in trouble click here to open the Help drawer.", - "event": "click", - "backdropOpacity": 0.6 - }, - { - "element": "[data-element='drawer']", - "elementPreventClick": true, - "title": "Help", - "content": "

In the help drawer you will find articles and videos related to the section you are using.

This is also where you will find the next tour on how to get started with Umbraco.

", - "backdropOpacity": 0.6 - }, - { - "element": "[data-element='drawer'] [data-element='help-tours']", - "title": "Tours", - "content": "To continue your journey on getting started with Umbraco, you can find more tours right here." - } - ] - }, - { - "name": "Create document type", - "alias": "umbIntroCreateDocType", - "group": "Getting Started", - "groupOrder": 100, - "requiredSections": [ - "settings" - ], - "steps": [ - { - "title": "Create your first Document Type", - "content": "

Step 1 of any site is to create a Document Type.
A Document Type is a template for content. For each type of content you want to create you'll create a Document Type. This will define where content based on this Document Type can be created, how many properties it holds and what the input method should be for these properties.

When you have at least one Document Type in place you can start creating content and this content can then be used in a template.

In this tour you will learn how to set up a basic Document Type with a property to enter a short text.

", - "type": "intro" - }, - { - "element": "#applications [data-element='section-settings']", - "title": "Navigate to the Settings sections", - "content": "In the Settings section you can create and manage Document types.", - "event": "click", - "backdropOpacity": 0.6 - }, - { - "element": "#tree [data-element='tree-item-documentTypes']", - "title": "Create Document Type", - "content": "

Hover over the Document Type tree and click the three small dots to open the context menu.

", - "event": "click", - "eventElement": "#tree [data-element='tree-item-documentTypes'] [data-element='tree-item-options']" - }, - { - "element": "#dialog [data-element='action-documentType']", - "title": "Create Document Type", - "content": "

Click Document Type to create a new document type with a template. The template will be automatically created and set as the default template for this Document Type.

You will use the template in a later tour to render content.

", - "event": "click" - }, - { - "element": "[data-element='editor-name-field']", - "title": "Enter a name", - "content": "

Your Document Type needs a name. Enter Home Page in the field and click Next.", - "view": "doctypename" - }, - { - "element": "[data-element='editor-description']", - "title": "Enter a description", - "content": "

A description helps to pick the right document type when creating content.

Write a description for our Home page. It could be:

The home page of the website

" - }, - { - "element": "[data-element='groups-builder']", - "elementPreventClick": true, - "title": "Properties, groups, and tabs", - "content": "A Document Type consist of Properties (data fields/attributes) where an editor can input data. For complex Document Types you can organize Properties in groups and tabs." - }, - { - "element": "[data-element='group-add']", - "title": "Add group", - "content": "In this tour we only need a group. Click Add Group to add a group.", - "event": "click" - }, - { - "element": "[data-element='group-name-field']", - "title": "Name the group", - "content": "

Enter Home in the group name.

You can name a group anything you want and if you have a lot of properties it can be useful to add multiple groups.

", - "view": "tabName" - }, - { - "element": "[data-element='property-add']", - "title": "Add a property", - "content": "

Properties are the different input fields on a content page.

On our Home Page we want to add a welcome text.

Click Add property to open the property dialog.

", - "event": "click" - }, - { - "element": "[data-element='editor-property-settings'] [data-element='property-name']", - "title": "Name the property", - "content": "Enter Welcome Text as the name for the property.", - "view": "propertyname" - }, - { - "element": "[data-element~='editor-property-settings'] [data-element='property-description']", - "title": "Enter a description", - "content": "

A description will help your editor fill in the right content.

Enter a description for the property editor. It could be:

Write a nice introduction text so the visitors feel welcome

" - }, - { - "element": "[data-element~='editor-property-settings'] [data-element='editor-add']", - "title": "Add editor", - "content": "When you add an editor you choose what the input method for this property will be. Click Add editor to open the editor picker dialog.", - "event": "click" - }, - { - "element": "[ng-controller*='Umbraco.Editors.DataTypePickerController'] [data-element='editor-data-type-picker']", - "elementPreventClick": true, - "title": "Editor picker", - "content": "

In the editor picker dialog we can pick one of the many built-in editors.

", - - - }, - { - "element": "[data-element~='editor-data-type-picker'] [data-element='datatype-Textarea']", - "title": "Select editor", - "content": "Select the Textarea editor. This will add a textarea to the Welcome Text property.", - "event": "click" - }, - { - "element": "[data-element='editor-data-type-picker'] [data-element='datatypeconfig-Textarea']", - "title": "Editor settings", - "content": "Each property editor can have individual settings. For the textarea editor you can set a character limit but in this case it is not needed.", - "event": "click" - }, - { - "element": "[data-element~='editor-property-settings'] [data-element='button-submit']", - "title": "Add property to document type", - "content": "Click Submit to add the property to the document type.", - "event": "click" - }, - { - "element": "[data-element~='sub-view-permissions']", - "title": "Check the document type permissions", - "content": "Click Permissions to view the permissions page.", - "event": "click" - }, - { - "element": "[data-element~='permissions-allow-as-root']", - "title": "Allow this document type to work at the root of your site", - "content": "Toggle the switch Allow as root to allow new content pages based on this document type to be created at the root of your site", - "event": "click" - }, - { - "element": "[data-element='button-save']", - "title": "Save the document type", - "content": "All we need now is to save the document type. Click Save to create and save your new document type.", - "event": "click" - } - ] - }, - { - "name": "Create Content", - "alias": "umbIntroCreateContent", - "group": "Getting Started", - "groupOrder": 100, - "requiredSections": [ - "content" - ], - "steps": [ - { - "title": "Creating your first content node", - "content": "

In this tour you will learn how to create the home page for your website. It will use the Home Page Document type you created in the previous tour.

", - "type": "intro" - }, - { - "element": "#applications [data-element='section-content']", - "title": "Navigate to the Content section", - "content": "

In the Content section you can create and manage the content of the website.

The Content section contains the content of your website. Content is displayed as nodes in the content tree.

", - "event": "click", - "backdropOpacity": 0.6 - }, - { - "element": "[data-element='tree-root']", - "title": "Open context menu", - "content": "

Open the context menu by hovering over the root of the content section.

Now click the three small dots to the right.

", - "event": "click", - "eventElement": "#tree [data-element='tree-root'] [data-element='tree-item-options']" - }, - { - "element": "[data-element='action-create-homePage']", - "title": "Create Home page", - "content": "

The context menu shows you all the actions that are available on a node

Click on Home Page to create a new page of type Home Page.

", - "event": "click" - }, - { - "element": "[data-element='editor-content'] [data-element='editor-name-field']", - "title": "Give your new page a name", - "content": "

Our new page needs a name. Enter Home in the field and click Next.

", - "view": "nodename" - }, - { - "element": "[data-element='editor-content'] [data-element='property-welcomeText'] > div", - "title": "Add a welcome text", - "content": "

Add content to the Welcome Text field.

If you don't have any ideas here is a start:

I am learning Umbraco. High Five I Rock #H5IR

" - }, - { - "element": "[data-element='editor-content'] [data-element='button-saveAndPublish']", - "title": "Publish", - "content": "

Now click the Publish button to publish your changes.

", - "event": "click" - } - ] - }, - { - "name": "Render in template", - "alias": "umbIntroRenderInTemplate", - "group": "Getting Started", - "groupOrder": 100, - "requiredSections": [ - "settings" - ], - "steps": [ - { - "title": "Render your content in a template", - "content": "

Templating in Umbraco builds on the concept of Razor Views from ASP.NET MVC. This tour is a sneak peak on how to write templates in Umbraco.

In this tour you will learn how to render content from the Home Page document type so you can see the content added to our Home content page.

", - "type": "intro" - }, - { - "element": "#applications [data-element='section-settings']", - "title": "Navigate to the Settings section", - "content": "

In the Settings section you will find all the templates.

It is of course also possible to edit all your code files in your favorite code editor.

", - "event": "click", - "backdropOpacity": 0.6 - }, - { - "element": "#tree [data-element='tree-item-templates']", - "title": "Expand the Templates node", - "content": "

To see all our templates click the small triangle to the left of the templates node.

", - "event": "click", - "eventElement": "#tree [data-element='tree-item-templates'] [data-element='tree-item-expand']", - "view": "templatetree", - "skipStepIfVisible": "#tree [data-element='tree-item-templates'] > div > button[data-element=tree-item-expand] span.icon-navigation-down" - }, - { - "element": "#tree [data-element='tree-item-templates'] [data-element='tree-item-Home Page']", - "title": "Open Home template", - "content": "

Click the Home Page template to open and edit it.

", - "eventElement": "#tree [data-element='tree-item-templates'] [data-element='tree-item-Home Page'] a.umb-tree-item__label", - "event": "click" - }, - { - "element": "[data-element='editor-templates'] [data-element='code-editor']", - "title": "Edit template", - "content": "

The template can be edited here or in your favorite code editor.

To render the field from the document type add the following to the template:

<h1>@Model.Name</h1>
<p>@Model.WelcomeText</p>

" - }, - { - "element": "[data-element='editor-templates'] [data-element='button-save']", - "title": "Save the template", - "content": "Click the Save button and your template will be saved.", - "event": "click" - } - ] - }, - { - "name": "View Home page", - "alias": "umbIntroViewHomePage", - "group": "Getting Started", - "groupOrder": 100, - "requiredSections": [ - "content" - ], - "steps": [ - { - "title": "View your Umbraco site", - "content": "

Our three main components for a page are done: Document type, Template, and Content. It is now time to see the result.

In this tour you will learn how to see your published website.

", - "type": "intro" - }, - { - "element": "#applications [data-element='section-content']", - "title": "Navigate to the content sections", - "content": "In the Content section you will find the content of our website.", - "event": "click", - "backdropOpacity": 0.6 - }, - { - "element": "#tree [data-element='tree-item-Home']", - "title": "Open the Home page", - "content": "

Click the Home page to open it.

", - "event": "click", - "eventElement": "#tree [data-element='tree-item-Home'] a.umb-tree-item__label" - }, - { - "element": "[data-element='editor-content'] [data-element='sub-view-umbInfo']", - "title": "Info", - "content": "

Under the Info-app you will find the default information about a content item.

", - "event": "click" - }, - { - "element": "[data-element='editor-content'] [data-element='node-info-urls']", - "title": "Open page", - "content": "

Click the Link to document to view your page.

Tip: Click the preview button in the bottom right corner to preview changes without publishing them.

", - "event": "click", - "eventElement": "[data-element='editor-content'] [data-element='node-info-urls'] a[target='_blank']" - } - ] - }, - { - "name": "The Media library", - "alias": "umbIntroMediaSection", - "group": "Getting Started", - "groupOrder": 100, - "requiredSections": [ - "media" - ], - "steps": [ - { - "title": "How to use the media library", - "content": "

A website would be boring without media content. In Umbraco you can manage all your images, documents, videos etc. in the Media section. Here you can upload and organise your media items and see details about each item.

In this tour you will learn how to upload and organise your Media library in Umbraco. It will also show you how to view details about a specific media item.

", - "type": "intro" - }, - { - "element": "#applications [data-element='section-media']", - "title": "Navigate to the Media section", - "content": "The media section is where you manage all your media items.", - "event": "click", - "backdropOpacity": 0.6 - }, - { - "element": "#tree [data-element='tree-root']", - "title": "Create a new folder", - "content": "

First create a folder for your images. Hover over the media root node and click the three small dots on the right side of the item.

", - "event": "click", - "eventElement": "#tree [data-element='tree-root'] [data-element='tree-item-options']" - }, - { - "element": "#dialog [data-element='action-Folder']", - "title": "Create a new folder", - "content": "

Select the Folder option to select the type folder.

", - "event": "click" - }, - { - "element": "[data-element='editor-media'] [data-element='editor-name-field']", - "title": "Enter a name", - "content": "

Enter My Images in the field.

", - "view": "foldername" - }, - { - "element": "[data-element='editor-media'] [data-element='button-save']", - "title": "Save the folder", - "content": "

Click the Save button to create the new folder.

", - "event": "click" - }, - { - "element": "[data-element='editor-media'] [data-element='dropzone']", - "title": "Upload images", - "content": "

In the upload area you can upload your media items.

Click the Click here to choose files button and select a couple of images on your computer and upload them.

", - "view": "uploadimages" - }, - { - "element": "[data-element='editor-media'] [data-element='media-grid-item-0']", - "title": "View media item details", - "content": "Hover over the media item and Click the white bar to view details about the media item.", - "event": "click", - "eventElement": "[data-element='editor-media'] [data-element='media-grid-item-0'] [data-element='media-grid-item-edit']" - }, - { - "element": "[data-element='editor-media'] [data-element='property-umbracoFile']", - "elementPreventClick": true, - "title": "The uploaded image", - "content": "

Here you can see the image you have uploaded.

" - }, - { - "element": "[data-element='editor-media'] [data-element='property-umbracoBytes']", - "title": "Image size", - "content": "

You will also find other details about the image, like the size.

Media items work in much the same way as content. So you can add extra properties to an image by creating or editing the Media types in the Settings section.

" - }, - { - "element": "[data-element='editor-media'] [data-element='sub-view-umbInfo']", - "title": "Info", - "content": "Like the content section you can also find default information about the media item. You will find these under the info app.", - "event": "click" - }, - { - "element": "[data-element='editor-media'] [data-element='node-info-urls']", - "title": "Link to media", - "content": "The path to the media item..." - }, - { - "element": "[data-element='editor-media'] [data-element='node-info-update-date']", - "title": "Last edited", - "content": "...and information about when the media item has been created and edited." - }, - { - "element": "[data-element='editor-container']", - "elementPreventClick": true, - "title": "Using media items", - "content": "You can reference a media item directly in a template by using the path or try adding a Media Picker to a document type property so you can select media items from the content section." - } - ] - } + { + "name": "Email Marketing", + "alias": "umbEmailMarketing", + "group": "Email Marketing", + "groupOrder": 10, + "hidden": true, + "requiredSections": [ + "content" + ], + "steps": [ + { + "title": "Do you want to stay updated on everything Umbraco?", + "content": "

Thank you for using Umbraco! Would you like to stay up-to-date with Umbraco product updates, security advisories, community news and special offers? Sign up for our newsletter and never miss out on the latest Umbraco news.

By signing up, you agree that we can use your info according to our privacy policy.

", + "view": "emails", + "type": "promotion" + }, + { + "title": "Thank you for subscribing to our mailing list", + "view": "confirm" + } + ] + }, + { + "name": "Introduction", + "alias": "umbIntroIntroduction", + "group": "Getting Started", + "groupOrder": 100, + "allowDisable": true, + "requiredSections": [ + "content" + ], + "steps": [ + { + "title": "Welcome to Umbraco - The Friendly CMS", + "content": "

Thank you for choosing Umbraco - we think this could be the beginning of something beautiful. While it may feel overwhelming at first, we've done a lot to make the learning curve as smooth and fast as possible.

In this quick tour we will introduce you to the main areas of Umbraco and show you how to best get started.

If you don't want to take the tour now you can always start it by opening the Help drawer in the top right corner.

", + "type": "intro" + }, + { + "element": "[data-element='sections']", + "elementPreventClick": true, + "title": "Main Menu", + "content": "This is the main menu in Umbraco backoffice. Here you can navigate between the different sections, search for items, see your user profile and open the help drawer.", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element='section-content']", + "elementPreventClick": true, + "title": "Sections", + "content": "Each area in Umbraco is called a Section. Right now you are in the Content section, when you want to go to another section simply click on the appropriate name in the main menu and you'll be there in no time.", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element='section-content']", + "skipStepIfVisible": "[data-element='dashboard']", + "title": "Content section", + "content": "Try clicking Content to enter the content section.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "#tree", + "elementPreventClick": true, + "title": "The Tree", + "content": "

This is the Tree and it is the main navigation inside a section.

In the Content section the tree is called the Content tree and here you can navigate the content of your website.

" + }, + { + "element": "[data-element='dashboard']", + "elementPreventClick": true, + "title": "Dashboards", + "content": "

A dashboard is the main view you are presented with when entering a section within the backoffice, and can be used to show valuable information to the users of the system.

Notice that some sections have multiple dashboards.

" + }, + { + "element": "[data-element='global-search']", + "title": "Search", + "content": "The search allows you to quickly find whatever you're looking for across sections within Umbraco." + }, + { + "element": "[data-element='global-user']", + "title": "User profile", + "content": "Now click on your user avatar to open the user profile dialog.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element~='overlay-user']", + "elementPreventClick": true, + "title": "User profile", + "content": "

Here you can see details about your user, change your password and log out of Umbraco.

In the User section you will be able to do more advanced user management.

" + }, + { + "element": "[data-element~='overlay-user'] [data-element='button-overlayClose']", + "title": "User profile", + "content": "Let's close the user profile again.", + "event": "click" + }, + { + "element": "[data-element='global-help']", + "title": "Help", + "content": "If you ever find yourself in trouble click here to open the Help drawer.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element='drawer']", + "elementPreventClick": true, + "title": "Help", + "content": "

In the help drawer you will find articles and videos related to the section you are using.

This is also where you will find the next tour on how to get started with Umbraco.

", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element='drawer'] [data-element='help-tours']", + "title": "Tours", + "content": "To continue your journey on getting started with Umbraco, you can find more tours right here." + } + ] + }, + { + "name": "Create document type", + "alias": "umbIntroCreateDocType", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "settings" + ], + "steps": [ + { + "title": "Create your first Document Type", + "content": "

Step 1 of any site is to create a Document Type.
A Document Type is a template for content. For each type of content you want to create you'll create a Document Type. This will define where content based on this Document Type can be created, how many properties it holds and what the input method should be for these properties.

When you have at least one Document Type in place you can start creating content and this content can then be used in a template.

In this tour you will learn how to set up a basic Document Type with a property to enter a short text.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-settings']", + "title": "Navigate to the Settings sections", + "content": "In the Settings section you can create and manage Document types.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "#tree [data-element='tree-item-documentTypes']", + "title": "Create Document Type", + "content": "

Hover over the Document Type tree and click the three small dots to open the context menu.

", + "event": "click", + "eventElement": "#tree [data-element='tree-item-documentTypes'] [data-element='tree-item-options']" + }, + { + "element": "#dialog [data-element='action-documentType']", + "title": "Create Document Type", + "content": "

Click Document Type to create a new document type with a template. The template will be automatically created and set as the default template for this Document Type.

You will use the template in a later tour to render content.

", + "event": "click" + }, + { + "element": "[data-element='editor-name-field']", + "title": "Enter a name", + "content": "

Your Document Type needs a name. Enter Home Page in the field and click Next.", + "view": "doctypename" + }, + { + "element": "[data-element='editor-description']", + "title": "Enter a description", + "content": "

A description helps to pick the right document type when creating content.

Write a description for our Home page. It could be:

The home page of the website

" + }, + { + "element": "[data-element='groups-builder']", + "elementPreventClick": true, + "title": "Properties, groups, and tabs", + "content": "A Document Type consist of Properties (data fields/attributes) where an editor can input data. For complex Document Types you can organize Properties in groups and tabs." + }, + { + "element": "[data-element='group-add']", + "title": "Add group", + "content": "In this tour we only need a group. Click Add Group to add a group.", + "event": "click" + }, + { + "element": "[data-element='group-name-field']", + "title": "Name the group", + "content": "

Enter Home in the group name.

You can name a group anything you want and if you have a lot of properties it can be useful to add multiple groups.

", + "view": "tabName" + }, + { + "element": "[data-element='property-add']", + "title": "Add a property", + "content": "

Properties are the different input fields on a content page.

On our Home Page we want to add a welcome text.

Click Add property to open the property dialog.

", + "event": "click" + }, + { + "element": "[data-element='editor-property-settings'] [data-element='property-name']", + "title": "Name the property", + "content": "Enter Welcome Text as the name for the property.", + "view": "propertyname" + }, + { + "element": "[data-element~='editor-property-settings'] [data-element='property-description']", + "title": "Enter a description", + "content": "

A description will help your editor fill in the right content.

Enter a description for the property editor. It could be:

Write a nice introduction text so the visitors feel welcome

" + }, + { + "element": "[data-element~='editor-property-settings'] [data-element='editor-add']", + "title": "Add editor", + "content": "When you add an editor you choose what the input method for this property will be. Click Add editor to open the editor picker dialog.", + "event": "click" + }, + { + "element": "[ng-controller*='Umbraco.Editors.DataTypePickerController'] [data-element='editor-data-type-picker']", + "elementPreventClick": true, + "title": "Editor picker", + "content": "

In the editor picker dialog we can pick one of the many built-in editors.

" + }, + { + "element": "[data-element~='editor-data-type-picker'] [data-element='datatype-Textarea']", + "title": "Select editor", + "content": "Select the Textarea editor. This will add a textarea to the Welcome Text property.", + "event": "click" + }, + { + "element": "[data-element='editor-data-type-picker'] [data-element='datatypeconfig-Textarea']", + "title": "Editor settings", + "content": "Each property editor can have individual settings. For the textarea editor you can set a character limit but in this case it is not needed.", + "event": "click" + }, + { + "element": "[data-element~='editor-property-settings'] [data-element='button-submit']", + "title": "Add property to document type", + "content": "Click Submit to add the property to the document type.", + "event": "click" + }, + { + "element": "[data-element~='sub-view-permissions']", + "title": "Check the document type permissions", + "content": "Click Permissions to view the permissions page.", + "event": "click" + }, + { + "element": "[data-element~='permissions-allow-as-root']", + "title": "Allow this document type to work at the root of your site", + "content": "Toggle the switch Allow as root to allow new content pages based on this document type to be created at the root of your site", + "event": "click" + }, + { + "element": "[data-element='button-save']", + "title": "Save the document type", + "content": "All we need now is to save the document type. Click Save to create and save your new document type.", + "event": "click" + } + ] + }, + { + "name": "Create Content", + "alias": "umbIntroCreateContent", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "content" + ], + "steps": [ + { + "title": "Creating your first content node", + "content": "

In this tour you will learn how to create the home page for your website. It will use the Home Page Document type you created in the previous tour.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-content']", + "title": "Navigate to the Content section", + "content": "

In the Content section you can create and manage the content of the website.

The Content section contains the content of your website. Content is displayed as nodes in the content tree.

", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "[data-element='tree-root']", + "title": "Open context menu", + "content": "

Open the context menu by hovering over the root of the content section.

Now click the three small dots to the right.

", + "event": "click", + "eventElement": "#tree [data-element='tree-root'] [data-element='tree-item-options']" + }, + { + "element": "[data-element='action-create-homePage']", + "title": "Create Home page", + "content": "

The context menu shows you all the actions that are available on a node

Click on Home Page to create a new page of type Home Page.

", + "event": "click" + }, + { + "element": "[data-element='editor-content'] [data-element='editor-name-field']", + "title": "Give your new page a name", + "content": "

Our new page needs a name. Enter Home in the field and click Next.

", + "view": "nodename" + }, + { + "element": "[data-element='editor-content'] [data-element='property-welcomeText'] > div", + "title": "Add a welcome text", + "content": "

Add content to the Welcome Text field.

If you don't have any ideas here is a start:

I am learning Umbraco. High Five I Rock #H5IR

" + }, + { + "element": "[data-element='editor-content'] [data-element='button-saveAndPublish']", + "title": "Publish", + "content": "

Now click the Publish button to publish your changes.

", + "event": "click" + } + ] + }, + { + "name": "Render in template", + "alias": "umbIntroRenderInTemplate", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "settings" + ], + "steps": [ + { + "title": "Render your content in a template", + "content": "

Templating in Umbraco builds on the concept of Razor Views from ASP.NET MVC. This tour is a sneak peak on how to write templates in Umbraco.

In this tour you will learn how to render content from the Home Page document type so you can see the content added to our Home content page.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-settings']", + "title": "Navigate to the Settings section", + "content": "

In the Settings section you will find all the templates.

It is of course also possible to edit all your code files in your favorite code editor.

", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "#tree [data-element='tree-item-templates']", + "title": "Expand the Templates node", + "content": "

To see all our templates click the small triangle to the left of the templates node.

", + "event": "click", + "eventElement": "#tree [data-element='tree-item-templates'] [data-element='tree-item-expand']", + "view": "templatetree", + "skipStepIfVisible": "#tree [data-element='tree-item-templates'] > div > button[data-element=tree-item-expand] span.icon-navigation-down" + }, + { + "element": "#tree [data-element='tree-item-templates'] [data-element='tree-item-Home Page']", + "title": "Open Home template", + "content": "

Click the Home Page template to open and edit it.

", + "eventElement": "#tree [data-element='tree-item-templates'] [data-element='tree-item-Home Page'] a.umb-tree-item__label", + "event": "click" + }, + { + "element": "[data-element='editor-templates'] [data-element='code-editor']", + "title": "Edit template", + "content": "

The template can be edited here or in your favorite code editor.

To render the field from the document type add the following to the template:

<h1>@Model.Name</h1>
<p>@Model.WelcomeText</p>

" + }, + { + "element": "[data-element='editor-templates'] [data-element='button-save']", + "title": "Save the template", + "content": "Click the Save button and your template will be saved.", + "event": "click" + } + ] + }, + { + "name": "View Home page", + "alias": "umbIntroViewHomePage", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "content" + ], + "steps": [ + { + "title": "View your Umbraco site", + "content": "

Our three main components for a page are done: Document type, Template, and Content. It is now time to see the result.

In this tour you will learn how to see your published website.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-content']", + "title": "Navigate to the content sections", + "content": "In the Content section you will find the content of our website.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "#tree [data-element='tree-item-Home']", + "title": "Open the Home page", + "content": "

Click the Home page to open it.

", + "event": "click", + "eventElement": "#tree [data-element='tree-item-Home'] a.umb-tree-item__label" + }, + { + "element": "[data-element='editor-content'] [data-element='sub-view-umbInfo']", + "title": "Info", + "content": "

Under the Info-app you will find the default information about a content item.

", + "event": "click" + }, + { + "element": "[data-element='editor-content'] [data-element='node-info-urls']", + "title": "Open page", + "content": "

Click the Link to document to view your page.

Tip: Click the preview button in the bottom right corner to preview changes without publishing them.

", + "event": "click", + "eventElement": "[data-element='editor-content'] [data-element='node-info-urls'] a[target='_blank']" + } + ] + }, + { + "name": "The Media library", + "alias": "umbIntroMediaSection", + "group": "Getting Started", + "groupOrder": 100, + "requiredSections": [ + "media" + ], + "steps": [ + { + "title": "How to use the media library", + "content": "

A website would be boring without media content. In Umbraco you can manage all your images, documents, videos etc. in the Media section. Here you can upload and organise your media items and see details about each item.

In this tour you will learn how to upload and organise your Media library in Umbraco. It will also show you how to view details about a specific media item.

", + "type": "intro" + }, + { + "element": "#applications [data-element='section-media']", + "title": "Navigate to the Media section", + "content": "The media section is where you manage all your media items.", + "event": "click", + "backdropOpacity": 0.6 + }, + { + "element": "#tree [data-element='tree-root']", + "title": "Create a new folder", + "content": "

First create a folder for your images. Hover over the media root node and click the three small dots on the right side of the item.

", + "event": "click", + "eventElement": "#tree [data-element='tree-root'] [data-element='tree-item-options']" + }, + { + "element": "#dialog [data-element='action-Folder']", + "title": "Create a new folder", + "content": "

Select the Folder option to select the type folder.

", + "event": "click" + }, + { + "element": "[data-element='editor-media'] [data-element='editor-name-field']", + "title": "Enter a name", + "content": "

Enter My Images in the field.

", + "view": "foldername" + }, + { + "element": "[data-element='editor-media'] [data-element='button-save']", + "title": "Save the folder", + "content": "

Click the Save button to create the new folder.

", + "event": "click" + }, + { + "element": "[data-element='editor-media'] [data-element='dropzone']", + "title": "Upload images", + "content": "

In the upload area you can upload your media items.

Click the Click here to choose files button and select a couple of images on your computer and upload them.

", + "view": "uploadimages" + }, + { + "element": "[data-element='editor-media'] [data-element='media-grid-item-0']", + "title": "View media item details", + "content": "Hover over the media item and Click the white bar to view details about the media item.", + "event": "click", + "eventElement": "[data-element='editor-media'] [data-element='media-grid-item-0'] [data-element='media-grid-item-edit']" + }, + { + "element": "[data-element='editor-media'] [data-element='property-umbracoFile']", + "elementPreventClick": true, + "title": "The uploaded image", + "content": "

Here you can see the image you have uploaded.

" + }, + { + "element": "[data-element='editor-media'] [data-element='property-umbracoBytes']", + "title": "Image size", + "content": "

You will also find other details about the image, like the size.

Media items work in much the same way as content. So you can add extra properties to an image by creating or editing the Media types in the Settings section.

" + }, + { + "element": "[data-element='editor-media'] [data-element='sub-view-umbInfo']", + "title": "Info", + "content": "Like the content section you can also find default information about the media item. You will find these under the info app.", + "event": "click" + }, + { + "element": "[data-element='editor-media'] [data-element='node-info-urls']", + "title": "Link to media", + "content": "The path to the media item..." + }, + { + "element": "[data-element='editor-media'] [data-element='node-info-update-date']", + "title": "Last edited", + "content": "...and information about when the media item has been created and edited." + }, + { + "element": "[data-element='editor-container']", + "elementPreventClick": true, + "title": "Using media items", + "content": "You can reference a media item directly in a template by using the path or try adding a Media Picker to a document type property so you can select media items from the content section." + } + ] + } ] diff --git a/src/Umbraco.Web.BackOffice/Extensions/ControllerContextExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/ControllerContextExtensions.cs index 567195c8fa..4ea015e249 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/ControllerContextExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/ControllerContextExtensions.cs @@ -1,152 +1,157 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; -namespace Umbraco.Cms.Web.BackOffice.Extensions +namespace Umbraco.Cms.Web.BackOffice.Extensions; + +internal static class ControllerContextExtensions { - internal static class ControllerContextExtensions + /// + /// Invokes the authorization filters for the controller action. + /// + /// Whether the user is authenticated or not. + internal static async Task InvokeAuthorizationFiltersForRequest(this ControllerContext controllerContext, ActionContext actionContext) { - /// - /// Invokes the authorization filters for the controller action. - /// - /// Whether the user is authenticated or not. - internal static async Task InvokeAuthorizationFiltersForRequest(this ControllerContext controllerContext, ActionContext actionContext) + ControllerActionDescriptor actionDescriptor = controllerContext.ActionDescriptor; + + var metadataCollection = + new EndpointMetadataCollection(actionDescriptor.EndpointMetadata.Union(new[] { actionDescriptor })); + + IReadOnlyList authorizeData = metadataCollection.GetOrderedMetadata(); + IAuthorizationPolicyProvider policyProvider = controllerContext.HttpContext.RequestServices + .GetRequiredService(); + AuthorizationPolicy? policy = await AuthorizationPolicy.CombineAsync(policyProvider, authorizeData); + if (policy is not null) { + IPolicyEvaluator policyEvaluator = + controllerContext.HttpContext.RequestServices.GetRequiredService(); + AuthenticateResult authenticateResult = + await policyEvaluator.AuthenticateAsync(policy, controllerContext.HttpContext); - var actionDescriptor = controllerContext.ActionDescriptor; - - var metadataCollection = new EndpointMetadataCollection(actionDescriptor.EndpointMetadata.Union(new []{actionDescriptor})); - - var authorizeData = metadataCollection.GetOrderedMetadata(); - var policyProvider = controllerContext.HttpContext.RequestServices.GetRequiredService(); - var policy = await AuthorizationPolicy.CombineAsync(policyProvider, authorizeData); - if (policy is not null) + if (!authenticateResult.Succeeded) { - var policyEvaluator = controllerContext.HttpContext.RequestServices.GetRequiredService(); - var authenticateResult = await policyEvaluator.AuthenticateAsync(policy, controllerContext.HttpContext); - - if (!authenticateResult.Succeeded) - { - return false; - } - - // TODO this is super hacky, but we rely on the FeatureAuthorizeHandler can still handle endpoints - // (The way before .NET 5). The .NET 5 way would need to use han http context, for the "inner" request - // with the nested controller - var resource = new Endpoint(null,metadataCollection, null); - var authorizeResult = await policyEvaluator.AuthorizeAsync(policy, authenticateResult, controllerContext.HttpContext, resource); - if (!authorizeResult.Succeeded) - { - return false; - } + return false; } - var filters = actionDescriptor.FilterDescriptors; - var filterGrouping = new FilterGrouping(filters, controllerContext.HttpContext.RequestServices); - - // because the continuation gets built from the inside out we need to reverse the filter list - // so that least specific filters (Global) get run first and the most specific filters (Action) get run last. - var authorizationFilters = filterGrouping.AuthorizationFilters.Reverse().ToList(); - var asyncAuthorizationFilters = filterGrouping.AsyncAuthorizationFilters.Reverse().ToList(); - - if (authorizationFilters.Count == 0 && asyncAuthorizationFilters.Count == 0) - return true; - - // if the authorization filter returns a result, it means it failed to authorize - var authorizationFilterContext = new AuthorizationFilterContext(actionContext, filters.Select(x=>x.Filter).ToArray()); - return await ExecuteAuthorizationFiltersAsync(authorizationFilterContext, authorizationFilters, asyncAuthorizationFilters); + // TODO this is super hacky, but we rely on the FeatureAuthorizeHandler can still handle endpoints + // (The way before .NET 5). The .NET 5 way would need to use han http context, for the "inner" request + // with the nested controller + var resource = new Endpoint(null, metadataCollection, null); + PolicyAuthorizationResult authorizeResult = + await policyEvaluator.AuthorizeAsync(policy, authenticateResult, controllerContext.HttpContext, resource); + if (!authorizeResult.Succeeded) + { + return false; + } } - /// - /// Executes a chain of filters. - /// - /// - /// Recursively calls in to itself as its continuation for the next filter in the chain. - /// - private static async Task ExecuteAuthorizationFiltersAsync( - AuthorizationFilterContext authorizationFilterContext, - IList authorizationFilters, - IList asyncAuthorizationFilters) + IList filters = actionDescriptor.FilterDescriptors; + var filterGrouping = new FilterGrouping(filters, controllerContext.HttpContext.RequestServices); + + // because the continuation gets built from the inside out we need to reverse the filter list + // so that least specific filters (Global) get run first and the most specific filters (Action) get run last. + var authorizationFilters = filterGrouping.AuthorizationFilters.Reverse().ToList(); + var asyncAuthorizationFilters = filterGrouping.AsyncAuthorizationFilters.Reverse().ToList(); + + if (authorizationFilters.Count == 0 && asyncAuthorizationFilters.Count == 0) { - - foreach (var authorizationFilter in authorizationFilters) - { - authorizationFilter.OnAuthorization(authorizationFilterContext); - if (!(authorizationFilterContext.Result is null)) - { - return false; - } - - } - - foreach (var asyncAuthorizationFilter in asyncAuthorizationFilters) - { - await asyncAuthorizationFilter.OnAuthorizationAsync(authorizationFilterContext); - if (!(authorizationFilterContext.Result is null)) - { - return false; - } - } - return true; } - /// - /// Quickly split filters into different types - /// - private class FilterGrouping + // if the authorization filter returns a result, it means it failed to authorize + var authorizationFilterContext = + new AuthorizationFilterContext(actionContext, filters.Select(x => x.Filter).ToArray()); + return await ExecuteAuthorizationFiltersAsync(authorizationFilterContext, authorizationFilters, asyncAuthorizationFilters); + } + + /// + /// Executes a chain of filters. + /// + /// + /// Recursively calls in to itself as its continuation for the next filter in the chain. + /// + private static async Task ExecuteAuthorizationFiltersAsync( + AuthorizationFilterContext authorizationFilterContext, + IList authorizationFilters, + IList asyncAuthorizationFilters) + { + foreach (IAuthorizationFilter authorizationFilter in authorizationFilters) { - private readonly List _actionFilters = new List(); - private readonly List _asyncActionFilters = new List(); - private readonly List _authorizationFilters = new List(); - private readonly List _asyncAuthorizationFilters = new List(); - private readonly List _exceptionFilters = new List(); - private readonly List _asyncExceptionFilters = new List(); - - public FilterGrouping(IEnumerable filters, IServiceProvider serviceProvider) + authorizationFilter.OnAuthorization(authorizationFilterContext); + if (!(authorizationFilterContext.Result is null)) { - if (filters == null) throw new ArgumentNullException("filters"); + return false; + } + } - foreach (FilterDescriptor f in filters) - { - var filter = f.Filter; - Categorize(filter, _actionFilters, serviceProvider); - Categorize(filter, _authorizationFilters, serviceProvider); - Categorize(filter, _exceptionFilters, serviceProvider); - Categorize(filter, _asyncActionFilters, serviceProvider); - Categorize(filter, _asyncAuthorizationFilters, serviceProvider); - } + foreach (IAsyncAuthorizationFilter asyncAuthorizationFilter in asyncAuthorizationFilters) + { + await asyncAuthorizationFilter.OnAuthorizationAsync(authorizationFilterContext); + if (!(authorizationFilterContext.Result is null)) + { + return false; + } + } + + return true; + } + + /// + /// Quickly split filters into different types + /// + private class FilterGrouping + { + private readonly List _actionFilters = new(); + private readonly List _asyncActionFilters = new(); + private readonly List _asyncAuthorizationFilters = new(); + private readonly List _asyncExceptionFilters = new(); + private readonly List _authorizationFilters = new(); + private readonly List _exceptionFilters = new(); + + public FilterGrouping(IEnumerable filters, IServiceProvider serviceProvider) + { + if (filters == null) + { + throw new ArgumentNullException("filters"); } - public IEnumerable ActionFilters => _actionFilters; - public IEnumerable AsyncActionFilters => _asyncActionFilters; - public IEnumerable AuthorizationFilters => _authorizationFilters; - - public IEnumerable AsyncAuthorizationFilters => _asyncAuthorizationFilters; - - public IEnumerable ExceptionFilters => _exceptionFilters; - - public IEnumerable AsyncExceptionFilters => _asyncExceptionFilters; - - private static void Categorize(IFilterMetadata filter, List list, IServiceProvider serviceProvider) where T : class + foreach (FilterDescriptor f in filters) { - if(filter is TypeFilterAttribute typeFilterAttribute) - { - filter = typeFilterAttribute.CreateInstance(serviceProvider); - } + IFilterMetadata filter = f.Filter; + Categorize(filter, _actionFilters, serviceProvider); + Categorize(filter, _authorizationFilters, serviceProvider); + Categorize(filter, _exceptionFilters, serviceProvider); + Categorize(filter, _asyncActionFilters, serviceProvider); + Categorize(filter, _asyncAuthorizationFilters, serviceProvider); + } + } - T? match = filter as T; - if (match != null) - { - list.Add(match); - } + public IEnumerable ActionFilters => _actionFilters; + public IEnumerable AsyncActionFilters => _asyncActionFilters; + public IEnumerable AuthorizationFilters => _authorizationFilters; + + public IEnumerable AsyncAuthorizationFilters => _asyncAuthorizationFilters; + + public IEnumerable ExceptionFilters => _exceptionFilters; + + public IEnumerable AsyncExceptionFilters => _asyncExceptionFilters; + + private static void Categorize(IFilterMetadata filter, List list, IServiceProvider serviceProvider) + where T : class + { + if (filter is TypeFilterAttribute typeFilterAttribute) + { + filter = typeFilterAttribute.CreateInstance(serviceProvider); + } + + if (filter is T match) + { + list.Add(match); } } } diff --git a/src/Umbraco.Web.BackOffice/Extensions/HtmlHelperBackOfficeExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/HtmlHelperBackOfficeExtensions.cs index be83725b3e..dbe5b0f7b0 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/HtmlHelperBackOfficeExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/HtmlHelperBackOfficeExtensions.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.Rendering; using Newtonsoft.Json; @@ -11,131 +8,138 @@ using Umbraco.Cms.Infrastructure.WebAssets; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.BackOffice.Security; -namespace Umbraco.Extensions -{ - public static class HtmlHelperBackOfficeExtensions - { - /// - /// Outputs a script tag containing the bare minimum (non secure) server vars for use with the angular app - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// These are the bare minimal server variables that are required for the application to start without being authenticated, - /// we will load the rest of the server vars after the user is authenticated. - /// - public static async Task BareMinimumServerVariablesScriptAsync(this IHtmlHelper html, BackOfficeServerVariables backOfficeServerVariables) - { - var minVars = await backOfficeServerVariables.BareMinimumServerVariablesAsync(); +namespace Umbraco.Extensions; - var str = @""; - return html.Raw(str); - } + return html.Raw(str); + } - /// - /// Used to render the script that will pass in the angular "externalLoginInfo" service/value on page load - /// - /// - /// - /// - public static async Task AngularValueExternalLoginInfoScriptAsync(this IHtmlHelper html, - IBackOfficeExternalLoginProviders externalLogins, - BackOfficeExternalLoginProviderErrors externalLoginErrors) - { - var providers = await externalLogins.GetBackOfficeProvidersAsync(); + /// + /// Used to render the script that will pass in the angular "externalLoginInfo" service/value on page load + /// + /// + /// + /// + public static async Task AngularValueExternalLoginInfoScriptAsync(this IHtmlHelper html, + IBackOfficeExternalLoginProviders externalLogins, + BackOfficeExternalLoginProviderErrors externalLoginErrors) + { + IEnumerable + providers = await externalLogins.GetBackOfficeProvidersAsync(); - var loginProviders = providers - .Select(p => new - { - authType = p.ExternalLoginProvider.AuthenticationType, - caption = p.AuthenticationScheme.DisplayName, - properties = p.ExternalLoginProvider.Options - }) - .ToArray(); - - var sb = new StringBuilder(); - sb.AppendLine(); - sb.AppendLine(@"var errors = [];"); - - if (externalLoginErrors != null) + var loginProviders = providers + .Select(p => new { - if (externalLoginErrors.Errors is not null) + authType = p.ExternalLoginProvider.AuthenticationType, + caption = p.AuthenticationScheme.DisplayName, + properties = p.ExternalLoginProvider.Options + }) + .ToArray(); + + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine(@"var errors = [];"); + + if (externalLoginErrors != null) + { + if (externalLoginErrors.Errors is not null) + { + foreach (var error in externalLoginErrors.Errors) { - foreach (var error in externalLoginErrors.Errors) - { - sb.AppendFormat(@"errors.push(""{0}"");", error.ToSingleLine()).AppendLine(); - } + sb.AppendFormat(@"errors.push(""{0}"");", error.ToSingleLine()).AppendLine(); } } - - sb.AppendLine(@"app.value(""externalLoginInfo"", {"); - if (externalLoginErrors?.AuthenticationType != null) - sb.AppendLine($@"errorProvider: '{externalLoginErrors.AuthenticationType}',"); - sb.AppendLine(@"errors: errors,"); - sb.Append(@"providers: "); - sb.AppendLine(JsonConvert.SerializeObject(loginProviders)); - sb.AppendLine(@"});"); - - return html.Raw(sb.ToString()); } - /// - /// Used to render the script that will pass in the angular "resetPasswordCodeInfo" service/value on page load - /// - /// - /// - /// - public static IHtmlContent AngularValueResetPasswordCodeInfoScript(this IHtmlHelper html, object val) + sb.AppendLine(@"app.value(""externalLoginInfo"", {"); + if (externalLoginErrors?.AuthenticationType != null) { - var sb = new StringBuilder(); - sb.AppendLine(); - sb.AppendLine(@"var errors = [];"); + sb.AppendLine($@"errorProvider: '{externalLoginErrors.AuthenticationType}',"); + } - if (val is IEnumerable errors) + sb.AppendLine(@"errors: errors,"); + sb.Append(@"providers: "); + sb.AppendLine(JsonConvert.SerializeObject(loginProviders)); + sb.AppendLine(@"});"); + + return html.Raw(sb.ToString()); + } + + /// + /// Used to render the script that will pass in the angular "resetPasswordCodeInfo" service/value on page load + /// + /// + /// + /// + public static IHtmlContent AngularValueResetPasswordCodeInfoScript(this IHtmlHelper html, object val) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine(@"var errors = [];"); + + if (val is IEnumerable errors) + { + foreach (var error in errors) { - foreach (var error in errors) - { - sb.AppendFormat(@"errors.push(""{0}"");", error).AppendLine(); - } + sb.AppendFormat(@"errors.push(""{0}"");", error).AppendLine(); } - - sb.AppendLine(@"app.value(""resetPasswordCodeInfo"", {"); - sb.AppendLine(@"errors: errors,"); - sb.Append(@"resetCodeModel: "); - sb.AppendLine(val?.ToString() ?? "null"); - sb.AppendLine(@"});"); - - return html.Raw(sb.ToString()); } - public static async Task AngularValueTinyMceAssetsAsync(this IHtmlHelper html, IRuntimeMinifier runtimeMinifier) - { - var files = await runtimeMinifier.GetJsAssetPathsAsync(BackOfficeWebAssets.UmbracoTinyMceJsBundleName); + sb.AppendLine(@"app.value(""resetPasswordCodeInfo"", {"); + sb.AppendLine(@"errors: errors,"); + sb.Append(@"resetCodeModel: "); + sb.AppendLine(val?.ToString() ?? "null"); + sb.AppendLine(@"});"); - var sb = new StringBuilder(); + return html.Raw(sb.ToString()); + } - sb.AppendLine(@"app.value(""tinyMceAssets"","); - sb.AppendLine(JsonConvert.SerializeObject(files)); - sb.AppendLine(@");"); + public static async Task AngularValueTinyMceAssetsAsync(this IHtmlHelper html, + IRuntimeMinifier runtimeMinifier) + { + IEnumerable files = + await runtimeMinifier.GetJsAssetPathsAsync(BackOfficeWebAssets.UmbracoTinyMceJsBundleName); + + var sb = new StringBuilder(); + + sb.AppendLine(@"app.value(""tinyMceAssets"","); + sb.AppendLine(JsonConvert.SerializeObject(files)); + sb.AppendLine(@");"); - return html.Raw(sb.ToString()); - } + return html.Raw(sb.ToString()); } } diff --git a/src/Umbraco.Web.BackOffice/Extensions/HttpContextExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/HttpContextExtensions.cs index 0a091bcacb..38a681917a 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/HttpContextExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/HttpContextExtensions.cs @@ -1,17 +1,13 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Umbraco.Cms.Core.Security; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +public static class HttpContextExtensions { - public static class HttpContextExtensions - { - public static void SetExternalLoginProviderErrors(this HttpContext httpContext, BackOfficeExternalLoginProviderErrors errors) - => httpContext.Items[nameof(BackOfficeExternalLoginProviderErrors)] = errors; + public static void SetExternalLoginProviderErrors(this HttpContext httpContext, BackOfficeExternalLoginProviderErrors errors) + => httpContext.Items[nameof(BackOfficeExternalLoginProviderErrors)] = errors; - public static BackOfficeExternalLoginProviderErrors? GetExternalLoginProviderErrors(this HttpContext httpContext) - => httpContext.Items[nameof(BackOfficeExternalLoginProviderErrors)] as BackOfficeExternalLoginProviderErrors; - - } + public static BackOfficeExternalLoginProviderErrors? GetExternalLoginProviderErrors(this HttpContext httpContext) + => httpContext.Items[nameof(BackOfficeExternalLoginProviderErrors)] as BackOfficeExternalLoginProviderErrors; } diff --git a/src/Umbraco.Web.BackOffice/Extensions/IdentityBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/IdentityBuilderExtensions.cs index 676a681dc8..a7cce8f9b5 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/IdentityBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/IdentityBuilderExtensions.cs @@ -2,41 +2,40 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Security; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +/// +/// Extension methods for +/// +public static class IdentityBuilderExtensions { /// - /// Extension methods for + /// Adds a implementation for /// - public static class IdentityBuilderExtensions + /// The usermanager interface + /// The usermanager type + /// The + /// The current instance. + public static IdentityBuilder AddUserManager(this IdentityBuilder identityBuilder) + where TUserManager : UserManager, TInterface { - /// - /// Adds a implementation for - /// - /// The usermanager interface - /// The usermanager type - /// The - /// The current instance. - public static IdentityBuilder AddUserManager(this IdentityBuilder identityBuilder) - where TUserManager : UserManager, TInterface - { - identityBuilder.AddUserManager(); - identityBuilder.Services.AddScoped(typeof(TInterface), typeof(TUserManager)); - return identityBuilder; - } + identityBuilder.AddUserManager(); + identityBuilder.Services.AddScoped(typeof(TInterface), typeof(TUserManager)); + return identityBuilder; + } - /// - /// Adds a implementation for - /// - /// The sign in manager interface - /// The sign in manager type - /// The - /// The current instance. - public static IdentityBuilder AddSignInManager(this IdentityBuilder identityBuilder) - where TSignInManager : SignInManager, TInterface - { - identityBuilder.AddSignInManager(); - identityBuilder.Services.AddScoped(typeof(TInterface), typeof(TSignInManager)); - return identityBuilder; - } + /// + /// Adds a implementation for + /// + /// The sign in manager interface + /// The sign in manager type + /// The + /// The current instance. + public static IdentityBuilder AddSignInManager(this IdentityBuilder identityBuilder) + where TSignInManager : SignInManager, TInterface + { + identityBuilder.AddSignInManager(); + identityBuilder.Services.AddScoped(typeof(TInterface), typeof(TSignInManager)); + return identityBuilder; } } diff --git a/src/Umbraco.Web.BackOffice/Extensions/LinkGeneratorExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/LinkGeneratorExtensions.cs index 1644ec6f21..8b528737bc 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/LinkGeneratorExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/LinkGeneratorExtensions.cs @@ -1,25 +1,23 @@ -using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing; +using Umbraco.Cms.Core; using Umbraco.Cms.Web.BackOffice.Install; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +public static class BackofficeLinkGeneratorExtensions { - public static class BackofficeLinkGeneratorExtensions - { - /// - /// Returns the URL for the installer - /// - public static string? GetInstallerUrl(this LinkGenerator linkGenerator) - => linkGenerator.GetPathByAction(nameof(InstallController.Index), ControllerExtensions.GetControllerName(), new { area = Cms.Core.Constants.Web.Mvc.InstallArea }); + /// + /// Returns the URL for the installer + /// + public static string? GetInstallerUrl(this LinkGenerator linkGenerator) + => linkGenerator.GetPathByAction(nameof(InstallController.Index), ControllerExtensions.GetControllerName(), new { area = Constants.Web.Mvc.InstallArea }); - /// - /// Returns the URL for the installer api - /// - public static string? GetInstallerApiUrl(this LinkGenerator linkGenerator) - => linkGenerator.GetPathByAction( - nameof(InstallApiController.GetSetup), - ControllerExtensions.GetControllerName(), - new { area = Cms.Core.Constants.Web.Mvc.InstallArea })?.TrimEnd(nameof(InstallApiController.GetSetup)); - - - } + /// + /// Returns the URL for the installer api + /// + public static string? GetInstallerApiUrl(this LinkGenerator linkGenerator) + => linkGenerator.GetPathByAction( + nameof(InstallApiController.GetSetup), + ControllerExtensions.GetControllerName(), + new { area = Constants.Web.Mvc.InstallArea })?.TrimEnd(nameof(InstallApiController.GetSetup)); } diff --git a/src/Umbraco.Web.BackOffice/Extensions/ModelStateExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/ModelStateExtensions.cs index 514af6a41b..915d94a68b 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/ModelStateExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/ModelStateExtensions.cs @@ -1,168 +1,169 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Umbraco.Cms.Web.BackOffice.PropertyEditors.Validation; -using Umbraco.Extensions; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +public static class ModelStateExtensions { - public static class ModelStateExtensions + /// + /// Adds the to the model state with the appropriate keys for property errors + /// + /// + /// + /// + /// + /// + internal static void AddPropertyValidationError(this ModelStateDictionary modelState, + ValidationResult result, + string propertyAlias, + string culture = "", + string segment = "") => + modelState.AddValidationError( + result, + "_Properties", + propertyAlias, + //if the culture is null, we'll add the term 'invariant' as part of the key + culture.IsNullOrWhiteSpace() ? "invariant" : culture, + // if the segment is null, we'll add the term 'null' as part of the key + segment.IsNullOrWhiteSpace() ? "null" : segment); + + /// + /// Adds an error to model state for a property so we can use it on the + /// client side. + /// + /// + /// + /// + /// The culture for the property, if the property is invariant than this is empty + internal static void AddPropertyError(this ModelStateDictionary modelState, + ValidationResult result, string propertyAlias, string culture = "", string segment = "") => + modelState.AddPropertyValidationError(new ContentPropertyValidationResult(result, culture, segment), + propertyAlias, culture, segment); + + /// + /// Adds a generic culture error for use in displaying the culture validation error in the save/publish/etc... dialogs + /// + /// + /// + /// + /// + internal static void AddVariantValidationError(this ModelStateDictionary modelState, + string? culture, string? segment, string errMsg) { - - - /// - /// Adds the to the model state with the appropriate keys for property errors - /// - /// - /// - /// - /// - /// - internal static void AddPropertyValidationError(this ModelStateDictionary modelState, - ValidationResult result, - string propertyAlias, - string culture = "", - string segment = "") => - modelState.AddValidationError( - result, - "_Properties", - propertyAlias, - //if the culture is null, we'll add the term 'invariant' as part of the key - culture.IsNullOrWhiteSpace() ? "invariant" : culture, - // if the segment is null, we'll add the term 'null' as part of the key - segment.IsNullOrWhiteSpace() ? "null" : segment); - - /// - /// Adds an error to model state for a property so we can use it on the client side. - /// - /// - /// - /// - /// The culture for the property, if the property is invariant than this is empty - internal static void AddPropertyError(this ModelStateDictionary modelState, - ValidationResult result, string propertyAlias, string culture = "", string segment = "") => - modelState.AddPropertyValidationError(new ContentPropertyValidationResult(result, culture, segment), propertyAlias, culture, segment); - - /// - /// Adds a generic culture error for use in displaying the culture validation error in the save/publish/etc... dialogs - /// - /// - /// - /// - /// - internal static void AddVariantValidationError(this ModelStateDictionary modelState, - string? culture, string? segment, string errMsg) + var key = "_content_variant_" + (culture.IsNullOrWhiteSpace() ? "invariant" : culture) + "_" + + (segment.IsNullOrWhiteSpace() ? "null" : segment) + "_"; + if (modelState.ContainsKey(key)) { - var key = "_content_variant_" + (culture.IsNullOrWhiteSpace() ? "invariant" : culture) + "_" + (segment.IsNullOrWhiteSpace() ? "null" : segment) + "_"; - if (modelState.ContainsKey(key)) - { - return; - } - - modelState.AddModelError(key, errMsg); + return; } - /// - /// Returns a list of cultures that have property validation errors - /// - /// - /// - /// The culture to affiliate invariant errors with - /// - /// A list of cultures that have property validation errors. The default culture will be returned for any invariant property errors. - /// - internal static IReadOnlyList<(string? culture, string? segment)>? GetVariantsWithPropertyErrors(this ModelStateDictionary modelState, - string? cultureForInvariantErrors) - { - //Add any variant specific errors here - var variantErrors = modelState.Keys - .Where(key => key.StartsWith("_Properties.")) //only choose _Properties errors - .Select(x => x.Split('.')) //split into parts - .Where(x => x.Length >= 4 && !x[2].IsNullOrWhiteSpace() && !x[3].IsNullOrWhiteSpace()) - .Select(x => (culture: x[2], segment: x[3])) - //if the culture is marked "invariant" than return the default language, this is because we can only edit invariant properties on the default language - //so errors for those must show up under the default lang. - //if the segment is marked "null" then return an actual null - .Select(x => - { - var culture = x.culture == "invariant" ? cultureForInvariantErrors : x.culture; - var segment = x.segment == "null" ? null : x.segment; - return (culture, segment); - }) - .Distinct() - .ToList(); + modelState.AddModelError(key, errMsg); + } - return variantErrors; + /// + /// Returns a list of cultures that have property validation errors + /// + /// + /// + /// The culture to affiliate invariant errors with + /// + /// A list of cultures that have property validation errors. The default culture will be returned for any invariant + /// property errors. + /// + internal static IReadOnlyList<(string? culture, string? segment)>? GetVariantsWithPropertyErrors( + this ModelStateDictionary modelState, + string? cultureForInvariantErrors) + { + //Add any variant specific errors here + var variantErrors = modelState.Keys + .Where(key => key.StartsWith("_Properties.")) //only choose _Properties errors + .Select(x => x.Split('.')) //split into parts + .Where(x => x.Length >= 4 && !x[2].IsNullOrWhiteSpace() && !x[3].IsNullOrWhiteSpace()) + .Select(x => (culture: x[2], segment: x[3])) + //if the culture is marked "invariant" than return the default language, this is because we can only edit invariant properties on the default language + //so errors for those must show up under the default lang. + //if the segment is marked "null" then return an actual null + .Select(x => + { + var culture = x.culture == "invariant" ? cultureForInvariantErrors : x.culture; + var segment = x.segment == "null" ? null : x.segment; + return (culture, segment); + }) + .Distinct() + .ToList(); + + return variantErrors; + } + + /// + /// Returns a list of cultures that have any validation errors + /// + /// + /// + /// The culture to affiliate invariant errors with + /// + /// A list of cultures that have validation errors. The default culture will be returned for any invariant errors. + /// + internal static IReadOnlyList<(string? culture, string? segment)>? GetVariantsWithErrors( + this ModelStateDictionary modelState, string? cultureForInvariantErrors) + { + IReadOnlyList<(string? culture, string? segment)>? propertyVariantErrors = + modelState.GetVariantsWithPropertyErrors(cultureForInvariantErrors); + + //now check the other special variant errors that are + IEnumerable<(string? culture, string? segment)>? genericVariantErrors = modelState.Keys + .Where(x => x.StartsWith("_content_variant_") && x.EndsWith("_")) + .Select(x => x.TrimStart("_content_variant_").TrimEnd("_")) + .Select(x => + { + // Format "_" + var cs = x.Split(new[] { '_' }); + return (culture: cs[0], segment: cs[1]); + }) + .Where(x => !x.culture.IsNullOrWhiteSpace()) + //if it's marked "invariant" than return the default language, this is because we can only edit invariant properties on the default language + //so errors for those must show up under the default lang. + //if the segment is marked "null" then return an actual null + .Select(x => + { + var culture = x.culture == "invariant" ? cultureForInvariantErrors : x.culture; + var segment = x.segment == "null" ? null : x.segment; + return (culture, segment); + }) + .Distinct(); + + return propertyVariantErrors?.Union(genericVariantErrors).Distinct().ToList(); + } + + /// + /// Adds the error to model state correctly for a property so we can use it on the client side. + /// + /// + /// + /// + /// Each model state validation error has a name and in most cases this name is made up of parts which are delimited by + /// a '.' + /// + internal static void AddValidationError(this ModelStateDictionary modelState, + ValidationResult result, params string[] parts) + { + // if there are assigned member names, we combine the member name with the owner name + // so that we can try to match it up to a real field. otherwise, we assume that the + // validation message is for the overall owner. + // Owner = the component being validated, like a content property but could be just an HTML field on another editor + + var withNames = false; + var delimitedParts = string.Join(".", parts); + foreach (var memberName in result.MemberNames) + { + modelState.TryAddModelError($"{delimitedParts}.{memberName}", result.ErrorMessage ?? string.Empty); + withNames = true; } - /// - /// Returns a list of cultures that have any validation errors - /// - /// - /// - /// The culture to affiliate invariant errors with - /// - /// A list of cultures that have validation errors. The default culture will be returned for any invariant errors. - /// - internal static IReadOnlyList<(string? culture, string? segment)>? GetVariantsWithErrors(this ModelStateDictionary modelState, string? cultureForInvariantErrors) + if (!withNames) { - IReadOnlyList<(string? culture, string? segment)>? propertyVariantErrors = modelState.GetVariantsWithPropertyErrors(cultureForInvariantErrors); - - //now check the other special variant errors that are - IEnumerable<(string? culture, string? segment)>? genericVariantErrors = modelState.Keys - .Where(x => x.StartsWith("_content_variant_") && x.EndsWith("_")) - .Select(x => x.TrimStart("_content_variant_").TrimEnd("_")) - .Select(x => - { - // Format "_" - var cs = x.Split(new[] { '_' }); - return (culture: cs[0], segment: cs[1]); - }) - .Where(x => !x.culture.IsNullOrWhiteSpace()) - //if it's marked "invariant" than return the default language, this is because we can only edit invariant properties on the default language - //so errors for those must show up under the default lang. - //if the segment is marked "null" then return an actual null - .Select(x => - { - var culture = x.culture == "invariant" ? cultureForInvariantErrors : x.culture; - var segment = x.segment == "null" ? null : x.segment; - return (culture, segment); - }) - .Distinct(); - - return propertyVariantErrors?.Union(genericVariantErrors).Distinct().ToList(); - } - - /// - /// Adds the error to model state correctly for a property so we can use it on the client side. - /// - /// - /// - /// - /// Each model state validation error has a name and in most cases this name is made up of parts which are delimited by a '.' - /// - internal static void AddValidationError(this ModelStateDictionary modelState, - ValidationResult result, params string[] parts) - { - // if there are assigned member names, we combine the member name with the owner name - // so that we can try to match it up to a real field. otherwise, we assume that the - // validation message is for the overall owner. - // Owner = the component being validated, like a content property but could be just an HTML field on another editor - - var withNames = false; - var delimitedParts = string.Join(".", parts); - foreach (var memberName in result.MemberNames) - { - modelState.TryAddModelError($"{delimitedParts}.{memberName}", result.ErrorMessage ?? string.Empty); - withNames = true; - } - if (!withNames) - { - modelState.TryAddModelError($"{delimitedParts}", result.ToString()); - } - + modelState.TryAddModelError($"{delimitedParts}", result.ToString()); } } } diff --git a/src/Umbraco.Web.BackOffice/Extensions/RuntimeMinifierExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/RuntimeMinifierExtensions.cs index b76d2dbfc6..d00d0285e7 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/RuntimeMinifierExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/RuntimeMinifierExtensions.cs @@ -1,110 +1,108 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Manifest; using Umbraco.Cms.Core.WebAssets; using Umbraco.Cms.Infrastructure.WebAssets; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +public static class RuntimeMinifierExtensions { - public static class RuntimeMinifierExtensions + /// + /// Returns the JavaScript to load the back office's assets + /// + /// + public static async Task GetScriptForLoadingBackOfficeAsync( + this IRuntimeMinifier minifier, + GlobalSettings globalSettings, + IHostingEnvironment hostingEnvironment, + IManifestParser manifestParser) { - /// - /// Returns the JavaScript to load the back office's assets - /// - /// - public static async Task GetScriptForLoadingBackOfficeAsync( - this IRuntimeMinifier minifier, - GlobalSettings globalSettings, - IHostingEnvironment hostingEnvironment, - IManifestParser manifestParser) + var files = new HashSet(StringComparer.InvariantCultureIgnoreCase); + foreach (var file in await minifier.GetJsAssetPathsAsync(BackOfficeWebAssets.UmbracoCoreJsBundleName)) { - var files = new HashSet(StringComparer.InvariantCultureIgnoreCase); - foreach(var file in await minifier.GetJsAssetPathsAsync(BackOfficeWebAssets.UmbracoCoreJsBundleName)) - { - files.Add(file); - } - - foreach (var file in await minifier.GetJsAssetPathsAsync(BackOfficeWebAssets.UmbracoExtensionsJsBundleName)) - { - files.Add(file); - } - - // process the independent bundles - if (manifestParser.CombinedManifest.Scripts.TryGetValue(BundleOptions.Independent, out IReadOnlyList? independentManifestAssetsList)) - { - foreach (ManifestAssets manifestAssets in independentManifestAssetsList) - { - var bundleName = BackOfficeWebAssets.GetIndependentPackageBundleName(manifestAssets, AssetType.Javascript); - foreach(var asset in await minifier.GetJsAssetPathsAsync(bundleName)) - { - files.Add(asset); - } - } - } - - // process the "None" bundles, meaning we'll just render the script as-is - foreach (var asset in await minifier.GetJsAssetPathsAsync(BackOfficeWebAssets.UmbracoNonOptimizedPackageJsBundleName)) - { - files.Add(asset); - } - - var result = BackOfficeJavaScriptInitializer.GetJavascriptInitialization( - files, - "umbraco", - globalSettings, - hostingEnvironment); - - result += await GetStylesheetInitializationAsync(minifier, manifestParser); - - return result; + files.Add(file); } - /// - /// Gets the back office css bundle paths and formats a JS call to lazy load them - /// - private static async Task GetStylesheetInitializationAsync( - IRuntimeMinifier minifier, - IManifestParser manifestParser) + foreach (var file in await minifier.GetJsAssetPathsAsync(BackOfficeWebAssets.UmbracoExtensionsJsBundleName)) { - var files = new HashSet(StringComparer.InvariantCultureIgnoreCase); - foreach(var file in await minifier.GetCssAssetPathsAsync(BackOfficeWebAssets.UmbracoCssBundleName)) - { - files.Add(file); - } - - // process the independent bundles - if (manifestParser.CombinedManifest.Stylesheets.TryGetValue(BundleOptions.Independent, out IReadOnlyList? independentManifestAssetsList)) - { - foreach (ManifestAssets manifestAssets in independentManifestAssetsList) - { - var bundleName = BackOfficeWebAssets.GetIndependentPackageBundleName(manifestAssets, AssetType.Css); - foreach (var asset in await minifier.GetCssAssetPathsAsync(bundleName)) - { - files.Add(asset); - } - } - } - - // process the "None" bundles, meaning we'll just render the script as-is - foreach (var asset in await minifier.GetCssAssetPathsAsync(BackOfficeWebAssets.UmbracoNonOptimizedPackageCssBundleName)) - { - files.Add(asset); - } - - var sb = new StringBuilder(); - foreach (string file in files) - { - sb.AppendFormat("{0}LazyLoad.css('{1}');", Environment.NewLine, file); - } - - return sb.ToString(); + files.Add(file); } + // process the independent bundles + if (manifestParser.CombinedManifest.Scripts.TryGetValue(BundleOptions.Independent, + out IReadOnlyList? independentManifestAssetsList)) + { + foreach (ManifestAssets manifestAssets in independentManifestAssetsList) + { + var bundleName = + BackOfficeWebAssets.GetIndependentPackageBundleName(manifestAssets, AssetType.Javascript); + foreach (var asset in await minifier.GetJsAssetPathsAsync(bundleName)) + { + files.Add(asset); + } + } + } + + // process the "None" bundles, meaning we'll just render the script as-is + foreach (var asset in await minifier.GetJsAssetPathsAsync(BackOfficeWebAssets + .UmbracoNonOptimizedPackageJsBundleName)) + { + files.Add(asset); + } + + var result = BackOfficeJavaScriptInitializer.GetJavascriptInitialization( + files, + "umbraco", + globalSettings, + hostingEnvironment); + + result += await GetStylesheetInitializationAsync(minifier, manifestParser); + + return result; + } + + /// + /// Gets the back office css bundle paths and formats a JS call to lazy load them + /// + private static async Task GetStylesheetInitializationAsync( + IRuntimeMinifier minifier, + IManifestParser manifestParser) + { + var files = new HashSet(StringComparer.InvariantCultureIgnoreCase); + foreach (var file in await minifier.GetCssAssetPathsAsync(BackOfficeWebAssets.UmbracoCssBundleName)) + { + files.Add(file); + } + + // process the independent bundles + if (manifestParser.CombinedManifest.Stylesheets.TryGetValue(BundleOptions.Independent, + out IReadOnlyList? independentManifestAssetsList)) + { + foreach (ManifestAssets manifestAssets in independentManifestAssetsList) + { + var bundleName = BackOfficeWebAssets.GetIndependentPackageBundleName(manifestAssets, AssetType.Css); + foreach (var asset in await minifier.GetCssAssetPathsAsync(bundleName)) + { + files.Add(asset); + } + } + } + + // process the "None" bundles, meaning we'll just render the script as-is + foreach (var asset in await minifier.GetCssAssetPathsAsync(BackOfficeWebAssets + .UmbracoNonOptimizedPackageCssBundleName)) + { + files.Add(asset); + } + + var sb = new StringBuilder(); + foreach (var file in files) + { + sb.AppendFormat("{0}LazyLoad.css('{1}');", Environment.NewLine, file); + } + + return sb.ToString(); } } diff --git a/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.BackOffice.cs b/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.BackOffice.cs index cd041521b0..71e94a1b2d 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.BackOffice.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.BackOffice.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -8,51 +7,50 @@ using Umbraco.Cms.Web.BackOffice.Middleware; using Umbraco.Cms.Web.BackOffice.Routing; using Umbraco.Cms.Web.Common.ApplicationBuilder; +namespace Umbraco.Extensions; -namespace Umbraco.Extensions +/// +/// extensions for Umbraco +/// +public static partial class UmbracoApplicationBuilderExtensions { /// - /// extensions for Umbraco + /// Adds all required middleware to run the back office /// - public static partial class UmbracoApplicationBuilderExtensions + /// + /// + public static IUmbracoApplicationBuilderContext UseBackOffice(this IUmbracoApplicationBuilderContext builder) { - /// - /// Adds all required middleware to run the back office - /// - /// - /// - public static IUmbracoApplicationBuilderContext UseBackOffice(this IUmbracoApplicationBuilderContext builder) - { - KeepAliveSettings keepAliveSettings = builder.ApplicationServices.GetRequiredService>().Value; - IHostingEnvironment hostingEnvironment = builder.ApplicationServices.GetRequiredService(); - builder.AppBuilder.Map( - hostingEnvironment.ToAbsolute(keepAliveSettings.KeepAlivePingUrl), - a => a.UseMiddleware()); + KeepAliveSettings keepAliveSettings = + builder.ApplicationServices.GetRequiredService>().Value; + IHostingEnvironment hostingEnvironment = builder.ApplicationServices.GetRequiredService(); + builder.AppBuilder.Map( + hostingEnvironment.ToAbsolute(keepAliveSettings.KeepAlivePingUrl), + a => a.UseMiddleware()); - builder.AppBuilder.UseMiddleware(); - return builder; + builder.AppBuilder.UseMiddleware(); + return builder; + } + + public static IUmbracoEndpointBuilderContext UseBackOfficeEndpoints(this IUmbracoEndpointBuilderContext app) + { + // NOTE: This method will have been called after UseRouting, UseAuthentication, UseAuthorization + if (app == null) + { + throw new ArgumentNullException(nameof(app)); } - public static IUmbracoEndpointBuilderContext UseBackOfficeEndpoints(this IUmbracoEndpointBuilderContext app) + if (!app.RuntimeState.UmbracoCanBoot()) { - // NOTE: This method will have been called after UseRouting, UseAuthentication, UseAuthorization - if (app == null) - { - throw new ArgumentNullException(nameof(app)); - } - - if (!app.RuntimeState.UmbracoCanBoot()) - { - return app; - } - - BackOfficeAreaRoutes backOfficeRoutes = app.ApplicationServices.GetRequiredService(); - backOfficeRoutes.CreateRoutes(app.EndpointRouteBuilder); - - app.UseUmbracoRuntimeMinificationEndpoints(); - app.UseUmbracoPreviewEndpoints(); - return app; } + + BackOfficeAreaRoutes backOfficeRoutes = app.ApplicationServices.GetRequiredService(); + backOfficeRoutes.CreateRoutes(app.EndpointRouteBuilder); + + app.UseUmbracoRuntimeMinificationEndpoints(); + app.UseUmbracoPreviewEndpoints(); + + return app; } } diff --git a/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.Installer.cs b/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.Installer.cs index d61b955efc..e4241f9a96 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.Installer.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.Installer.cs @@ -3,27 +3,26 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Web.BackOffice.Install; using Umbraco.Cms.Web.Common.ApplicationBuilder; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +/// +/// extensions for Umbraco installer +/// +public static partial class UmbracoApplicationBuilderExtensions { /// - /// extensions for Umbraco installer + /// Enables the Umbraco installer /// - public static partial class UmbracoApplicationBuilderExtensions + public static IUmbracoEndpointBuilderContext UseInstallerEndpoints(this IUmbracoEndpointBuilderContext app) { - /// - /// Enables the Umbraco installer - /// - public static IUmbracoEndpointBuilderContext UseInstallerEndpoints(this IUmbracoEndpointBuilderContext app) + if (!app.RuntimeState.UmbracoCanBoot()) { - if (!app.RuntimeState.UmbracoCanBoot()) - { - return app; - } - - InstallAreaRoutes installerRoutes = app.ApplicationServices.GetRequiredService(); - installerRoutes.CreateRoutes(app.EndpointRouteBuilder); - return app; } + + InstallAreaRoutes installerRoutes = app.ApplicationServices.GetRequiredService(); + installerRoutes.CreateRoutes(app.EndpointRouteBuilder); + + return app; } } diff --git a/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.Preview.cs b/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.Preview.cs index 012205575a..18e1a877aa 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.Preview.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/UmbracoApplicationBuilder.Preview.cs @@ -1,21 +1,19 @@ -using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Web.BackOffice.Routing; using Umbraco.Cms.Web.Common.ApplicationBuilder; -namespace Umbraco.Extensions -{ - /// - /// extensions for Umbraco - /// - public static partial class UmbracoApplicationBuilderExtensions - { - public static IUmbracoEndpointBuilderContext UseUmbracoPreviewEndpoints(this IUmbracoEndpointBuilderContext app) - { - PreviewRoutes previewRoutes = app.ApplicationServices.GetRequiredService(); - previewRoutes.CreateRoutes(app.EndpointRouteBuilder); +namespace Umbraco.Extensions; - return app; - } +/// +/// extensions for Umbraco +/// +public static partial class UmbracoApplicationBuilderExtensions +{ + public static IUmbracoEndpointBuilderContext UseUmbracoPreviewEndpoints(this IUmbracoEndpointBuilderContext app) + { + PreviewRoutes previewRoutes = app.ApplicationServices.GetRequiredService(); + previewRoutes.CreateRoutes(app.EndpointRouteBuilder); + + return app; } } diff --git a/src/Umbraco.Web.BackOffice/Extensions/WebMappingProfiles.cs b/src/Umbraco.Web.BackOffice/Extensions/WebMappingProfiles.cs index efc066ea32..7597feab69 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/WebMappingProfiles.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/WebMappingProfiles.cs @@ -3,20 +3,19 @@ using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Web.BackOffice.Mapping; -namespace Umbraco.Extensions +namespace Umbraco.Extensions; + +public static class WebMappingProfiles { - public static class WebMappingProfiles + public static IUmbracoBuilder AddWebMappingProfiles(this IUmbracoBuilder builder) { - public static IUmbracoBuilder AddWebMappingProfiles(this IUmbracoBuilder builder) - { - builder.WithCollectionBuilder() - .Add() - .Add() - .Add(); + builder.WithCollectionBuilder() + .Add() + .Add() + .Add(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); - return builder; - } + return builder; } } diff --git a/src/Umbraco.Web.BackOffice/Filters/AppendCurrentEventMessagesAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/AppendCurrentEventMessagesAttribute.cs index 67dab8dcb7..a9d4485acc 100644 --- a/src/Umbraco.Web.BackOffice/Filters/AppendCurrentEventMessagesAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/AppendCurrentEventMessagesAttribute.cs @@ -1,89 +1,101 @@ -using System; -using System.Net.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Web; -namespace Umbraco.Cms.Web.BackOffice.Filters -{ +namespace Umbraco.Cms.Web.BackOffice.Filters; - /// - /// Automatically checks if any request is a non-GET and if the - /// resulting message is INotificationModel in which case it will append any Event Messages - /// currently in the request. - /// - internal sealed class AppendCurrentEventMessagesAttribute : TypeFilterAttribute +/// +/// Automatically checks if any request is a non-GET and if the +/// resulting message is INotificationModel in which case it will append any Event Messages +/// currently in the request. +/// +internal sealed class AppendCurrentEventMessagesAttribute : TypeFilterAttribute +{ + public AppendCurrentEventMessagesAttribute() : base(typeof(AppendCurrentEventMessagesFilter)) { - public AppendCurrentEventMessagesAttribute() : base(typeof(AppendCurrentEventMessagesFilter)) + } + + private class AppendCurrentEventMessagesFilter : IActionFilter + { + private readonly IEventMessagesFactory _eventMessagesFactory; + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + + public AppendCurrentEventMessagesFilter(IUmbracoContextAccessor umbracoContextAccessor, IEventMessagesFactory eventMessagesFactory) { + _umbracoContextAccessor = umbracoContextAccessor; + _eventMessagesFactory = eventMessagesFactory; } - private class AppendCurrentEventMessagesFilter : IActionFilter + public void OnActionExecuted(ActionExecutedContext context) { - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly IEventMessagesFactory _eventMessagesFactory; - - public AppendCurrentEventMessagesFilter(IUmbracoContextAccessor umbracoContextAccessor, IEventMessagesFactory eventMessagesFactory) + if (context.HttpContext.Response == null) { - _umbracoContextAccessor = umbracoContextAccessor; - _eventMessagesFactory = eventMessagesFactory; + return; } - public void OnActionExecuted(ActionExecutedContext context) + if (context.HttpContext.Request.Method.Equals(HttpMethod.Get.ToString(), StringComparison.InvariantCultureIgnoreCase)) { - if (context.HttpContext.Response == null) return; - if (context.HttpContext.Request.Method.Equals(HttpMethod.Get.ToString(), StringComparison.InvariantCultureIgnoreCase)) return; - if (!_umbracoContextAccessor.TryGetUmbracoContext(out _)) + return; + } + + if (!_umbracoContextAccessor.TryGetUmbracoContext(out _)) + { + return; + } + + if (!(context.Result is ObjectResult obj)) + { + return; + } + + if (obj.Value is not INotificationModel notifications) + { + return; + } + + EventMessages? msgs = _eventMessagesFactory.GetOrDefault(); + if (msgs == null) + { + return; + } + + foreach (EventMessage eventMessage in msgs.GetAll()) + { + NotificationStyle msgType; + switch (eventMessage.MessageType) { - return; + case EventMessageType.Default: + msgType = NotificationStyle.Save; + break; + case EventMessageType.Info: + msgType = NotificationStyle.Info; + break; + case EventMessageType.Error: + msgType = NotificationStyle.Error; + break; + case EventMessageType.Success: + msgType = NotificationStyle.Success; + break; + case EventMessageType.Warning: + msgType = NotificationStyle.Warning; + break; + default: + throw new ArgumentOutOfRangeException(); } - if (!(context.Result is ObjectResult obj)) return; - - var notifications = obj.Value as INotificationModel; - if (notifications == null) return; - - var msgs = _eventMessagesFactory.GetOrDefault(); - if (msgs == null) return; - - foreach (var eventMessage in msgs.GetAll()) + notifications.Notifications?.Add(new BackOfficeNotification { - NotificationStyle msgType; - switch (eventMessage.MessageType) - { - case EventMessageType.Default: - msgType = NotificationStyle.Save; - break; - case EventMessageType.Info: - msgType = NotificationStyle.Info; - break; - case EventMessageType.Error: - msgType = NotificationStyle.Error; - break; - case EventMessageType.Success: - msgType = NotificationStyle.Success; - break; - case EventMessageType.Warning: - msgType = NotificationStyle.Warning; - break; - default: - throw new ArgumentOutOfRangeException(); - } - - notifications.Notifications?.Add(new BackOfficeNotification - { - Message = eventMessage.Message, - Header = eventMessage.Category, - NotificationType = msgType - }); - } + Message = eventMessage.Message, + Header = eventMessage.Category, + NotificationType = msgType + }); } + } - public void OnActionExecuting(ActionExecutingContext context) - { - } + public void OnActionExecuting(ActionExecutingContext context) + { } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/AppendUserModifiedHeaderAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/AppendUserModifiedHeaderAttribute.cs index ebc5d59a75..eaee8993cc 100644 --- a/src/Umbraco.Web.BackOffice/Filters/AppendUserModifiedHeaderAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/AppendUserModifiedHeaderAttribute.cs @@ -1,80 +1,79 @@ -using System; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Appends a custom response header to notify the UI that the current user data has been modified +/// +public sealed class AppendUserModifiedHeaderAttribute : ActionFilterAttribute { + private readonly string? _userIdParameter; + /// - /// Appends a custom response header to notify the UI that the current user data has been modified + /// An empty constructor which will always set the header. /// - public sealed class AppendUserModifiedHeaderAttribute : ActionFilterAttribute + public AppendUserModifiedHeaderAttribute() { - private readonly string? _userIdParameter; + } - /// - /// An empty constructor which will always set the header. - /// - public AppendUserModifiedHeaderAttribute() + /// + /// A constructor specifying the action parameter name containing the user id to match against the + /// current user and if they match the header will be appended. + /// + /// + public AppendUserModifiedHeaderAttribute(string userIdParameter) => _userIdParameter = + userIdParameter ?? throw new ArgumentNullException(nameof(userIdParameter)); + + public override void OnActionExecuting(ActionExecutingContext context) + { + if (_userIdParameter.IsNullOrWhiteSpace()) { + AppendHeader(context); } - - /// - /// A constructor specifying the action parameter name containing the user id to match against the - /// current user and if they match the header will be appended. - /// - /// - public AppendUserModifiedHeaderAttribute(string userIdParameter) + else { - _userIdParameter = userIdParameter ?? throw new ArgumentNullException(nameof(userIdParameter)); - } + if (!context.ActionArguments.ContainsKey(_userIdParameter!)) + { + throw new InvalidOperationException( + $"No argument found for the current action with the name: {_userIdParameter}"); + } - public override void OnActionExecuting(ActionExecutingContext context) - { - if (_userIdParameter.IsNullOrWhiteSpace()) + IBackOfficeSecurityAccessor? backofficeSecurityAccessor = + context.HttpContext.RequestServices.GetService(); + IUser? user = backofficeSecurityAccessor?.BackOfficeSecurity?.CurrentUser; + if (user == null) + { + return; + } + + var userId = GetUserIdFromParameter(context.ActionArguments[_userIdParameter!]); + if (userId == user.Id) { AppendHeader(context); } - else - { - if (!context.ActionArguments.ContainsKey(_userIdParameter!)) - { - throw new InvalidOperationException($"No argument found for the current action with the name: {_userIdParameter}"); - } - - var backofficeSecurityAccessor = context.HttpContext.RequestServices.GetService(); - var user = backofficeSecurityAccessor?.BackOfficeSecurity?.CurrentUser; - if (user == null) - { - return; - } - - var userId = GetUserIdFromParameter(context.ActionArguments[_userIdParameter!]); - if (userId == user.Id) - { - AppendHeader(context); - } - } - } - - public static void AppendHeader(ActionExecutingContext context) - { - const string HeaderName = "X-Umb-User-Modified"; - if (context.HttpContext.Response.Headers.ContainsKey(HeaderName) == false) - { - context.HttpContext.Response.Headers.Add(HeaderName, "1"); - } - } - - private int GetUserIdFromParameter(object? parameterValue) - { - if (parameterValue is int) - { - return (int)parameterValue; - } - - throw new InvalidOperationException($"The id type: {parameterValue?.GetType()} is not a supported id."); } } + + public static void AppendHeader(ActionExecutingContext context) + { + const string HeaderName = "X-Umb-User-Modified"; + if (context.HttpContext.Response.Headers.ContainsKey(HeaderName) == false) + { + context.HttpContext.Response.Headers.Add(HeaderName, "1"); + } + } + + private int GetUserIdFromParameter(object? parameterValue) + { + if (parameterValue is int) + { + return (int)parameterValue; + } + + throw new InvalidOperationException($"The id type: {parameterValue?.GetType()} is not a supported id."); + } } diff --git a/src/Umbraco.Web.BackOffice/Filters/CheckIfUserTicketDataIsStaleAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/CheckIfUserTicketDataIsStaleAttribute.cs index 699b8561b2..7d0270d3ac 100644 --- a/src/Umbraco.Web.BackOffice/Filters/CheckIfUserTicketDataIsStaleAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/CheckIfUserTicketDataIsStaleAttribute.cs @@ -1,13 +1,8 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Security.Claims; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Mapping; @@ -19,161 +14,158 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// +internal sealed class CheckIfUserTicketDataIsStaleAttribute : TypeFilterAttribute { - /// - /// - /// - internal sealed class CheckIfUserTicketDataIsStaleAttribute : TypeFilterAttribute + public CheckIfUserTicketDataIsStaleAttribute() + : base(typeof(CheckIfUserTicketDataIsStaleFilter)) { - public CheckIfUserTicketDataIsStaleAttribute() - : base(typeof(CheckIfUserTicketDataIsStaleFilter)) + } + + private class CheckIfUserTicketDataIsStaleFilter : IAsyncActionFilter + { + private readonly AppCaches _appCaches; + private readonly IBackOfficeAntiforgery _backOfficeAntiforgery; + private readonly IBackOfficeSignInManager _backOfficeSignInManager; + private readonly IEntityService _entityService; + private readonly ILocalizedTextService _localizedTextService; + private readonly IRequestCache _requestCache; + private readonly ICoreScopeProvider _scopeProvider; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IUserService _userService; + private readonly GlobalSettings _globalSettings; + + public CheckIfUserTicketDataIsStaleFilter( + IRequestCache requestCache, + IUmbracoMapper umbracoMapper, + IUserService userService, + IEntityService entityService, + ILocalizedTextService localizedTextService, + IOptionsSnapshot globalSettings, + IBackOfficeSignInManager backOfficeSignInManager, + IBackOfficeAntiforgery backOfficeAntiforgery, + ICoreScopeProvider scopeProvider, + AppCaches appCaches) { + _requestCache = requestCache; + _umbracoMapper = umbracoMapper; + _userService = userService; + _entityService = entityService; + _localizedTextService = localizedTextService; + _globalSettings = globalSettings.Value; + _backOfficeSignInManager = backOfficeSignInManager; + _backOfficeAntiforgery = backOfficeAntiforgery; + _scopeProvider = scopeProvider; + _appCaches = appCaches; } - private class CheckIfUserTicketDataIsStaleFilter : IAsyncActionFilter - { - private readonly IRequestCache _requestCache; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IUserService _userService; - private readonly IEntityService _entityService; - private readonly ILocalizedTextService _localizedTextService; - private GlobalSettings _globalSettings; - private readonly IBackOfficeSignInManager _backOfficeSignInManager; - private readonly IBackOfficeAntiforgery _backOfficeAntiforgery; - private readonly ICoreScopeProvider _scopeProvider; - private readonly AppCaches _appCaches; - public CheckIfUserTicketDataIsStaleFilter( - IRequestCache requestCache, - IUmbracoMapper umbracoMapper, - IUserService userService, - IEntityService entityService, - ILocalizedTextService localizedTextService, - IOptionsSnapshot globalSettings, - IBackOfficeSignInManager backOfficeSignInManager, - IBackOfficeAntiforgery backOfficeAntiforgery, - ICoreScopeProvider scopeProvider, - AppCaches appCaches) + public async Task OnActionExecutionAsync(ActionExecutingContext actionContext, ActionExecutionDelegate next) + { + await CheckStaleData(actionContext); + + await next(); + + await CheckStaleData(actionContext); + + // return if nothing is updated + if (_requestCache.Get(nameof(CheckIfUserTicketDataIsStaleFilter)) is null) { - _requestCache = requestCache; - _umbracoMapper = umbracoMapper; - _userService = userService; - _entityService = entityService; - _localizedTextService = localizedTextService; - _globalSettings = globalSettings.Value; - _backOfficeSignInManager = backOfficeSignInManager; - _backOfficeAntiforgery = backOfficeAntiforgery; - _scopeProvider = scopeProvider; - _appCaches = appCaches; + return; } + await UpdateTokensAndAppendCustomHeaders(actionContext); + } - public async Task OnActionExecutionAsync(ActionExecutingContext actionContext, ActionExecutionDelegate next) + private async Task UpdateTokensAndAppendCustomHeaders(ActionExecutingContext actionContext) + { + var tokenFilter = + new SetAngularAntiForgeryTokensAttribute.SetAngularAntiForgeryTokensFilter(_backOfficeAntiforgery); + await tokenFilter.OnActionExecutionAsync( + actionContext, + () => Task.FromResult(new ActionExecutedContext(actionContext, new List(), new { }))); + + // add the header + AppendUserModifiedHeaderAttribute.AppendHeader(actionContext); + } + + + private async Task CheckStaleData(ActionExecutingContext actionContext) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) { - await CheckStaleData(actionContext); - - await next(); - - await CheckStaleData(actionContext); - - // return if nothing is updated - if (_requestCache.Get(nameof(CheckIfUserTicketDataIsStaleFilter)) is null) + if (actionContext?.HttpContext.Request == null || actionContext.HttpContext.User?.Identity == null) { return; } - await UpdateTokensAndAppendCustomHeaders(actionContext); - } - - private async Task UpdateTokensAndAppendCustomHeaders(ActionExecutingContext actionContext) - { - var tokenFilter = new SetAngularAntiForgeryTokensAttribute.SetAngularAntiForgeryTokensFilter(_backOfficeAntiforgery); - await tokenFilter.OnActionExecutionAsync( - actionContext, - () => Task.FromResult(new ActionExecutedContext(actionContext, new List(), new { }))); - - // add the header - AppendUserModifiedHeaderAttribute.AppendHeader(actionContext); - } - - - private async Task CheckStaleData(ActionExecutingContext actionContext) - { - using (var scope = _scopeProvider.CreateCoreScope(autoComplete: true)) + // don't execute if it's already been done + if (!(_requestCache.Get(nameof(CheckIfUserTicketDataIsStaleFilter)) is null)) { - if (actionContext?.HttpContext.Request == null || actionContext.HttpContext.User?.Identity == null) - { - return; - } - - // don't execute if it's already been done - if (!(_requestCache.Get(nameof(CheckIfUserTicketDataIsStaleFilter)) is null)) - { - return; - } - - if (actionContext.HttpContext.User.Identity is not ClaimsIdentity identity) - { - return; - } - - var id = identity.GetId(); - if (id is null) - { - return; - } - - IUser? user = _userService.GetUserById(id.Value); - if (user == null) - { - return; - } - - // a list of checks to execute, if any of them pass then we resync - var checks = new Func[] - { - () => user.Username != identity.GetUsername(), - () => - { - CultureInfo culture = user.GetUserCulture(_localizedTextService, _globalSettings); - return culture != null && culture.ToString() != identity.GetCultureString(); - }, - () => user.AllowedSections.UnsortedSequenceEqual(identity.GetAllowedApplications()) == false, - () => user.Groups.Select(x => x.Alias).UnsortedSequenceEqual(identity.GetRoles()) == false, - () => - { - var startContentIds = user.CalculateContentStartNodeIds(_entityService, _appCaches); - return startContentIds.UnsortedSequenceEqual(identity.GetStartContentNodes()) == false; - }, - () => - { - var startMediaIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches); - return startMediaIds.UnsortedSequenceEqual(identity.GetStartMediaNodes()) == false; - } - }; - - if (checks.Any(check => check())) - { - await ReSync(user, actionContext); - } - } - } - - /// - /// This will update the current request IPrincipal to be correct and re-create the auth ticket - /// - private async Task ReSync(IUser user, ActionExecutingContext actionContext) - { - BackOfficeIdentityUser? backOfficeIdentityUser = _umbracoMapper.Map(user); - if (backOfficeIdentityUser is not null) - { - await _backOfficeSignInManager.SignInAsync(backOfficeIdentityUser, isPersistent: true); + return; } - // flag that we've made changes - _requestCache.Set(nameof(CheckIfUserTicketDataIsStaleFilter), true); + if (actionContext.HttpContext.User.Identity is not ClaimsIdentity identity) + { + return; + } + + var id = identity.GetId(); + if (id is null) + { + return; + } + + IUser? user = _userService.GetUserById(id.Value); + if (user == null) + { + return; + } + + // a list of checks to execute, if any of them pass then we resync + var checks = new Func[] + { + () => user.Username != identity.GetUsername(), () => + { + CultureInfo culture = user.GetUserCulture(_localizedTextService, _globalSettings); + return culture != null && culture.ToString() != identity.GetCultureString(); + }, + () => user.AllowedSections.UnsortedSequenceEqual(identity.GetAllowedApplications()) == false, + () => user.Groups.Select(x => x.Alias).UnsortedSequenceEqual(identity.GetRoles()) == false, () => + { + var startContentIds = user.CalculateContentStartNodeIds(_entityService, _appCaches); + return startContentIds.UnsortedSequenceEqual(identity.GetStartContentNodes()) == false; + }, + () => + { + var startMediaIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches); + return startMediaIds.UnsortedSequenceEqual(identity.GetStartMediaNodes()) == false; + } + }; + + if (checks.Any(check => check())) + { + await ReSync(user, actionContext); + } } } + + /// + /// This will update the current request IPrincipal to be correct and re-create the auth ticket + /// + private async Task ReSync(IUser user, ActionExecutingContext actionContext) + { + BackOfficeIdentityUser? backOfficeIdentityUser = _umbracoMapper.Map(user); + if (backOfficeIdentityUser is not null) + { + await _backOfficeSignInManager.SignInAsync(backOfficeIdentityUser, true); + } + + // flag that we've made changes + _requestCache.Set(nameof(CheckIfUserTicketDataIsStaleFilter), true); + } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/ContentModelValidator.cs b/src/Umbraco.Web.BackOffice/Filters/ContentModelValidator.cs index e3377f8557..e0caad339d 100644 --- a/src/Umbraco.Web.BackOffice/Filters/ContentModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/Filters/ContentModelValidator.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -10,197 +7,208 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Web.BackOffice.Extensions; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// A base class purely used for logging without generics +/// +internal abstract class ContentModelValidator { - /// - /// A base class purely used for logging without generics - /// - internal abstract class ContentModelValidator + protected ContentModelValidator(ILogger logger, IPropertyValidationService propertyValidationService) { - public IPropertyValidationService PropertyValidationService { get; } - protected ILogger Logger { get; } - - protected ContentModelValidator(ILogger logger, IPropertyValidationService propertyValidationService) - { - Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - PropertyValidationService = propertyValidationService ?? throw new ArgumentNullException(nameof(propertyValidationService)); - } + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + PropertyValidationService = propertyValidationService ?? + throw new ArgumentNullException(nameof(propertyValidationService)); } - /// - /// A validation helper class used with ContentItemValidationFilterAttribute to be shared between content, media, etc... - /// - /// - /// - /// - /// - /// If any severe errors occur then the response gets set to an error and execution will not continue. Property validation - /// errors will just be added to the ModelState. - /// - internal abstract class ContentModelValidator: ContentModelValidator - where TPersisted : class, IContentBase - where TModelSave: IContentSave - where TModelWithProperties : IContentProperties - { - protected ContentModelValidator( - ILogger logger, - IPropertyValidationService propertyValidationService) - : base(logger, propertyValidationService) - { - } - - /// - /// Ensure the content exists - /// - /// - /// - /// - public virtual bool ValidateExistingContent(TModelSave? postedItem, ActionExecutingContext actionContext) - { - var persistedContent = postedItem?.PersistedContent; - if (persistedContent == null) - { - actionContext.Result = new NotFoundObjectResult("content was not found"); - return false; - } - - return true; - } - - /// - /// Ensure all of the ids in the post are valid - /// - /// - /// - /// - /// - public virtual bool ValidateProperties(TModelSave? model, IContentProperties? modelWithProperties, ActionExecutingContext actionContext) - { - var persistedContent = model?.PersistedContent; - return ValidateProperties(modelWithProperties?.Properties.ToList() ?? new List(), persistedContent?.Properties.ToList(), actionContext); - } - - /// - /// This validates that all of the posted properties exist on the persisted entity - /// - /// - /// - /// - /// - protected bool ValidateProperties(List? postedProperties, List? persistedProperties, ActionExecutingContext actionContext) - { - if (postedProperties is null) - { - return false; - } - - foreach (var p in postedProperties) - { - if (persistedProperties?.Any(property => property.Alias == p.Alias) == false) - { - // TODO: Do we return errors here ? If someone deletes a property whilst their editing then should we just - //save the property data that remains? Or inform them they need to reload... not sure. This problem exists currently too i think. - - var message = $"property with alias: {p.Alias} was not found"; - actionContext.Result = new NotFoundObjectResult(new InvalidOperationException(message)); - return false; - } - - } - return true; - } - - /// - /// Validates the data for each property - /// - /// - /// - /// - /// - /// - /// - /// All property data validation goes into the model state with a prefix of "Properties" - /// - public virtual bool ValidatePropertiesData( - TModelSave? model, - TModelWithProperties? modelWithProperties, - ContentPropertyCollectionDto? dto, - ModelStateDictionary modelState) - { - var properties = modelWithProperties?.Properties.ToDictionary(x => x.Alias, x => x); - - if (dto is not null) - { - foreach (var p in dto.Properties) - { - var editor = p.PropertyEditor; - - if (editor == null) - { - var message = $"Could not find property editor \"{p.DataType?.EditorAlias}\" for property with id {p.Id}."; - - Logger.LogWarning(message); - continue; - } - - //get the posted value for this property, this may be null in cases where the property was marked as readonly which means - //the angular app will not post that value. - if (properties is null || !properties.TryGetValue(p.Alias, out var postedProp)) - continue; - - var postedValue = postedProp.Value; - - ValidatePropertyValue(model, modelWithProperties, editor, p, postedValue, modelState); - } - } - - - return modelState.IsValid; - } - - /// - /// Validates a property's value and adds the error to model state if found - /// - /// - /// - /// - /// - /// - /// - /// - /// - protected virtual void ValidatePropertyValue( - TModelSave? model, - TModelWithProperties? modelWithProperties, - IDataEditor editor, - ContentPropertyDto property, - object? postedValue, - ModelStateDictionary modelState) - { - if (property is null) throw new ArgumentNullException(nameof(property)); - if (property.DataType is null) throw new InvalidOperationException($"{nameof(property)}.{nameof(property.DataType)} cannot be null"); - - foreach (var validationResult in PropertyValidationService.ValidatePropertyValue( - editor, property.DataType, postedValue, property.IsRequired ?? false, - property.ValidationRegExp, property.IsRequiredMessage, property.ValidationRegExpMessage)) - { - AddPropertyError(model, modelWithProperties, editor, property, validationResult, modelState); - } - } - - protected virtual void AddPropertyError( - TModelSave? model, - TModelWithProperties? modelWithProperties, - IDataEditor editor, - ContentPropertyDto property, - ValidationResult validationResult, - ModelStateDictionary modelState) - { - modelState.AddPropertyError(validationResult, property.Alias, property.Culture ?? string.Empty, property.Segment ?? string.Empty); - } - - } + public IPropertyValidationService PropertyValidationService { get; } + protected ILogger Logger { get; } +} + +/// +/// A validation helper class used with ContentItemValidationFilterAttribute to be shared between content, media, +/// etc... +/// +/// +/// +/// +/// +/// If any severe errors occur then the response gets set to an error and execution will not continue. Property +/// validation +/// errors will just be added to the ModelState. +/// +internal abstract class ContentModelValidator : ContentModelValidator + where TPersisted : class, IContentBase + where TModelSave : IContentSave + where TModelWithProperties : IContentProperties +{ + protected ContentModelValidator( + ILogger logger, + IPropertyValidationService propertyValidationService) + : base(logger, propertyValidationService) + { + } + + /// + /// Ensure the content exists + /// + /// + /// + /// + public virtual bool ValidateExistingContent(TModelSave? postedItem, ActionExecutingContext actionContext) + { + TPersisted? persistedContent = postedItem?.PersistedContent; + if (persistedContent == null) + { + actionContext.Result = new NotFoundObjectResult("content was not found"); + return false; + } + + return true; + } + + /// + /// Ensure all of the ids in the post are valid + /// + /// + /// + /// + /// + public virtual bool ValidateProperties(TModelSave? model, IContentProperties? modelWithProperties, ActionExecutingContext actionContext) + { + TPersisted? persistedContent = model?.PersistedContent; + return ValidateProperties(modelWithProperties?.Properties.ToList() ?? new List(), persistedContent?.Properties.ToList(), actionContext); + } + + /// + /// This validates that all of the posted properties exist on the persisted entity + /// + /// + /// + /// + /// + protected bool ValidateProperties(List? postedProperties, List? persistedProperties, ActionExecutingContext actionContext) + { + if (postedProperties is null) + { + return false; + } + + foreach (ContentPropertyBasic p in postedProperties) + { + if (persistedProperties?.Any(property => property.Alias == p.Alias) == false) + { + // TODO: Do we return errors here ? If someone deletes a property whilst their editing then should we just + //save the property data that remains? Or inform them they need to reload... not sure. This problem exists currently too i think. + + var message = $"property with alias: {p.Alias} was not found"; + actionContext.Result = new NotFoundObjectResult(new InvalidOperationException(message)); + return false; + } + } + + return true; + } + + /// + /// Validates the data for each property + /// + /// + /// + /// + /// + /// + /// + /// All property data validation goes into the model state with a prefix of "Properties" + /// + public virtual bool ValidatePropertiesData( + TModelSave? model, + TModelWithProperties? modelWithProperties, + ContentPropertyCollectionDto? dto, + ModelStateDictionary modelState) + { + var properties = modelWithProperties?.Properties.ToDictionary(x => x.Alias, x => x); + + if (dto is not null) + { + foreach (ContentPropertyDto p in dto.Properties) + { + IDataEditor? editor = p.PropertyEditor; + + if (editor == null) + { + var message = + $"Could not find property editor \"{p.DataType?.EditorAlias}\" for property with id {p.Id}."; + + Logger.LogWarning(message); + continue; + } + + //get the posted value for this property, this may be null in cases where the property was marked as readonly which means + //the angular app will not post that value. + if (properties is null || !properties.TryGetValue(p.Alias, out ContentPropertyBasic? postedProp)) + { + continue; + } + + var postedValue = postedProp.Value; + + ValidatePropertyValue(model, modelWithProperties, editor, p, postedValue, modelState); + } + } + + + return modelState.IsValid; + } + + /// + /// Validates a property's value and adds the error to model state if found + /// + /// + /// + /// + /// + /// + /// + protected virtual void ValidatePropertyValue( + TModelSave? model, + TModelWithProperties? modelWithProperties, + IDataEditor editor, + ContentPropertyDto property, + object? postedValue, + ModelStateDictionary modelState) + { + if (property is null) + { + throw new ArgumentNullException(nameof(property)); + } + + if (property.DataType is null) + { + throw new InvalidOperationException($"{nameof(property)}.{nameof(property.DataType)} cannot be null"); + } + + foreach (ValidationResult validationResult in PropertyValidationService.ValidatePropertyValue( + editor, + property.DataType, + postedValue, + property.IsRequired ?? false, + property.ValidationRegExp, + property.IsRequiredMessage, + property.ValidationRegExpMessage)) + { + AddPropertyError(model, modelWithProperties, editor, property, validationResult, modelState); + } + } + + protected virtual void AddPropertyError( + TModelSave? model, + TModelWithProperties? modelWithProperties, + IDataEditor editor, + ContentPropertyDto property, + ValidationResult validationResult, + ModelStateDictionary modelState) => + modelState.AddPropertyError(validationResult, property.Alias, property.Culture ?? string.Empty, property.Segment ?? string.Empty); } diff --git a/src/Umbraco.Web.BackOffice/Filters/ContentSaveModelValidator.cs b/src/Umbraco.Web.BackOffice/Filters/ContentSaveModelValidator.cs index 28dfcfdb97..e11a130988 100644 --- a/src/Umbraco.Web.BackOffice/Filters/ContentSaveModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/Filters/ContentSaveModelValidator.cs @@ -1,21 +1,19 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Web.BackOffice.Filters -{ - /// - /// Validator for - /// - internal class ContentSaveModelValidator : ContentModelValidator - { - public ContentSaveModelValidator( - ILogger logger, - IPropertyValidationService propertyValidationService) - : base(logger, propertyValidationService) - { - } +namespace Umbraco.Cms.Web.BackOffice.Filters; +/// +/// Validator for +/// +internal class ContentSaveModelValidator : ContentModelValidator +{ + public ContentSaveModelValidator( + ILogger logger, + IPropertyValidationService propertyValidationService) + : base(logger, propertyValidationService) + { } } diff --git a/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs index 430d38022a..d995fcd3e8 100644 --- a/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -14,238 +10,245 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Authorization; using Umbraco.Cms.Web.Common.Authorization; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Validates the incoming model along with if the user is allowed to perform the +/// operation +/// +internal sealed class ContentSaveValidationAttribute : TypeFilterAttribute { - /// - /// Validates the incoming model along with if the user is allowed to perform the - /// operation - /// - internal sealed class ContentSaveValidationAttribute : TypeFilterAttribute + public ContentSaveValidationAttribute() : base(typeof(ContentSaveValidationFilter)) => + Order = -3000; // More important than ModelStateInvalidFilter.FilterOrder + + + private sealed class ContentSaveValidationFilter : IAsyncActionFilter { - public ContentSaveValidationAttribute() : base(typeof(ContentSaveValidationFilter)) + private readonly IAuthorizationService _authorizationService; + private readonly IContentService _contentService; + private readonly ILoggerFactory _loggerFactory; + private readonly IPropertyValidationService _propertyValidationService; + + + public ContentSaveValidationFilter( + ILoggerFactory loggerFactory, + IContentService contentService, + IPropertyValidationService propertyValidationService, + IAuthorizationService authorizationService) { - Order = -3000; // More important than ModelStateInvalidFilter.FilterOrder + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); + _propertyValidationService = propertyValidationService ?? + throw new ArgumentNullException(nameof(propertyValidationService)); + _authorizationService = authorizationService; } - - private sealed class ContentSaveValidationFilter : IAsyncActionFilter + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - private readonly IContentService _contentService; - private readonly IPropertyValidationService _propertyValidationService; - private readonly IAuthorizationService _authorizationService; - private readonly ILoggerFactory _loggerFactory; + // on executing... + await OnActionExecutingAsync(context); - - public ContentSaveValidationFilter( - ILoggerFactory loggerFactory, - IContentService contentService, - IPropertyValidationService propertyValidationService, - IAuthorizationService authorizationService) + if (context.Result == null) { - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); - _propertyValidationService = propertyValidationService ?? throw new ArgumentNullException(nameof(propertyValidationService)); - _authorizationService = authorizationService; + //need to pass the execution to next if a result was not set + await next(); } - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + + // on executed... + } + + private async Task OnActionExecutingAsync(ActionExecutingContext context) + { + var model = (ContentItemSave?)context.ActionArguments["contentItem"]; + var contentItemValidator = + new ContentSaveModelValidator(_loggerFactory.CreateLogger(), _propertyValidationService); + + if (context.ModelState.ContainsKey("contentItem")) { - // on executing... - await OnActionExecutingAsync(context); - - if (context.Result == null) - { - //need to pass the execution to next if a result was not set - await next(); - } - - - // on executed... + // if the entire model is marked as error, remove it, we handle everything separately + context.ModelState.Remove("contentItem"); } - private async Task OnActionExecutingAsync(ActionExecutingContext context) + if (!ValidateAtLeastOneVariantIsBeingSaved(model, context)) { - var model = (ContentItemSave?) context.ActionArguments["contentItem"]; - var contentItemValidator = new ContentSaveModelValidator(_loggerFactory.CreateLogger(), _propertyValidationService); + return; + } - if (context.ModelState.ContainsKey("contentItem")) + if (!contentItemValidator.ValidateExistingContent(model, context)) + { + return; + } + + if (!await ValidateUserAccessAsync(model, context)) + { + return; + } + + if (model is not null) + { + //validate for each variant that is being updated + foreach (ContentVariantSave variant in model.Variants.Where(x => x.Save)) { - // if the entire model is marked as error, remove it, we handle everything separately - context.ModelState.Remove("contentItem"); - } - - if (!ValidateAtLeastOneVariantIsBeingSaved(model, context)) return; - if (!contentItemValidator.ValidateExistingContent(model, context)) return; - if (!await ValidateUserAccessAsync(model, context)) return; - - if (model is not null) - { - //validate for each variant that is being updated - foreach (var variant in model.Variants.Where(x => x.Save)) + if (contentItemValidator.ValidateProperties(model, variant, context)) { - if (contentItemValidator.ValidateProperties(model, variant, context)) - { - contentItemValidator.ValidatePropertiesData(model, variant, variant.PropertyCollectionDto, - context.ModelState); - } + contentItemValidator.ValidatePropertiesData(model, variant, variant.PropertyCollectionDto, context.ModelState); } } } + } - /// - /// If there are no variants tagged for Saving, then this is an invalid request - /// - /// - /// - /// - private bool ValidateAtLeastOneVariantIsBeingSaved( - ContentItemSave? contentItem, - ActionExecutingContext actionContext) + /// + /// If there are no variants tagged for Saving, then this is an invalid request + /// + /// + /// + /// + private bool ValidateAtLeastOneVariantIsBeingSaved( + ContentItemSave? contentItem, + ActionExecutingContext actionContext) + { + if (!contentItem?.Variants.Any(x => x.Save) ?? true) { - if (!contentItem?.Variants.Any(x => x.Save) ?? true) - { - actionContext.Result = new NotFoundObjectResult(new {Message = "No variants flagged for saving"}); - return false; - } - - return true; + actionContext.Result = new NotFoundObjectResult(new { Message = "No variants flagged for saving" }); + return false; } - /// - /// Checks if the user has access to post a content item based on whether it's being created or saved. - /// - /// - /// - /// - private async Task ValidateUserAccessAsync( - ContentItemSave? contentItem, - ActionExecutingContext actionContext) + return true; + } + + /// + /// Checks if the user has access to post a content item based on whether it's being created or saved. + /// + /// + /// + /// + private async Task ValidateUserAccessAsync( + ContentItemSave? contentItem, + ActionExecutingContext actionContext) + { + // We now need to validate that the user is allowed to be doing what they are doing. + // Based on the action we need to check different permissions. + // Then if it is new, we need to lookup those permissions on the parent! + + var permissionToCheck = new List(); + IContent? contentToCheck = null; + int contentIdToCheck; + switch (contentItem?.Action) { - // We now need to validate that the user is allowed to be doing what they are doing. - // Based on the action we need to check different permissions. - // Then if it is new, we need to lookup those permissions on the parent! + case ContentSaveAction.Save: + permissionToCheck.Add(ActionUpdate.ActionLetter); + contentToCheck = contentItem.PersistedContent; + contentIdToCheck = contentToCheck?.Id ?? default; + break; + case ContentSaveAction.Publish: + case ContentSaveAction.PublishWithDescendants: + case ContentSaveAction.PublishWithDescendantsForce: + permissionToCheck.Add(ActionPublish.ActionLetter); + contentToCheck = contentItem.PersistedContent; + contentIdToCheck = contentToCheck?.Id ?? default; + break; + case ContentSaveAction.SendPublish: + permissionToCheck.Add(ActionToPublish.ActionLetter); + contentToCheck = contentItem.PersistedContent; + contentIdToCheck = contentToCheck?.Id ?? default; + break; + case ContentSaveAction.Schedule: + permissionToCheck.Add(ActionUpdate.ActionLetter); + permissionToCheck.Add(ActionToPublish.ActionLetter); + contentToCheck = contentItem.PersistedContent; + contentIdToCheck = contentToCheck?.Id ?? default; + break; + case ContentSaveAction.SaveNew: + //Save new requires ActionNew - var permissionToCheck = new List(); - IContent? contentToCheck = null; - int contentIdToCheck; - switch (contentItem?.Action) - { - case ContentSaveAction.Save: - permissionToCheck.Add(ActionUpdate.ActionLetter); - contentToCheck = contentItem.PersistedContent; + permissionToCheck.Add(ActionNew.ActionLetter); + + if (contentItem.ParentId != Constants.System.Root) + { + contentToCheck = _contentService.GetById(contentItem.ParentId); contentIdToCheck = contentToCheck?.Id ?? default; - break; - case ContentSaveAction.Publish: - case ContentSaveAction.PublishWithDescendants: - case ContentSaveAction.PublishWithDescendantsForce: - permissionToCheck.Add(ActionPublish.ActionLetter); - contentToCheck = contentItem.PersistedContent; + } + else + { + contentIdToCheck = contentItem.ParentId; + } + + break; + case ContentSaveAction.SendPublishNew: + //Send new requires both ActionToPublish AND ActionNew + + permissionToCheck.Add(ActionNew.ActionLetter); + permissionToCheck.Add(ActionToPublish.ActionLetter); + if (contentItem.ParentId != Constants.System.Root) + { + contentToCheck = _contentService.GetById(contentItem.ParentId); contentIdToCheck = contentToCheck?.Id ?? default; - break; - case ContentSaveAction.SendPublish: - permissionToCheck.Add(ActionToPublish.ActionLetter); - contentToCheck = contentItem.PersistedContent; + } + else + { + contentIdToCheck = contentItem.ParentId; + } + + break; + case ContentSaveAction.PublishNew: + case ContentSaveAction.PublishWithDescendantsNew: + case ContentSaveAction.PublishWithDescendantsForceNew: + //Publish new requires both ActionNew AND ActionPublish + // TODO: Shouldn't publish also require ActionUpdate since it will definitely perform an update to publish but maybe that's just implied + + permissionToCheck.Add(ActionNew.ActionLetter); + permissionToCheck.Add(ActionPublish.ActionLetter); + + if (contentItem.ParentId != Constants.System.Root) + { + contentToCheck = _contentService.GetById(contentItem.ParentId); contentIdToCheck = contentToCheck?.Id ?? default; - break; - case ContentSaveAction.Schedule: - permissionToCheck.Add(ActionUpdate.ActionLetter); - permissionToCheck.Add(ActionToPublish.ActionLetter); - contentToCheck = contentItem.PersistedContent; + } + else + { + contentIdToCheck = contentItem.ParentId; + } + + break; + case ContentSaveAction.ScheduleNew: + + permissionToCheck.Add(ActionNew.ActionLetter); + permissionToCheck.Add(ActionUpdate.ActionLetter); + permissionToCheck.Add(ActionPublish.ActionLetter); + + if (contentItem.ParentId != Constants.System.Root) + { + contentToCheck = _contentService.GetById(contentItem.ParentId); contentIdToCheck = contentToCheck?.Id ?? default; - break; - case ContentSaveAction.SaveNew: - //Save new requires ActionNew + } + else + { + contentIdToCheck = contentItem.ParentId; + } - permissionToCheck.Add(ActionNew.ActionLetter); - - if (contentItem.ParentId != Constants.System.Root) - { - contentToCheck = _contentService.GetById(contentItem.ParentId); - contentIdToCheck = contentToCheck?.Id ?? default; - } - else - { - contentIdToCheck = contentItem.ParentId; - } - - break; - case ContentSaveAction.SendPublishNew: - //Send new requires both ActionToPublish AND ActionNew - - permissionToCheck.Add(ActionNew.ActionLetter); - permissionToCheck.Add(ActionToPublish.ActionLetter); - if (contentItem.ParentId != Constants.System.Root) - { - contentToCheck = _contentService.GetById(contentItem.ParentId); - contentIdToCheck = contentToCheck?.Id ?? default; - } - else - { - contentIdToCheck = contentItem.ParentId; - } - - break; - case ContentSaveAction.PublishNew: - case ContentSaveAction.PublishWithDescendantsNew: - case ContentSaveAction.PublishWithDescendantsForceNew: - //Publish new requires both ActionNew AND ActionPublish - // TODO: Shouldn't publish also require ActionUpdate since it will definitely perform an update to publish but maybe that's just implied - - permissionToCheck.Add(ActionNew.ActionLetter); - permissionToCheck.Add(ActionPublish.ActionLetter); - - if (contentItem.ParentId != Constants.System.Root) - { - contentToCheck = _contentService.GetById(contentItem.ParentId); - contentIdToCheck = contentToCheck?.Id ?? default; - } - else - { - contentIdToCheck = contentItem.ParentId; - } - - break; - case ContentSaveAction.ScheduleNew: - - permissionToCheck.Add(ActionNew.ActionLetter); - permissionToCheck.Add(ActionUpdate.ActionLetter); - permissionToCheck.Add(ActionPublish.ActionLetter); - - if (contentItem.ParentId != Constants.System.Root) - { - contentToCheck = _contentService.GetById(contentItem.ParentId); - contentIdToCheck = contentToCheck?.Id ?? default; - } - else - { - contentIdToCheck = contentItem.ParentId; - } - - break; - default: - throw new ArgumentOutOfRangeException(); - } - - - var resource = contentToCheck == null - ? new ContentPermissionsResource(contentToCheck, contentIdToCheck, permissionToCheck) - : new ContentPermissionsResource(contentToCheck, permissionToCheck); - - var authorizationResult = await _authorizationService.AuthorizeAsync( - actionContext.HttpContext.User, - resource, - AuthorizationPolicies.ContentPermissionByResource); - - if (!authorizationResult.Succeeded) - { - return false; - } - - return true; + break; + default: + throw new ArgumentOutOfRangeException(); } + ContentPermissionsResource resource = contentToCheck == null + ? new ContentPermissionsResource(contentToCheck, contentIdToCheck, permissionToCheck) + : new ContentPermissionsResource(contentToCheck, permissionToCheck); + + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeAsync( + actionContext.HttpContext.User, + resource, + AuthorizationPolicies.ContentPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return false; + } + + return true; } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/DataTypeValidateAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/DataTypeValidateAttribute.cs index 9d9f0324c1..c20061b973 100644 --- a/src/Umbraco.Web.BackOffice/Filters/DataTypeValidateAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/DataTypeValidateAttribute.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.ComponentModel.DataAnnotations; using System.Net; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -8,116 +7,127 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Web.BackOffice.Extensions; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Extensions; +using DataType = Umbraco.Cms.Core.Models.DataType; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// An attribute/filter that wires up the persisted entity of the DataTypeSave model and validates the whole request +/// +internal sealed class DataTypeValidateAttribute : TypeFilterAttribute { - /// - /// An attribute/filter that wires up the persisted entity of the DataTypeSave model and validates the whole request - /// - internal sealed class DataTypeValidateAttribute : TypeFilterAttribute + public DataTypeValidateAttribute() : base(typeof(DataTypeValidateFilter)) { - public DataTypeValidateAttribute() : base(typeof(DataTypeValidateFilter)) + } + + private class DataTypeValidateFilter : IActionFilter + { + private readonly IDataTypeService _dataTypeService; + private readonly PropertyEditorCollection _propertyEditorCollection; + private readonly IUmbracoMapper _umbracoMapper; + + public DataTypeValidateFilter(IDataTypeService dataTypeService, PropertyEditorCollection propertyEditorCollection, IUmbracoMapper umbracoMapper) + { + _dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); + _propertyEditorCollection = propertyEditorCollection ?? + throw new ArgumentNullException(nameof(propertyEditorCollection)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + } + + public void OnActionExecuted(ActionExecutedContext context) { } - private class DataTypeValidateFilter : IActionFilter + public void OnActionExecuting(ActionExecutingContext context) { - private readonly IDataTypeService _dataTypeService; - private readonly PropertyEditorCollection _propertyEditorCollection; - private readonly IUmbracoMapper _umbracoMapper; - - public DataTypeValidateFilter(IDataTypeService dataTypeService, PropertyEditorCollection propertyEditorCollection, IUmbracoMapper umbracoMapper) + var dataType = (DataTypeSave?)context.ActionArguments["dataType"]; + if (dataType is not null) { - _dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); - _propertyEditorCollection = propertyEditorCollection ?? throw new ArgumentNullException(nameof(propertyEditorCollection)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + dataType.Name = dataType.Name?.CleanForXss('[', ']', '(', ')', ':'); + dataType.Alias = dataType.Alias == null + ? dataType.Name! + : dataType.Alias.CleanForXss('[', ']', '(', ')', ':'); } - public void OnActionExecuted(ActionExecutedContext context) + // get the property editor, ensuring that it exits + if (!_propertyEditorCollection.TryGet(dataType?.EditorAlias, out IDataEditor? propertyEditor)) { + var message = $"Property editor \"{dataType?.EditorAlias}\" was not found."; + context.Result = new UmbracoProblemResult(message, HttpStatusCode.NotFound); + return; } - public void OnActionExecuting(ActionExecutingContext context) + if (dataType is null) { - var dataType = (DataTypeSave?)context.ActionArguments["dataType"]; - if (dataType is not null) - { - dataType.Name = dataType.Name?.CleanForXss('[', ']', '(', ')', ':'); - dataType.Alias = dataType.Alias == null ? dataType.Name! : dataType.Alias.CleanForXss('[', ']', '(', ')', ':'); - } + return; + } - // get the property editor, ensuring that it exits - if (!_propertyEditorCollection.TryGet(dataType?.EditorAlias, out var propertyEditor)) - { - var message = $"Property editor \"{dataType?.EditorAlias}\" was not found."; - context.Result = new UmbracoProblemResult(message, HttpStatusCode.NotFound); - return; - } + // assign + dataType.PropertyEditor = propertyEditor; - if (dataType is null) - { - return; - } - - // assign - dataType.PropertyEditor = propertyEditor; - - // validate that the data type exists, or create one if required - IDataType? persisted; - switch (dataType.Action) - { - case ContentSaveAction.Save: - persisted = _dataTypeService.GetDataType(Convert.ToInt32(dataType.Id)); - if (persisted == null) - { - var message = $"Data type with id {dataType.Id} was not found."; - context.Result = new UmbracoProblemResult(message, HttpStatusCode.NotFound); - return; - } - // map the model to the persisted instance - _umbracoMapper.Map(dataType, persisted); - break; - - case ContentSaveAction.SaveNew: - // create the persisted model from mapping the saved model - persisted = _umbracoMapper.Map(dataType); - ((DataType?)persisted)?.ResetIdentity(); - break; - - default: - context.Result = new UmbracoProblemResult($"Data type action {dataType.Action} was not found.", HttpStatusCode.NotFound); - return; - } - - // assign (so it's available in the action) - dataType.PersistedDataType = persisted; - - // validate the configuration - // which is posted as a set of fields with key (string) and value (object) - var configurationEditor = propertyEditor.GetConfigurationEditor(); - - if (dataType.ConfigurationFields is not null) - { - foreach (var field in dataType.ConfigurationFields) + // validate that the data type exists, or create one if required + IDataType? persisted; + switch (dataType.Action) + { + case ContentSaveAction.Save: + persisted = _dataTypeService.GetDataType(Convert.ToInt32(dataType.Id)); + if (persisted == null) { - var editorField = configurationEditor.Fields.SingleOrDefault(x => x.Key == field.Key); - if (editorField == null) continue; + var message = $"Data type with id {dataType.Id} was not found."; + context.Result = new UmbracoProblemResult(message, HttpStatusCode.NotFound); + return; + } - // run each IValueValidator (with null valueType and dataTypeConfiguration: not relevant here) - foreach (var validator in editorField.Validators) - foreach (var result in validator.Validate(field.Value, null, null)) + // map the model to the persisted instance + _umbracoMapper.Map(dataType, persisted); + break; + + case ContentSaveAction.SaveNew: + // create the persisted model from mapping the saved model + persisted = _umbracoMapper.Map(dataType); + ((DataType?)persisted)?.ResetIdentity(); + break; + + default: + context.Result = new UmbracoProblemResult($"Data type action {dataType.Action} was not found.", HttpStatusCode.NotFound); + return; + } + + // assign (so it's available in the action) + dataType.PersistedDataType = persisted; + + // validate the configuration + // which is posted as a set of fields with key (string) and value (object) + IConfigurationEditor configurationEditor = propertyEditor.GetConfigurationEditor(); + + if (dataType.ConfigurationFields is not null) + { + foreach (DataTypeConfigurationFieldSave field in dataType.ConfigurationFields) + { + ConfigurationField? editorField = + configurationEditor.Fields.SingleOrDefault(x => x.Key == field.Key); + if (editorField == null) + { + continue; + } + + // run each IValueValidator (with null valueType and dataTypeConfiguration: not relevant here) + foreach (IValueValidator validator in editorField.Validators) + { + foreach (ValidationResult result in validator.Validate(field.Value, null, null)) + { context.ModelState.AddValidationError(result, "Properties", field.Key ?? string.Empty); + } } } + } - if (context.ModelState.IsValid == false) - { - // if it is not valid, do not continue and return the model state - context.Result = new ValidationErrorResult(context.ModelState); - } + if (context.ModelState.IsValid == false) + { + // if it is not valid, do not continue and return the model state + context.Result = new ValidationErrorResult(context.ModelState); } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/FileUploadCleanupFilterAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/FileUploadCleanupFilterAttribute.cs index 22770abced..474b1ef581 100644 --- a/src/Umbraco.Web.BackOffice/Filters/FileUploadCleanupFilterAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/FileUploadCleanupFilterAttribute.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; @@ -10,54 +5,98 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Editors; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Checks if the parameter is IHaveUploadedFiles and then deletes any temporary saved files from file uploads +/// associated with the request +/// +public sealed class FileUploadCleanupFilterAttribute : TypeFilterAttribute { /// - /// Checks if the parameter is IHaveUploadedFiles and then deletes any temporary saved files from file uploads - /// associated with the request + /// Constructor specifies if the filter should analyze the incoming or outgoing model /// - public sealed class FileUploadCleanupFilterAttribute : TypeFilterAttribute + /// + public FileUploadCleanupFilterAttribute(bool incomingModel = true) : base(typeof(FileUploadCleanupFilter)) => + Arguments = new object[] { incomingModel }; + + // We need to use IAsyncActionFilter even that we dont have any async because we need access to + // context.ActionArguments, and this is only available on ActionExecutingContext and not on + // ActionExecutedContext + + private class FileUploadCleanupFilter : IAsyncActionFilter { - /// - /// Constructor specifies if the filter should analyze the incoming or outgoing model - /// - /// - public FileUploadCleanupFilterAttribute(bool incomingModel = true) : base(typeof(FileUploadCleanupFilter)) => - Arguments = new object[] - { - incomingModel - }; + private readonly bool _incomingModel; + private readonly ILogger _logger; - // We need to use IAsyncActionFilter even that we dont have any async because we need access to - // context.ActionArguments, and this is only available on ActionExecutingContext and not on - // ActionExecutedContext - - private class FileUploadCleanupFilter : IAsyncActionFilter + public FileUploadCleanupFilter(ILogger logger, bool incomingModel) { - private readonly ILogger _logger; - private readonly bool _incomingModel; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _incomingModel = incomingModel; + } - public FileUploadCleanupFilter(ILogger logger, bool incomingModel) + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + ActionExecutedContext resultContext = await next(); // We only to do stuff after the action is executed + + var tempFolders = new List(); + + if (_incomingModel) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _incomingModel = incomingModel; - } - - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - ActionExecutedContext resultContext = await next(); // We only to do stuff after the action is executed - - var tempFolders = new List(); - - if (_incomingModel) + if (context.ActionArguments.Any()) { - if (context.ActionArguments.Any()) + if (context.ActionArguments.First().Value is IHaveUploadedFiles contentItem) { - - if (context.ActionArguments.First().Value is IHaveUploadedFiles contentItem) + //cleanup any files associated + foreach (ContentPropertyFile f in contentItem.UploadedFiles) { - //cleanup any files associated - foreach (ContentPropertyFile f in contentItem.UploadedFiles) + //track all temp folders so we can remove old files afterwards + var dir = Path.GetDirectoryName(f.TempFilePath); + if (dir is not null && tempFolders.Contains(dir) == false) + { + tempFolders.Add(dir); + } + + try + { + File.Delete(f.TempFilePath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not delete temp file {FileName}", f.TempFilePath); + } + } + } + } + } + else + { + if (resultContext == null) + { + _logger.LogWarning("The result context is null."); + return; + } + + if (resultContext.Result == null) + { + _logger.LogWarning("The result context's result is null"); + return; + } + + if (!(resultContext.Result is ObjectResult objectResult)) + { + _logger.LogWarning("Could not acquire the result context's result as ObjectResult"); + return; + } + + if (objectResult.Value is IHaveUploadedFiles uploadedFiles) + { + if (uploadedFiles.UploadedFiles != null) + { + //cleanup any files associated + foreach (ContentPropertyFile f in uploadedFiles.UploadedFiles) + { + if (f.TempFilePath.IsNullOrWhiteSpace() == false) { //track all temp folders so we can remove old files afterwards var dir = Path.GetDirectoryName(f.TempFilePath); @@ -66,6 +105,8 @@ namespace Umbraco.Cms.Web.BackOffice.Filters tempFolders.Add(dir); } + _logger.LogDebug("Removing temp file {FileName}", f.TempFilePath); + try { File.Delete(f.TempFilePath); @@ -74,78 +115,27 @@ namespace Umbraco.Cms.Web.BackOffice.Filters { _logger.LogError(ex, "Could not delete temp file {FileName}", f.TempFilePath); } + + //clear out the temp path so it's not returned in the response + f.TempFilePath = string.Empty; } - } - } - } - else - { - if (resultContext == null) - { - _logger.LogWarning("The result context is null."); - return; - } - - if (resultContext.Result == null) - { - _logger.LogWarning("The result context's result is null"); - return; - } - - if (!(resultContext.Result is ObjectResult objectResult)) - { - _logger.LogWarning("Could not acquire the result context's result as ObjectResult"); - return; - } - - if (objectResult.Value is IHaveUploadedFiles uploadedFiles) - { - if (uploadedFiles.UploadedFiles != null) - { - //cleanup any files associated - foreach (ContentPropertyFile f in uploadedFiles.UploadedFiles) + else { - if (f.TempFilePath.IsNullOrWhiteSpace() == false) - { - //track all temp folders so we can remove old files afterwards - var dir = Path.GetDirectoryName(f.TempFilePath); - if (dir is not null && tempFolders.Contains(dir) == false) - { - tempFolders.Add(dir); - } - - _logger.LogDebug("Removing temp file {FileName}", f.TempFilePath); - - try - { - File.Delete(f.TempFilePath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not delete temp file {FileName}", f.TempFilePath); - } - - //clear out the temp path so it's not returned in the response - f.TempFilePath = ""; - } - else - { - _logger.LogWarning("The f.TempFilePath is null or whitespace!!??"); - } + _logger.LogWarning("The f.TempFilePath is null or whitespace!!??"); } } - else - { - _logger.LogWarning("The uploadedFiles.UploadedFiles is null!!??"); - } } else { - _logger.LogWarning( - "The actionExecutedContext.Request.Content.Value is not IHaveUploadedFiles, it is {ObjectType}", - objectResult.Value?.GetType()); + _logger.LogWarning("The uploadedFiles.UploadedFiles is null!!??"); } } + else + { + _logger.LogWarning( + "The actionExecutedContext.Request.Content.Value is not IHaveUploadedFiles, it is {ObjectType}", + objectResult.Value?.GetType()); + } } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/FilterAllowedOutgoingContentAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/FilterAllowedOutgoingContentAttribute.cs index 011e8275ab..df3c08a7bc 100644 --- a/src/Umbraco.Web.BackOffice/Filters/FilterAllowedOutgoingContentAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/FilterAllowedOutgoingContentAttribute.cs @@ -1,122 +1,114 @@ -using System; using System.Collections; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// This inspects the result of the action that returns a collection of content and removes +/// any item that the current user doesn't have access to +/// +internal sealed class FilterAllowedOutgoingContentAttribute : TypeFilterAttribute { - /// - /// This inspects the result of the action that returns a collection of content and removes - /// any item that the current user doesn't have access to - /// - internal sealed class FilterAllowedOutgoingContentAttribute : TypeFilterAttribute + internal FilterAllowedOutgoingContentAttribute(Type outgoingType) + : this(outgoingType, null, ActionBrowse.ActionLetter) { - - internal FilterAllowedOutgoingContentAttribute(Type outgoingType) - : this(outgoingType, null, ActionBrowse.ActionLetter) - { - - } - - internal FilterAllowedOutgoingContentAttribute(Type outgoingType, char permissionToCheck) - : this(outgoingType, null, permissionToCheck) - { - } - - internal FilterAllowedOutgoingContentAttribute(Type outgoingType, string propertyName) - : this(outgoingType, propertyName, ActionBrowse.ActionLetter) - { - } - - internal FilterAllowedOutgoingContentAttribute(Type outgoingType, IUserService userService, IEntityService entityService) - : this(outgoingType, null, ActionBrowse.ActionLetter) - { - - } - - public FilterAllowedOutgoingContentAttribute(Type outgoingType, string? propertyName, char permissionToCheck) - : base(typeof(FilterAllowedOutgoingContentFilter)) - { - Arguments = new object[] - { - outgoingType, propertyName ?? string.Empty, permissionToCheck - }; - } } - internal sealed class FilterAllowedOutgoingContentFilter : FilterAllowedOutgoingMediaFilter + + internal FilterAllowedOutgoingContentAttribute(Type outgoingType, char permissionToCheck) + : this(outgoingType, null, permissionToCheck) { - private readonly char _permissionToCheck; - private readonly IUserService _userService; - private readonly IEntityService _entityService; - private readonly AppCaches _appCaches; + } - public FilterAllowedOutgoingContentFilter(Type outgoingType, string propertyName, char permissionToCheck, IUserService userService, IEntityService entityService, AppCaches appCaches, IBackOfficeSecurityAccessor backofficeSecurityAccessor) - : base(entityService, backofficeSecurityAccessor, appCaches, outgoingType, propertyName) + internal FilterAllowedOutgoingContentAttribute(Type outgoingType, string propertyName) + : this(outgoingType, propertyName, ActionBrowse.ActionLetter) + { + } + + internal FilterAllowedOutgoingContentAttribute(Type outgoingType, IUserService userService, IEntityService entityService) + : this(outgoingType, null, ActionBrowse.ActionLetter) + { + } + + public FilterAllowedOutgoingContentAttribute(Type outgoingType, string? propertyName, char permissionToCheck) + : base(typeof(FilterAllowedOutgoingContentFilter)) => + Arguments = new object[] { outgoingType, propertyName ?? string.Empty, permissionToCheck }; +} + +internal sealed class FilterAllowedOutgoingContentFilter : FilterAllowedOutgoingMediaFilter +{ + private readonly AppCaches _appCaches; + private readonly IEntityService _entityService; + private readonly char _permissionToCheck; + private readonly IUserService _userService; + + public FilterAllowedOutgoingContentFilter( + Type outgoingType, + string propertyName, + char permissionToCheck, + IUserService userService, + IEntityService entityService, + AppCaches appCaches, + IBackOfficeSecurityAccessor backofficeSecurityAccessor) + : base(entityService, backofficeSecurityAccessor, appCaches, outgoingType, propertyName) + { + _permissionToCheck = permissionToCheck; + _userService = userService; + _entityService = entityService; + _appCaches = appCaches; + } + + protected override int RecycleBinId => Constants.System.RecycleBinContent; + + protected override void FilterItems(IUser user, IList items) + { + base.FilterItems(user, items); + + FilterBasedOnPermissions(items, user); + } + + protected override int[]? GetUserStartNodes(IUser user) => + user.CalculateContentStartNodeIds(_entityService, _appCaches); + + internal void FilterBasedOnPermissions(IList items, IUser user) + { + var length = items.Count; + + if (length > 0) { - _permissionToCheck = permissionToCheck; - _userService = userService; - _entityService = entityService; - _appCaches = appCaches; - } - - protected override void FilterItems(IUser user, IList items) - { - base.FilterItems(user, items); - - FilterBasedOnPermissions(items, user); - } - - protected override int[]? GetUserStartNodes(IUser user) - { - return user.CalculateContentStartNodeIds(_entityService, _appCaches); - } - - protected override int RecycleBinId - { - get { return Constants.System.RecycleBinContent; } - } - - internal void FilterBasedOnPermissions(IList items, IUser user) - { - var length = items.Count; - - if (length > 0) + var ids = new List(); + for (var i = 0; i < length; i++) { - var ids = new List(); - for (var i = 0; i < length; i++) - { - ids.Add(((dynamic)items[i]!).Id); - } - //get all the permissions for these nodes in one call - var permissions = _userService.GetPermissions(user, ids.ToArray()); - var toRemove = new List(); - foreach (dynamic item in items) - { - //get the combined permission set across all user groups for this node - //we're in the world of dynamics here so we need to cast - var nodePermission = ((IEnumerable)permissions.GetAllPermissions(item.Id)).ToArray(); + ids.Add(((dynamic)items[i]!).Id); + } - //if the permission being checked doesn't exist then remove the item - if (nodePermission.Contains(_permissionToCheck.ToString(CultureInfo.InvariantCulture)) == false) - { - toRemove.Add(item); - } - } - foreach (var item in toRemove) + //get all the permissions for these nodes in one call + EntityPermissionCollection permissions = _userService.GetPermissions(user, ids.ToArray()); + var toRemove = new List(); + foreach (dynamic item in items) + { + //get the combined permission set across all user groups for this node + //we're in the world of dynamics here so we need to cast + var nodePermission = ((IEnumerable)permissions.GetAllPermissions(item.Id)).ToArray(); + + //if the permission being checked doesn't exist then remove the item + if (nodePermission.Contains(_permissionToCheck.ToString(CultureInfo.InvariantCulture)) == false) { - items.Remove(item); + toRemove.Add(item); } } - } + foreach (dynamic item in toRemove) + { + items.Remove(item); + } + } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/FilterAllowedOutgoingMediaAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/FilterAllowedOutgoingMediaAttribute.cs index 528c2a1c22..c456d62a7a 100644 --- a/src/Umbraco.Web.BackOffice/Filters/FilterAllowedOutgoingMediaAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/FilterAllowedOutgoingMediaAttribute.cs @@ -1,9 +1,8 @@ -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; +using System.Reflection; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.Models; @@ -11,149 +10,142 @@ using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; +namespace Umbraco.Cms.Web.BackOffice.Filters; -namespace Umbraco.Cms.Web.BackOffice.Filters +/// +/// This inspects the result of the action that returns a collection of content and removes +/// any item that the current user doesn't have access to +/// +internal class FilterAllowedOutgoingMediaAttribute : TypeFilterAttribute { - /// - /// This inspects the result of the action that returns a collection of content and removes - /// any item that the current user doesn't have access to - /// - internal class FilterAllowedOutgoingMediaAttribute : TypeFilterAttribute + public FilterAllowedOutgoingMediaAttribute(Type outgoingType, string? propertyName = null) + : base(typeof(FilterAllowedOutgoingMediaFilter)) => + Arguments = new object[] { outgoingType, propertyName ?? string.Empty }; +} + +internal class FilterAllowedOutgoingMediaFilter : IActionFilter +{ + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IEntityService _entityService; + private readonly Type _outgoingType; + private readonly string _propertyName; + + public FilterAllowedOutgoingMediaFilter( + IEntityService entityService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + AppCaches appCaches, + Type outgoingType, + string propertyName) { - public FilterAllowedOutgoingMediaAttribute(Type outgoingType, string? propertyName = null) - : base(typeof(FilterAllowedOutgoingMediaFilter)) + _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _appCaches = appCaches; + + _propertyName = propertyName; + _outgoingType = outgoingType; + } + + protected virtual int RecycleBinId => Constants.System.RecycleBinMedia; + + public void OnActionExecuted(ActionExecutedContext context) + { + if (context.Result == null) { - Arguments = new object[] + return; + } + + IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + if (user == null) + { + return; + } + + if (context.Result is ObjectResult objectContent) + { + dynamic? collection = GetValueFromResponse(objectContent); + + if (collection != null) { - outgoingType, propertyName ?? string.Empty - }; + var items = Enumerable.ToList(collection); + + FilterItems(user, items); + + //set the return value + SetValueForResponse(objectContent, items); + } } } - internal class FilterAllowedOutgoingMediaFilter : IActionFilter + + + public void OnActionExecuting(ActionExecutingContext context) { - private readonly Type _outgoingType; - private readonly IEntityService _entityService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly AppCaches _appCaches; - private readonly string _propertyName; + } - public FilterAllowedOutgoingMediaFilter( - IEntityService entityService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - AppCaches appCaches, - Type outgoingType, - string propertyName) + protected virtual int[]? GetUserStartNodes(IUser user) => + user.CalculateMediaStartNodeIds(_entityService, _appCaches); + + protected virtual void FilterItems(IUser user, IList items) => FilterBasedOnStartNode(items, user); + + internal void FilterBasedOnStartNode(IList items, IUser user) + { + var toRemove = new List(); + foreach (dynamic item in items) { - _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _appCaches = appCaches; - - _propertyName = propertyName; - _outgoingType = outgoingType; - } - - protected virtual int[]? GetUserStartNodes(IUser user) - { - return user.CalculateMediaStartNodeIds(_entityService, _appCaches); - } - - protected virtual int RecycleBinId => Constants.System.RecycleBinMedia; - - public void OnActionExecuted(ActionExecutedContext context) - { - if (context.Result == null) return; - - var user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - if (user == null) return; - - var objectContent = context.Result as ObjectResult; - if (objectContent != null) + dynamic hasPathAccess = item != null && + ContentPermissions.HasPathAccess(item?.Path, GetUserStartNodes(user), RecycleBinId); + if (hasPathAccess == false) { - var collection = GetValueFromResponse(objectContent); + toRemove.Add(item); + } + } - if (collection != null) + foreach (dynamic item in toRemove) + { + items.Remove(item); + } + } + + private void SetValueForResponse(ObjectResult objectContent, dynamic newVal) + { + if (TypeHelper.IsTypeAssignableFrom(_outgoingType, objectContent.Value?.GetType())) + { + objectContent.Value = newVal; + } + else if (_propertyName.IsNullOrWhiteSpace() == false) + { + //try to get the enumerable collection from a property on the result object using reflection + PropertyInfo? property = objectContent.Value?.GetType().GetProperty(_propertyName); + if (property != null) + { + property.SetValue(objectContent.Value, newVal); + } + } + } + + internal dynamic? GetValueFromResponse(ObjectResult objectContent) + { + if (TypeHelper.IsTypeAssignableFrom(_outgoingType, objectContent.Value?.GetType())) + { + return objectContent.Value; + } + + if (_propertyName.IsNullOrWhiteSpace() == false) + { + //try to get the enumerable collection from a property on the result object using reflection + PropertyInfo? property = objectContent.Value?.GetType().GetProperty(_propertyName); + if (property != null) + { + var result = property.GetValue(objectContent.Value); + if (result != null && TypeHelper.IsTypeAssignableFrom(_outgoingType, result.GetType())) { - var items = Enumerable.ToList(collection); - - FilterItems(user, items); - - //set the return value - SetValueForResponse(objectContent, items); + return result; } } } - protected virtual void FilterItems(IUser user, IList items) - { - FilterBasedOnStartNode(items, user); - } - - internal void FilterBasedOnStartNode(IList items, IUser user) - { - var toRemove = new List(); - foreach (dynamic item in items) - { - var hasPathAccess = (item != null && ContentPermissions.HasPathAccess(item?.Path, GetUserStartNodes(user), RecycleBinId)); - if (hasPathAccess == false) - { - toRemove.Add(item); - } - } - - foreach (var item in toRemove) - { - items.Remove(item); - } - } - - private void SetValueForResponse(ObjectResult objectContent, dynamic newVal) - { - if (TypeHelper.IsTypeAssignableFrom(_outgoingType, objectContent.Value?.GetType())) - { - objectContent.Value = newVal; - } - else if (_propertyName.IsNullOrWhiteSpace() == false) - { - //try to get the enumerable collection from a property on the result object using reflection - var property = objectContent.Value?.GetType().GetProperty(_propertyName); - if (property != null) - { - property.SetValue(objectContent.Value, newVal); - } - } - - } - - internal dynamic? GetValueFromResponse(ObjectResult objectContent) - { - if (TypeHelper.IsTypeAssignableFrom(_outgoingType, objectContent.Value?.GetType())) - { - return objectContent.Value; - } - - if (_propertyName.IsNullOrWhiteSpace() == false) - { - //try to get the enumerable collection from a property on the result object using reflection - var property = objectContent.Value?.GetType().GetProperty(_propertyName); - if (property != null) - { - var result = property.GetValue(objectContent.Value); - if (result != null && TypeHelper.IsTypeAssignableFrom(_outgoingType, result.GetType())) - { - return result; - } - } - } - - return null; - } - - - public void OnActionExecuting(ActionExecutingContext context) - { - - } + return null; } } diff --git a/src/Umbraco.Web.BackOffice/Filters/IsCurrentUserModelFilterAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/IsCurrentUserModelFilterAttribute.cs index bf61b3cfa4..ab8fc1aa03 100644 --- a/src/Umbraco.Web.BackOffice/Filters/IsCurrentUserModelFilterAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/IsCurrentUserModelFilterAttribute.cs @@ -1,72 +1,70 @@ -using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Web.BackOffice.Controllers; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +internal class IsCurrentUserModelFilterAttribute : TypeFilterAttribute { - internal class IsCurrentUserModelFilterAttribute : TypeFilterAttribute + public IsCurrentUserModelFilterAttribute() : base(typeof(IsCurrentUserModelFilter)) { - public IsCurrentUserModelFilterAttribute() : base(typeof(IsCurrentUserModelFilter)) - { - } + } - private class IsCurrentUserModelFilter : IActionFilter - { - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private class IsCurrentUserModelFilter : IActionFilter + { + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - public IsCurrentUserModelFilter(IBackOfficeSecurityAccessor backofficeSecurityAccessor) + public IsCurrentUserModelFilter(IBackOfficeSecurityAccessor backofficeSecurityAccessor) => + _backofficeSecurityAccessor = backofficeSecurityAccessor; + + + public void OnActionExecuted(ActionExecutedContext context) + { + if (context.Result == null) { - _backofficeSecurityAccessor = backofficeSecurityAccessor; + return; } - - public void OnActionExecuted(ActionExecutedContext context) + IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + if (user == null) { - if (context.Result == null) return; + return; + } - var user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - if (user == null) return; - - var objectContent = context.Result as ObjectResult; - if (objectContent != null) + if (context.Result is ObjectResult objectContent) + { + if (objectContent.Value is UserBasic model) { - var model = objectContent.Value as UserBasic; - if (model != null) + model.IsCurrentUser = (int?)model.Id == user.Id; + } + else + { + if (objectContent.Value is IEnumerable collection) { - model.IsCurrentUser = (int?) model.Id == user.Id; + foreach (UserBasic userBasic in collection) + { + userBasic.IsCurrentUser = (int?)userBasic.Id == user.Id; + } } else { - var collection = objectContent.Value as IEnumerable; - if (collection != null) + if (objectContent.Value is UsersController.PagedUserResult paged && paged.Items != null) { - foreach (var userBasic in collection) + foreach (UserBasic userBasic in paged.Items) { - userBasic.IsCurrentUser = (int?) userBasic.Id == user.Id; - } - } - else - { - var paged = objectContent.Value as UsersController.PagedUserResult; - if (paged != null && paged.Items != null) - { - foreach (var userBasic in paged.Items) - { - userBasic.IsCurrentUser = (int?)userBasic.Id == user.Id; - } + userBasic.IsCurrentUser = (int?)userBasic.Id == user.Id; } } } } } + } - public void OnActionExecuting(ActionExecutingContext context) - { - - } + public void OnActionExecuting(ActionExecutingContext context) + { } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/JsonCamelCaseFormatterAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/JsonCamelCaseFormatterAttribute.cs index 9d92dc8f8c..d421a0aa40 100644 --- a/src/Umbraco.Web.BackOffice/Filters/JsonCamelCaseFormatterAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/JsonCamelCaseFormatterAttribute.cs @@ -1,4 +1,4 @@ -using System.Buffers; +using System.Buffers; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; @@ -6,41 +6,40 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using Umbraco.Cms.Web.Common.Formatters; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +public class JsonCamelCaseFormatterAttribute : TypeFilterAttribute { - public class JsonCamelCaseFormatterAttribute : TypeFilterAttribute + public JsonCamelCaseFormatterAttribute() : base(typeof(JsonCamelCaseFormatterFilter)) => + Order = 2; //must be higher than AngularJsonOnlyConfigurationAttribute.Order + + private class JsonCamelCaseFormatterFilter : IResultFilter { - public JsonCamelCaseFormatterAttribute() : base(typeof(JsonCamelCaseFormatterFilter)) + private readonly ArrayPool _arrayPool; + private readonly MvcOptions _options; + + public JsonCamelCaseFormatterFilter(ArrayPool arrayPool, IOptionsSnapshot options) { - Order = 2; //must be higher than AngularJsonOnlyConfigurationAttribute.Order + _arrayPool = arrayPool; + _options = options.Value; } - private class JsonCamelCaseFormatterFilter : IResultFilter + public void OnResultExecuted(ResultExecutedContext context) { - private readonly ArrayPool _arrayPool; - private readonly MvcOptions _options; + } - public JsonCamelCaseFormatterFilter(ArrayPool arrayPool, IOptionsSnapshot options) + public void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is ObjectResult objectResult) { - _arrayPool = arrayPool; - _options = options.Value; - } - public void OnResultExecuted(ResultExecutedContext context) - { - } - - public void OnResultExecuting(ResultExecutingContext context) - { - if (context.Result is ObjectResult objectResult) + var serializerSettings = new JsonSerializerSettings { - var serializerSettings = new JsonSerializerSettings() - { - ContractResolver = new CamelCasePropertyNamesContractResolver() - }; + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; - objectResult.Formatters.Clear(); - objectResult.Formatters.Add(new AngularJsonMediaTypeFormatter(serializerSettings, _arrayPool, _options)); - } + objectResult.Formatters.Clear(); + objectResult.Formatters.Add( + new AngularJsonMediaTypeFormatter(serializerSettings, _arrayPool, _options)); } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/MediaItemSaveValidationAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/MediaItemSaveValidationAttribute.cs index ef558a9f17..222efcd8f3 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MediaItemSaveValidationAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MediaItemSaveValidationAttribute.cs @@ -1,129 +1,132 @@ -using System; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Authorization; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Validates the incoming model +/// +internal class MediaItemSaveValidationAttribute : TypeFilterAttribute { - /// - /// Validates the incoming model - /// - internal class MediaItemSaveValidationAttribute : TypeFilterAttribute + public MediaItemSaveValidationAttribute() : base(typeof(MediaItemSaveValidationFilter)) { - public MediaItemSaveValidationAttribute() : base(typeof(MediaItemSaveValidationFilter)) + } + + private sealed class MediaItemSaveValidationFilter : IAsyncActionFilter + { + private readonly IAuthorizationService _authorizationService; + private readonly ILoggerFactory _loggerFactory; + private readonly IMediaService _mediaService; + private readonly IPropertyValidationService _propertyValidationService; + + public MediaItemSaveValidationFilter( + ILoggerFactory loggerFactory, + IMediaService mediaService, + IPropertyValidationService propertyValidationService, + IAuthorizationService authorizationService) { + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); + _propertyValidationService = propertyValidationService ?? + throw new ArgumentNullException(nameof(propertyValidationService)); + _authorizationService = + authorizationService ?? throw new ArgumentNullException(nameof(authorizationService)); } - private sealed class MediaItemSaveValidationFilter : IAsyncActionFilter + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - private readonly IPropertyValidationService _propertyValidationService; - private readonly IAuthorizationService _authorizationService; - private readonly IMediaService _mediaService; - private readonly ILoggerFactory _loggerFactory; + // on executing... + await OnActionExecutingAsync(context); - public MediaItemSaveValidationFilter( - ILoggerFactory loggerFactory, - IMediaService mediaService, - IPropertyValidationService propertyValidationService, - IAuthorizationService authorizationService) + if (context.Result == null) { - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); - _propertyValidationService = propertyValidationService ?? throw new ArgumentNullException(nameof(propertyValidationService)); - _authorizationService = authorizationService ?? throw new ArgumentNullException(nameof(authorizationService)); + //need to pass the execution to next if a result was not set + await next(); } - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + // on executed... + } + + private async Task OnActionExecutingAsync(ActionExecutingContext context) + { + var model = (MediaItemSave?)context.ActionArguments["contentItem"]; + var contentItemValidator = + new MediaSaveModelValidator(_loggerFactory.CreateLogger(), _propertyValidationService); + + if (await ValidateUserAccessAsync(model, context)) { - // on executing... - await OnActionExecutingAsync(context); - - if (context.Result == null) + //now do each validation step + if (contentItemValidator.ValidateExistingContent(model, context)) { - //need to pass the execution to next if a result was not set - await next(); - } - - // on executed... - } - - private async Task OnActionExecutingAsync(ActionExecutingContext context) - { - var model = (MediaItemSave?) context.ActionArguments["contentItem"]; - var contentItemValidator = new MediaSaveModelValidator(_loggerFactory.CreateLogger(), _propertyValidationService); - - if (await ValidateUserAccessAsync(model, context)) - { - //now do each validation step - if (contentItemValidator.ValidateExistingContent(model, context)) - if (contentItemValidator.ValidateProperties(model, model, context)) - contentItemValidator.ValidatePropertiesData(model, model, model?.PropertyCollectionDto, - context.ModelState); + if (contentItemValidator.ValidateProperties(model, model, context)) + { + contentItemValidator.ValidatePropertiesData(model, model, model?.PropertyCollectionDto, context.ModelState); + } } } + } - /// - /// Checks if the user has access to post a content item based on whether it's being created or saved. - /// - /// - /// - private async Task ValidateUserAccessAsync(MediaItemSave? mediaItem, ActionExecutingContext actionContext) + /// + /// Checks if the user has access to post a content item based on whether it's being created or saved. + /// + /// + /// + private async Task ValidateUserAccessAsync(MediaItemSave? mediaItem, ActionExecutingContext actionContext) + { + //We now need to validate that the user is allowed to be doing what they are doing. + //Then if it is new, we need to lookup those permissions on the parent. + IMedia? contentToCheck; + int contentIdToCheck; + switch (mediaItem?.Action) { - //We now need to validate that the user is allowed to be doing what they are doing. - //Then if it is new, we need to lookup those permissions on the parent. - IMedia? contentToCheck; - int contentIdToCheck; - switch (mediaItem?.Action) - { - case ContentSaveAction.Save: - contentToCheck = mediaItem.PersistedContent; - contentIdToCheck = contentToCheck?.Id ?? default; - break; - case ContentSaveAction.SaveNew: + case ContentSaveAction.Save: + contentToCheck = mediaItem.PersistedContent; + contentIdToCheck = contentToCheck?.Id ?? default; + break; + case ContentSaveAction.SaveNew: + contentToCheck = _mediaService.GetById(mediaItem.ParentId); + + if (mediaItem.ParentId != Constants.System.Root) + { contentToCheck = _mediaService.GetById(mediaItem.ParentId); + contentIdToCheck = contentToCheck?.Id ?? default; + } + else + { + contentIdToCheck = mediaItem.ParentId; + } - if (mediaItem.ParentId != Constants.System.Root) - { - contentToCheck = _mediaService.GetById(mediaItem.ParentId); - contentIdToCheck = contentToCheck?.Id ?? default; - } - else - { - contentIdToCheck = mediaItem.ParentId; - } - - break; - default: - //we don't support this for media - actionContext.Result = new NotFoundResult(); - return false; - } - - var resource = contentToCheck == null - ? new MediaPermissionsResource(contentIdToCheck) - : new MediaPermissionsResource(contentToCheck); - - var authorizationResult = await _authorizationService.AuthorizeAsync( - actionContext.HttpContext.User, - resource, - AuthorizationPolicies.MediaPermissionByResource); - - if (!authorizationResult.Succeeded) - { - actionContext.Result = new ForbidResult(); + break; + default: + //we don't support this for media + actionContext.Result = new NotFoundResult(); return false; - } - - return true; } + + MediaPermissionsResource resource = contentToCheck == null + ? new MediaPermissionsResource(contentIdToCheck) + : new MediaPermissionsResource(contentToCheck); + + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeAsync( + actionContext.HttpContext.User, + resource, + AuthorizationPolicies.MediaPermissionByResource); + + if (!authorizationResult.Succeeded) + { + actionContext.Result = new ForbidResult(); + return false; + } + + return true; } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/MediaSaveModelValidator.cs b/src/Umbraco.Web.BackOffice/Filters/MediaSaveModelValidator.cs index 7e211773d9..3847673191 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MediaSaveModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MediaSaveModelValidator.cs @@ -1,20 +1,20 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Validator for +/// +internal class + MediaSaveModelValidator : ContentModelValidator> { - /// - /// Validator for - /// - internal class MediaSaveModelValidator : ContentModelValidator> + public MediaSaveModelValidator( + ILogger logger, + IPropertyValidationService propertyValidationService) + : base(logger, propertyValidationService) { - public MediaSaveModelValidator( - ILogger logger, - IPropertyValidationService propertyValidationService) - : base(logger, propertyValidationService) - { - } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs b/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs index 6eb6bd6620..6b29803e05 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs @@ -1,6 +1,4 @@ -using System; using System.ComponentModel.DataAnnotations; -using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -11,197 +9,211 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; -using Umbraco.Cms.Web.BackOffice.Extensions; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Validator for +/// +internal class + MemberSaveModelValidator : ContentModelValidator> { - /// - /// Validator for - /// - internal class MemberSaveModelValidator : ContentModelValidator> + private readonly IBackOfficeSecurity? _backofficeSecurity; + private readonly IMemberService _memberService; + private readonly IMemberTypeService _memberTypeService; + private readonly IShortStringHelper _shortStringHelper; + + public MemberSaveModelValidator( + ILogger logger, + IBackOfficeSecurity? backofficeSecurity, + IMemberTypeService memberTypeService, + IMemberService memberService, + IShortStringHelper shortStringHelper, + IPropertyValidationService propertyValidationService) + : base(logger, propertyValidationService) { - private readonly IBackOfficeSecurity? _backofficeSecurity; - private readonly IMemberTypeService _memberTypeService; - private readonly IMemberService _memberService; - private readonly IShortStringHelper _shortStringHelper; + _backofficeSecurity = backofficeSecurity; + _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); + _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + } - public MemberSaveModelValidator( - ILogger logger, - IBackOfficeSecurity? backofficeSecurity, - IMemberTypeService memberTypeService, - IMemberService memberService, - IShortStringHelper shortStringHelper, - IPropertyValidationService propertyValidationService) - : base(logger, propertyValidationService) + public override bool ValidatePropertiesData( + MemberSave? model, + IContentProperties? modelWithProperties, + ContentPropertyCollectionDto? dto, + ModelStateDictionary modelState) + { + if (model is null) { - _backofficeSecurity = backofficeSecurity; - _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); - _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + return false; } - public override bool ValidatePropertiesData(MemberSave? model, IContentProperties? modelWithProperties, ContentPropertyCollectionDto? dto, - ModelStateDictionary modelState) + if (model.Username.IsNullOrWhiteSpace()) { - if (model is null) - { - return false; - } - - if (model.Username.IsNullOrWhiteSpace()) - { - modelState.AddPropertyError( - new ValidationResult("Invalid user name", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); - } - - if (model.Email.IsNullOrWhiteSpace() || new EmailAddressAttribute().IsValid(model.Email) == false) - { - modelState.AddPropertyError( - new ValidationResult("Invalid email", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); - } - - var validEmail = ValidateUniqueEmail(model); - if (validEmail == false) - { - modelState.AddPropertyError( - new ValidationResult("Email address is already in use", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); - } - - var validLogin = ValidateUniqueLogin(model); - if (validLogin == false) - { - modelState.AddPropertyError( - new ValidationResult("Username is already in use", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); - } - - return base.ValidatePropertiesData(model, modelWithProperties, dto, modelState); + modelState.AddPropertyError( + new ValidationResult("Invalid user name", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); } - /// - /// This ensures that the internal membership property types are removed from validation before processing the validation - /// since those properties are actually mapped to real properties of the IMember. - /// This also validates any posted data for fields that are sensitive. - /// - /// - /// - /// - /// - public override bool ValidateProperties(MemberSave? model, IContentProperties? modelWithProperties, ActionExecutingContext actionContext) + if (model.Email.IsNullOrWhiteSpace() || new EmailAddressAttribute().IsValid(model.Email) == false) { - var propertiesToValidate = model?.Properties.ToList(); - var defaultProps = ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper); - var exclude = defaultProps.Select(x => x.Value.Alias).ToArray(); - foreach (var remove in exclude) - { - propertiesToValidate?.RemoveAll(property => property.Alias == remove); - } + modelState.AddPropertyError( + new ValidationResult("Invalid email", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); + } - //if the user doesn't have access to sensitive values, then we need to validate the incoming properties to check - //if a sensitive value is being submitted. - if (_backofficeSecurity?.CurrentUser?.HasAccessToSensitiveData() == false) - { - var contentType = _memberTypeService.Get(model?.PersistedContent?.ContentTypeId ?? default); - var sensitiveProperties = contentType? - .PropertyTypes.Where(x => contentType.IsSensitiveProperty(x.Alias)) - .ToList(); + var validEmail = ValidateUniqueEmail(model); + if (validEmail == false) + { + modelState.AddPropertyError( + new ValidationResult("Email address is already in use", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); + } - if (sensitiveProperties is not null) + var validLogin = ValidateUniqueLogin(model); + if (validLogin == false) + { + modelState.AddPropertyError( + new ValidationResult("Username is already in use", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); + } + + return base.ValidatePropertiesData(model, modelWithProperties, dto, modelState); + } + + /// + /// This ensures that the internal membership property types are removed from validation before processing the + /// validation + /// since those properties are actually mapped to real properties of the IMember. + /// This also validates any posted data for fields that are sensitive. + /// + /// + /// + /// + /// + public override bool ValidateProperties(MemberSave? model, IContentProperties? modelWithProperties, ActionExecutingContext actionContext) + { + var propertiesToValidate = model?.Properties.ToList(); + Dictionary defaultProps = + ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper); + var exclude = defaultProps.Select(x => x.Value.Alias).ToArray(); + foreach (var remove in exclude) + { + propertiesToValidate?.RemoveAll(property => property.Alias == remove); + } + + //if the user doesn't have access to sensitive values, then we need to validate the incoming properties to check + //if a sensitive value is being submitted. + if (_backofficeSecurity?.CurrentUser?.HasAccessToSensitiveData() == false) + { + IMemberType? contentType = _memberTypeService.Get(model?.PersistedContent?.ContentTypeId ?? default); + var sensitiveProperties = contentType? + .PropertyTypes.Where(x => contentType.IsSensitiveProperty(x.Alias)) + .ToList(); + + if (sensitiveProperties is not null) + { + foreach (IPropertyType sensitiveProperty in sensitiveProperties) { - foreach (var sensitiveProperty in sensitiveProperties) + ContentPropertyBasic? prop = + propertiesToValidate?.FirstOrDefault(x => x.Alias == sensitiveProperty.Alias); + + if (prop != null) { - var prop = propertiesToValidate?.FirstOrDefault(x => x.Alias == sensitiveProperty.Alias); + //this should not happen, this means that there was data posted for a sensitive property that + //the user doesn't have access to, which means that someone is trying to hack the values. - if (prop != null) - { - //this should not happen, this means that there was data posted for a sensitive property that - //the user doesn't have access to, which means that someone is trying to hack the values. - - var message = $"property with alias: {prop.Alias} cannot be posted"; - actionContext.Result = new NotFoundObjectResult(new InvalidOperationException(message)); - return false; - } + var message = $"property with alias: {prop.Alias} cannot be posted"; + actionContext.Result = new NotFoundObjectResult(new InvalidOperationException(message)); + return false; } } } - - return ValidateProperties(propertiesToValidate, model?.PersistedContent?.Properties.ToList(), actionContext); } - internal bool ValidateUniqueLogin(MemberSave model) + return ValidateProperties(propertiesToValidate, model?.PersistedContent?.Properties.ToList(), actionContext); + } + + internal bool ValidateUniqueLogin(MemberSave model) + { + if (model == null) { - if (model == null) throw new ArgumentNullException(nameof(model)); + throw new ArgumentNullException(nameof(model)); + } - var existingByName = _memberService.GetByUsername(model.Username.Trim()); - switch (model.Action) - { - case ContentSaveAction.Save: + IMember? existingByName = _memberService.GetByUsername(model.Username.Trim()); + switch (model.Action) + { + case ContentSaveAction.Save: - //ok, we're updating the member, we need to check if they are changing their login and if so, does it exist already ? - if (model.PersistedContent?.Username.InvariantEquals(model.Username.Trim()) == false) - { - //they are changing their login name - if (existingByName != null && existingByName.Username == model.Username.Trim()) - { - //the user cannot use this login - return false; - } - } - break; - case ContentSaveAction.SaveNew: - //check if the user's login already exists + //ok, we're updating the member, we need to check if they are changing their login and if so, does it exist already ? + if (model.PersistedContent?.Username.InvariantEquals(model.Username.Trim()) == false) + { + //they are changing their login name if (existingByName != null && existingByName.Username == model.Username.Trim()) { //the user cannot use this login return false; } - break; - default: - //we don't support this for members - throw new ArgumentOutOfRangeException(); - } + } - return true; + break; + case ContentSaveAction.SaveNew: + //check if the user's login already exists + if (existingByName != null && existingByName.Username == model.Username.Trim()) + { + //the user cannot use this login + return false; + } + + break; + default: + //we don't support this for members + throw new ArgumentOutOfRangeException(); } - internal bool ValidateUniqueEmail(MemberSave model) - { - if (model == null) throw new ArgumentNullException(nameof(model)); + return true; + } - var existingByEmail = _memberService.GetByEmail(model.Email.Trim()); - switch (model.Action) - { - case ContentSaveAction.Save: - //ok, we're updating the member, we need to check if they are changing their email and if so, does it exist already ? - if (model.PersistedContent?.Email.InvariantEquals(model.Email.Trim()) == false) - { - //they are changing their email - if (existingByEmail != null && existingByEmail.Email.InvariantEquals(model.Email.Trim())) - { - //the user cannot use this email - return false; - } - } - break; - case ContentSaveAction.SaveNew: - //check if the user's email already exists + internal bool ValidateUniqueEmail(MemberSave model) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + IMember? existingByEmail = _memberService.GetByEmail(model.Email.Trim()); + switch (model.Action) + { + case ContentSaveAction.Save: + //ok, we're updating the member, we need to check if they are changing their email and if so, does it exist already ? + if (model.PersistedContent?.Email.InvariantEquals(model.Email.Trim()) == false) + { + //they are changing their email if (existingByEmail != null && existingByEmail.Email.InvariantEquals(model.Email.Trim())) { //the user cannot use this email return false; } - break; - default: - //we don't support this for members - throw new ArgumentOutOfRangeException(); - } + } - return true; + break; + case ContentSaveAction.SaveNew: + //check if the user's email already exists + if (existingByEmail != null && existingByEmail.Email.InvariantEquals(model.Email.Trim())) + { + //the user cannot use this email + return false; + } + + break; + default: + //we don't support this for members + throw new ArgumentOutOfRangeException(); } + + return true; } } diff --git a/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs index 3175f703f0..61e119b66a 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; @@ -7,57 +6,66 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; -namespace Umbraco.Cms.Web.BackOffice.Filters -{ - /// - /// Validates the incoming model - /// - internal sealed class MemberSaveValidationAttribute : TypeFilterAttribute - { - public MemberSaveValidationAttribute() : base(typeof(MemberSaveValidationFilter)) - { +namespace Umbraco.Cms.Web.BackOffice.Filters; +/// +/// Validates the incoming model +/// +internal sealed class MemberSaveValidationAttribute : TypeFilterAttribute +{ + public MemberSaveValidationAttribute() : base(typeof(MemberSaveValidationFilter)) + { + } + + private sealed class MemberSaveValidationFilter : IActionFilter + { + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly ILoggerFactory _loggerFactory; + private readonly IMemberService _memberService; + private readonly IMemberTypeService _memberTypeService; + private readonly IPropertyValidationService _propertyValidationService; + private readonly IShortStringHelper _shortStringHelper; + + public MemberSaveValidationFilter( + ILoggerFactory loggerFactory, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IMemberTypeService memberTypeService, + IMemberService memberService, + IShortStringHelper shortStringHelper, + IPropertyValidationService propertyValidationService) + { + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _backofficeSecurityAccessor = backofficeSecurityAccessor ?? + throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); + _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); + _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _propertyValidationService = propertyValidationService ?? + throw new ArgumentNullException(nameof(propertyValidationService)); } - private sealed class MemberSaveValidationFilter : IActionFilter + public void OnActionExecuting(ActionExecutingContext context) { - private readonly ILoggerFactory _loggerFactory; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IMemberTypeService _memberTypeService; - private readonly IMemberService _memberService; - private readonly IShortStringHelper _shortStringHelper; - private readonly IPropertyValidationService _propertyValidationService; - - public MemberSaveValidationFilter( - ILoggerFactory loggerFactory, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IMemberTypeService memberTypeService, - IMemberService memberService, - IShortStringHelper shortStringHelper, - IPropertyValidationService propertyValidationService) - { - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); - _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _propertyValidationService = propertyValidationService ?? throw new ArgumentNullException(nameof(propertyValidationService)); - } - - public void OnActionExecuting(ActionExecutingContext context) - { - var model = (MemberSave?)context.ActionArguments["contentItem"]; - var contentItemValidator = new MemberSaveModelValidator(_loggerFactory.CreateLogger(), _backofficeSecurityAccessor.BackOfficeSecurity, _memberTypeService, _memberService, _shortStringHelper, _propertyValidationService); - //now do each validation step - if (contentItemValidator.ValidateExistingContent(model, context)) - if (contentItemValidator.ValidateProperties(model, model, context)) - contentItemValidator.ValidatePropertiesData(model, model, model?.PropertyCollectionDto, context.ModelState); - } - - public void OnActionExecuted(ActionExecutedContext context) + var model = (MemberSave?)context.ActionArguments["contentItem"]; + var contentItemValidator = new MemberSaveModelValidator( + _loggerFactory.CreateLogger(), + _backofficeSecurityAccessor.BackOfficeSecurity, + _memberTypeService, + _memberService, + _shortStringHelper, + _propertyValidationService); + //now do each validation step + if (contentItemValidator.ValidateExistingContent(model, context)) { + if (contentItemValidator.ValidateProperties(model, model, context)) + { + contentItemValidator.ValidatePropertiesData(model, model, model?.PropertyCollectionDto, context.ModelState); + } } + } + public void OnActionExecuted(ActionExecutedContext context) + { } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/MinifyJavaScriptResultAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/MinifyJavaScriptResultAttribute.cs index 4a9b7aaa72..0e11dd9fc3 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MinifyJavaScriptResultAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MinifyJavaScriptResultAttribute.cs @@ -1,37 +1,35 @@ -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.WebAssets; using Umbraco.Cms.Web.BackOffice.ActionResults; -namespace Umbraco.Cms.Web.BackOffice.Filters -{ - public class MinifyJavaScriptResultAttribute : ActionFilterAttribute - { - public override async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) - { - // logic before action goes here - var serviceProvider = context.HttpContext.RequestServices; - var hostingEnvironment = serviceProvider.GetService(); - if (!hostingEnvironment?.IsDebugMode ?? false) - { - var runtimeMinifier = serviceProvider.GetService(); +namespace Umbraco.Cms.Web.BackOffice.Filters; - if (context.Result is JavaScriptResult jsResult) +public class MinifyJavaScriptResultAttribute : ActionFilterAttribute +{ + public override async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + // logic before action goes here + IServiceProvider serviceProvider = context.HttpContext.RequestServices; + IHostingEnvironment? hostingEnvironment = serviceProvider.GetService(); + if (!hostingEnvironment?.IsDebugMode ?? false) + { + IRuntimeMinifier? runtimeMinifier = serviceProvider.GetService(); + + if (context.Result is JavaScriptResult jsResult) + { + var result = jsResult.Content; + if (runtimeMinifier is not null) { - var result = jsResult.Content; - if (runtimeMinifier is not null) - { - var minified = await runtimeMinifier.MinifyAsync(result, AssetType.Javascript); - jsResult.Content = minified; - } + var minified = await runtimeMinifier.MinifyAsync(result, AssetType.Javascript); + jsResult.Content = minified; } } - - await next(); // the actual action - - // logic after the action goes here } + + await next(); // the actual action + + // logic after the action goes here } } diff --git a/src/Umbraco.Web.BackOffice/Filters/OutgoingEditorModelEventAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/OutgoingEditorModelEventAttribute.cs index 64ac33b1aa..8c4db1a041 100644 --- a/src/Umbraco.Web.BackOffice/Filters/OutgoingEditorModelEventAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/OutgoingEditorModelEventAttribute.cs @@ -1,103 +1,107 @@ -using System; using System.Collections; -using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Umbraco.Cms.Core.Dashboards; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Web; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Used to emit outgoing editor model events +/// +internal sealed class OutgoingEditorModelEventAttribute : TypeFilterAttribute { - /// - /// Used to emit outgoing editor model events - /// - internal sealed class OutgoingEditorModelEventAttribute : TypeFilterAttribute + public OutgoingEditorModelEventAttribute() : base(typeof(OutgoingEditorModelEventFilter)) { - public OutgoingEditorModelEventAttribute() : base(typeof(OutgoingEditorModelEventFilter)) + } + + + private class OutgoingEditorModelEventFilter : IActionFilter + { + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + private readonly IEventAggregator _eventAggregator; + + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + + public OutgoingEditorModelEventFilter( + IUmbracoContextAccessor umbracoContextAccessor, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IEventAggregator eventAggregator) { + _umbracoContextAccessor = umbracoContextAccessor + ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); + _backOfficeSecurityAccessor = backOfficeSecurityAccessor + ?? throw new ArgumentNullException(nameof(backOfficeSecurityAccessor)); + _eventAggregator = eventAggregator + ?? throw new ArgumentNullException(nameof(eventAggregator)); } - - private class OutgoingEditorModelEventFilter : IActionFilter + public void OnActionExecuted(ActionExecutedContext context) { - - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - - private readonly IEventAggregator _eventAggregator; - - public OutgoingEditorModelEventFilter( - IUmbracoContextAccessor umbracoContextAccessor, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IEventAggregator eventAggregator) + if (context.Result == null) { - _umbracoContextAccessor = umbracoContextAccessor - ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); - _backOfficeSecurityAccessor = backOfficeSecurityAccessor - ?? throw new ArgumentNullException(nameof(backOfficeSecurityAccessor)); - _eventAggregator = eventAggregator - ?? throw new ArgumentNullException(nameof(eventAggregator)); + return; } - public void OnActionExecuted(ActionExecutedContext context) + IUmbracoContext umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); + IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + if (currentUser == null) { - if (context.Result == null) return; + return; + } - var umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext(); - var currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - if (currentUser == null) return; - - if (context.Result is ObjectResult objectContent) + if (context.Result is ObjectResult objectContent) + { + // Support both batch (dictionary) and single results + IEnumerable models; + if (objectContent.Value is IDictionary modelDictionary) { - // Support both batch (dictionary) and single results - IEnumerable models; - if (objectContent.Value is IDictionary modelDictionary) - { - models = modelDictionary.Values; - } - else - { - models = new[] { objectContent.Value }; - } + models = modelDictionary.Values; + } + else + { + models = new[] { objectContent.Value }; + } - foreach (var model in models) + foreach (var model in models) + { + switch (model) { - switch (model) - { - case ContentItemDisplay content: - _eventAggregator.Publish(new SendingContentNotification(content, umbracoContext)); - break; - case MediaItemDisplay media: - _eventAggregator.Publish(new SendingMediaNotification(media, umbracoContext)); - break; - case MemberDisplay member: - _eventAggregator.Publish(new SendingMemberNotification(member, umbracoContext)); - break; - case UserDisplay user: - _eventAggregator.Publish(new SendingUserNotification(user, umbracoContext)); - break; - case IEnumerable> dashboards: - _eventAggregator.Publish(new SendingDashboardsNotification(dashboards, umbracoContext)); - break; - case IEnumerable allowedChildren: - // Changing the Enumerable will generate a new instance, so we need to update the context result with the new content - var notification = new SendingAllowedChildrenNotification(allowedChildren, umbracoContext); - _eventAggregator.Publish(notification); - context.Result = new ObjectResult(notification.Children); - break; - } + case ContentItemDisplay content: + _eventAggregator.Publish(new SendingContentNotification(content, umbracoContext)); + break; + case MediaItemDisplay media: + _eventAggregator.Publish(new SendingMediaNotification(media, umbracoContext)); + break; + case MemberDisplay member: + _eventAggregator.Publish(new SendingMemberNotification(member, umbracoContext)); + break; + case UserDisplay user: + _eventAggregator.Publish(new SendingUserNotification(user, umbracoContext)); + break; + case IEnumerable> dashboards: + _eventAggregator.Publish(new SendingDashboardsNotification(dashboards, umbracoContext)); + break; + case IEnumerable allowedChildren: + // Changing the Enumerable will generate a new instance, so we need to update the context result with the new content + var notification = new SendingAllowedChildrenNotification(allowedChildren, umbracoContext); + _eventAggregator.Publish(notification); + context.Result = new ObjectResult(notification.Children); + break; } } } + } - public void OnActionExecuting(ActionExecutingContext context) - { - } + public void OnActionExecuting(ActionExecutingContext context) + { } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/PrefixlessBodyModelValidatorAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/PrefixlessBodyModelValidatorAttribute.cs index de23791720..a2bccb9814 100644 --- a/src/Umbraco.Web.BackOffice/Filters/PrefixlessBodyModelValidatorAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/PrefixlessBodyModelValidatorAttribute.cs @@ -1,53 +1,54 @@ -using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Applying this attribute to any controller will ensure that the parameter name (prefix) is not part of the +/// validation error keys. +/// +public class PrefixlessBodyModelValidatorAttribute : TypeFilterAttribute { - /// - /// Applying this attribute to any controller will ensure that the parameter name (prefix) is not part of the validation error keys. - /// - public class PrefixlessBodyModelValidatorAttribute : TypeFilterAttribute + //TODO: Could be a better solution to replace the IModelValidatorProvider and ensure the errors are created + //without the prefix, instead of removing it afterwards. But I couldn't find any way to do this for only some + //of the controllers. IObjectModelValidator seems to be the interface to implement and replace in the container + //to handle it for the entire solution. + public PrefixlessBodyModelValidatorAttribute() : base(typeof(PrefixlessBodyModelValidatorFilter)) { - //TODO: Could be a better solution to replace the IModelValidatorProvider and ensure the errors are created - //without the prefix, instead of removing it afterwards. But I couldn't find any way to do this for only some - //of the controllers. IObjectModelValidator seems to be the interface to implement and replace in the container - //to handle it for the entire solution. - public PrefixlessBodyModelValidatorAttribute() : base(typeof(PrefixlessBodyModelValidatorFilter)) + } + + private class PrefixlessBodyModelValidatorFilter : IActionFilter + { + public void OnActionExecuted(ActionExecutedContext context) { } - private class PrefixlessBodyModelValidatorFilter : IActionFilter + public void OnActionExecuting(ActionExecutingContext context) { - public void OnActionExecuted(ActionExecutedContext context) + if (context.ModelState.IsValid) { + return; } - public void OnActionExecuting(ActionExecutingContext context) + //Remove prefix from errors + foreach (KeyValuePair modelStateItem in context.ModelState) { - if (context.ModelState.IsValid) return; - - //Remove prefix from errors - foreach (var modelStateItem in context.ModelState) + foreach (var prefix in context.ActionArguments.Keys) { - foreach (var prefix in context.ActionArguments.Keys) + if (modelStateItem.Key.StartsWith(prefix)) { - if (modelStateItem.Key.StartsWith(prefix)) + if (modelStateItem.Value.Errors.Any()) { - if (modelStateItem.Value.Errors.Any()) + var newKey = modelStateItem.Key.Substring(prefix.Length).TrimStart('.'); + foreach (ModelError valueError in modelStateItem.Value.Errors) { - - var newKey = modelStateItem.Key.Substring(prefix.Length).TrimStart('.'); - foreach (var valueError in modelStateItem.Value.Errors) - { - context.ModelState.TryAddModelError(newKey, valueError.ErrorMessage); - } - context.ModelState.Remove(modelStateItem.Key); - + context.ModelState.TryAddModelError(newKey, valueError.ErrorMessage); } + + context.ModelState.Remove(modelStateItem.Key); } } - } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/SetAngularAntiForgeryTokensAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/SetAngularAntiForgeryTokensAttribute.cs index d869c93108..da58cf0b3a 100644 --- a/src/Umbraco.Web.BackOffice/Filters/SetAngularAntiForgeryTokensAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/SetAngularAntiForgeryTokensAttribute.cs @@ -1,40 +1,37 @@ using System.Net; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Umbraco.Cms.Web.BackOffice.Security; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// An attribute/filter to set the csrf cookie token based on angular conventions +/// +public class SetAngularAntiForgeryTokensAttribute : TypeFilterAttribute { - /// - /// An attribute/filter to set the csrf cookie token based on angular conventions - /// - public class SetAngularAntiForgeryTokensAttribute : TypeFilterAttribute + public SetAngularAntiForgeryTokensAttribute() : base(typeof(SetAngularAntiForgeryTokensFilter)) { - public SetAngularAntiForgeryTokensAttribute() : base(typeof(SetAngularAntiForgeryTokensFilter)) + } + + internal class SetAngularAntiForgeryTokensFilter : IAsyncActionFilter + { + private readonly IBackOfficeAntiforgery _antiforgery; + + public SetAngularAntiForgeryTokensFilter(IBackOfficeAntiforgery antiforgery) + => _antiforgery = antiforgery; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - } + await next(); - internal class SetAngularAntiForgeryTokensFilter : IAsyncActionFilter - { - private readonly IBackOfficeAntiforgery _antiforgery; - - public SetAngularAntiForgeryTokensFilter(IBackOfficeAntiforgery antiforgery) - => _antiforgery = antiforgery; - - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + // anti forgery tokens are based on the currently logged + // in user assigned to the HttpContext which will be assigned during signin so + // we can only execute after the action. + if (context.HttpContext.Response?.StatusCode == (int)HttpStatusCode.OK) { - await next(); - - // anti forgery tokens are based on the currently logged - // in user assigned to the HttpContext which will be assigned during signin so - // we can only execute after the action. - if (context.HttpContext.Response?.StatusCode == (int)HttpStatusCode.OK) - { - _antiforgery.GetAndStoreTokens(context.HttpContext); - } + _antiforgery.GetAndStoreTokens(context.HttpContext); } - } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/UmbracoRequireHttpsAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/UmbracoRequireHttpsAttribute.cs index a782d73550..6857f9be8e 100644 --- a/src/Umbraco.Web.BackOffice/Filters/UmbracoRequireHttpsAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/UmbracoRequireHttpsAttribute.cs @@ -1,27 +1,27 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// If Umbraco.Core.UseHttps property in web.config is set to true, this filter will redirect any http access to https. +/// +public class UmbracoRequireHttpsAttribute : RequireHttpsAttribute { - /// - /// If Umbraco.Core.UseHttps property in web.config is set to true, this filter will redirect any http access to https. - /// - public class UmbracoRequireHttpsAttribute : RequireHttpsAttribute + protected override void HandleNonHttpsRequest(AuthorizationFilterContext filterContext) { - protected override void HandleNonHttpsRequest(AuthorizationFilterContext filterContext) + // just like the base class does, we'll just resolve the required services from the httpcontext. + // we want to re-use their code so we don't have much choice, else we have to do some code tricks, + // this is just easiest. + IOptionsSnapshot optionsAccessor = filterContext.HttpContext.RequestServices + .GetRequiredService>(); + if (optionsAccessor.Value.UseHttps) { - // just like the base class does, we'll just resolve the required services from the httpcontext. - // we want to re-use their code so we don't have much choice, else we have to do some code tricks, - // this is just easiest. - var optionsAccessor = filterContext.HttpContext.RequestServices.GetRequiredService>(); - if (optionsAccessor.Value.UseHttps) - { - // only continue if this flag is set - base.HandleNonHttpsRequest(filterContext); - } + // only continue if this flag is set + base.HandleNonHttpsRequest(filterContext); } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/UnhandledExceptionLoggerFilter.cs b/src/Umbraco.Web.BackOffice/Filters/UnhandledExceptionLoggerFilter.cs index dd955d11f2..2abcaff85f 100644 --- a/src/Umbraco.Web.BackOffice/Filters/UnhandledExceptionLoggerFilter.cs +++ b/src/Umbraco.Web.BackOffice/Filters/UnhandledExceptionLoggerFilter.cs @@ -1,17 +1,14 @@ using Microsoft.AspNetCore.Builder; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Applies the UnhandledExceptionLoggerMiddleware to a specific controller +/// when used with this attribute [MiddlewareFilter(typeof(UnhandledExceptionLoggerFilter))] +/// The middleware will run in the filter pipeline, at the same stage as resource filters +/// +public class UnhandledExceptionLoggerFilter { - /// - /// Applies the UnhandledExceptionLoggerMiddleware to a specific controller - /// when used with this attribute [MiddlewareFilter(typeof(UnhandledExceptionLoggerFilter))] - /// The middleware will run in the filter pipeline, at the same stage as resource filters - /// - public class UnhandledExceptionLoggerFilter - { - public void Configure(IApplicationBuilder applicationBuilder) - { - applicationBuilder.UseMiddleware(); - } - } + public void Configure(IApplicationBuilder applicationBuilder) => + applicationBuilder.UseMiddleware(); } diff --git a/src/Umbraco.Web.BackOffice/Filters/UnhandledExceptionLoggerMiddleware.cs b/src/Umbraco.Web.BackOffice/Filters/UnhandledExceptionLoggerMiddleware.cs index 42662cf548..06eaf3fd30 100644 --- a/src/Umbraco.Web.BackOffice/Filters/UnhandledExceptionLoggerMiddleware.cs +++ b/src/Umbraco.Web.BackOffice/Filters/UnhandledExceptionLoggerMiddleware.cs @@ -1,44 +1,39 @@ -using System; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Logging; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// Logs any unhandled exception. +/// +public class UnhandledExceptionLoggerMiddleware : IMiddleware { - /// - /// Logs any unhandled exception. - /// - public class UnhandledExceptionLoggerMiddleware : IMiddleware + private readonly ILogger _logger; + + public UnhandledExceptionLoggerMiddleware(ILogger logger) => _logger = logger; + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { - private readonly ILogger _logger; - - public UnhandledExceptionLoggerMiddleware(ILogger logger) + // If it's a client side request just call next and don't try to log anything + if (context.Request.IsClientSideRequest()) { - _logger = logger; + await next(context); } - - public async Task InvokeAsync(HttpContext context, RequestDelegate next) + else { - // If it's a client side request just call next and don't try to log anything - if (context.Request.IsClientSideRequest()) + // Call the next middleware, and catch any errors that occurs in the rest of the pipeline + try { await next(context); } - else + catch (Exception e) { - // Call the next middleware, and catch any errors that occurs in the rest of the pipeline - try - { - await next(context); - } - catch (Exception e) - { - _logger.LogError(e, "Unhandled controller exception occurred for request '{RequestUrl}'", context.Request.GetEncodedPathAndQuery()); - // Throw the error again, just in case it gets handled - throw; - } + _logger.LogError(e, "Unhandled controller exception occurred for request '{RequestUrl}'", + context.Request.GetEncodedPathAndQuery()); + // Throw the error again, just in case it gets handled + throw; } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs index cea09b6c0a..73cf10d983 100644 --- a/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs @@ -1,8 +1,6 @@ -using System; using System.Net; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Services; @@ -11,88 +9,87 @@ using Umbraco.Cms.Web.BackOffice.ActionResults; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +internal sealed class UserGroupValidateAttribute : TypeFilterAttribute { - internal sealed class UserGroupValidateAttribute : TypeFilterAttribute + public UserGroupValidateAttribute() : base(typeof(UserGroupValidateFilter)) { - public UserGroupValidateAttribute() : base(typeof(UserGroupValidateFilter)) + } + + private class UserGroupValidateFilter : IActionFilter + { + private readonly IShortStringHelper _shortStringHelper; + private readonly IUserService _userService; + + public UserGroupValidateFilter( + IUserService userService, + IShortStringHelper shortStringHelper) { + _userService = userService ?? throw new ArgumentNullException(nameof(userService)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); } - private class UserGroupValidateFilter : IActionFilter + public void OnActionExecuting(ActionExecutingContext context) { - private readonly IShortStringHelper _shortStringHelper; - private readonly IUserService _userService; + var userGroupSave = (UserGroupSave?)context.ActionArguments["userGroupSave"]; - public UserGroupValidateFilter( - IUserService userService, - IShortStringHelper shortStringHelper) + if (userGroupSave is not null) { - _userService = userService ?? throw new ArgumentNullException(nameof(userService)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + userGroupSave.Name = userGroupSave.Name?.CleanForXss('[', ']', '(', ')', ':'); + userGroupSave.Alias = userGroupSave.Alias.CleanForXss('[', ']', '(', ')', ':'); } - public void OnActionExecuting(ActionExecutingContext context) + //Validate the usergroup exists or create one if required + IUserGroup? persisted; + switch (userGroupSave?.Action) { - var userGroupSave = (UserGroupSave?) context.ActionArguments["userGroupSave"]; - - if (userGroupSave is not null) - { - userGroupSave.Name = userGroupSave.Name?.CleanForXss('[', ']', '(', ')', ':'); - userGroupSave.Alias = userGroupSave.Alias.CleanForXss('[', ']', '(', ')', ':'); - } - - //Validate the usergroup exists or create one if required - IUserGroup? persisted; - switch (userGroupSave?.Action) - { - case ContentSaveAction.Save: - persisted = _userService.GetUserGroupById(Convert.ToInt32(userGroupSave.Id)); - if (persisted == null) - { - var message = $"User group with id: {userGroupSave.Id} was not found"; - context.Result = new UmbracoErrorResult(HttpStatusCode.NotFound, message); - return; - } - - if (persisted.Alias != userGroupSave.Alias && persisted.IsSystemUserGroup()) - { - var message = $"User group with alias: {persisted.Alias} cannot be changed"; - context.Result = new UmbracoErrorResult(HttpStatusCode.BadRequest, message); - return; - } - - break; - case ContentSaveAction.SaveNew: - persisted = new UserGroup(_shortStringHelper); - break; - default: - context.Result = - new UmbracoErrorResult(HttpStatusCode.NotFound, new ArgumentOutOfRangeException()); + case ContentSaveAction.Save: + persisted = _userService.GetUserGroupById(Convert.ToInt32(userGroupSave.Id)); + if (persisted == null) + { + var message = $"User group with id: {userGroupSave.Id} was not found"; + context.Result = new UmbracoErrorResult(HttpStatusCode.NotFound, message); return; - } + } - //now assign the persisted entity to the model so we can use it in the action - userGroupSave.PersistedUserGroup = persisted; + if (persisted.Alias != userGroupSave.Alias && persisted.IsSystemUserGroup()) + { + var message = $"User group with alias: {persisted.Alias} cannot be changed"; + context.Result = new UmbracoErrorResult(HttpStatusCode.BadRequest, message); + return; + } - var existing = _userService.GetUserGroupByAlias(userGroupSave.Alias); - if (existing != null && existing.Id != userGroupSave.PersistedUserGroup.Id) - { - context.ModelState.AddModelError("Alias", "A user group with this alias already exists"); - } - - // TODO: Validate the name is unique? - - if (context.ModelState.IsValid == false) - { - //if it is not valid, do not continue and return the model state - context.Result = new ValidationErrorResult(context.ModelState); - } + break; + case ContentSaveAction.SaveNew: + persisted = new UserGroup(_shortStringHelper); + break; + default: + context.Result = + new UmbracoErrorResult(HttpStatusCode.NotFound, new ArgumentOutOfRangeException()); + return; } - public void OnActionExecuted(ActionExecutedContext context) + //now assign the persisted entity to the model so we can use it in the action + userGroupSave.PersistedUserGroup = persisted; + + IUserGroup? existing = _userService.GetUserGroupByAlias(userGroupSave.Alias); + if (existing != null && existing.Id != userGroupSave.PersistedUserGroup.Id) { + context.ModelState.AddModelError("Alias", "A user group with this alias already exists"); } + + // TODO: Validate the name is unique? + + if (context.ModelState.IsValid == false) + { + //if it is not valid, do not continue and return the model state + context.Result = new ValidationErrorResult(context.ModelState); + } + } + + public void OnActionExecuted(ActionExecutedContext context) + { } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/ValidateAngularAntiForgeryTokenAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/ValidateAngularAntiForgeryTokenAttribute.cs index 31187d8350..c68f0fb0cf 100644 --- a/src/Umbraco.Web.BackOffice/Filters/ValidateAngularAntiForgeryTokenAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/ValidateAngularAntiForgeryTokenAttribute.cs @@ -1,53 +1,53 @@ using System.Net; using System.Security.Claims; -using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Umbraco.Cms.Core; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// An attribute/filter to check for the csrf token based on Angular's standard approach +/// +public sealed class ValidateAngularAntiForgeryTokenAttribute : TypeFilterAttribute { - /// - /// An attribute/filter to check for the csrf token based on Angular's standard approach - /// - public sealed class ValidateAngularAntiForgeryTokenAttribute : TypeFilterAttribute + public ValidateAngularAntiForgeryTokenAttribute() + : base(typeof(ValidateAngularAntiForgeryTokenFilter)) { - public ValidateAngularAntiForgeryTokenAttribute() - : base(typeof(ValidateAngularAntiForgeryTokenFilter)) + } + + private class ValidateAngularAntiForgeryTokenFilter : IAsyncActionFilter + { + private readonly IBackOfficeAntiforgery _antiforgery; + + public ValidateAngularAntiForgeryTokenFilter(IBackOfficeAntiforgery antiforgery) => _antiforgery = antiforgery; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - } - - private class ValidateAngularAntiForgeryTokenFilter : IAsyncActionFilter - { - private readonly IBackOfficeAntiforgery _antiforgery; - - public ValidateAngularAntiForgeryTokenFilter(IBackOfficeAntiforgery antiforgery) => _antiforgery = antiforgery; - - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + if (context.Controller is ControllerBase controller && + controller.User.Identity is ClaimsIdentity userIdentity) { - if (context.Controller is ControllerBase controller && controller.User.Identity is ClaimsIdentity userIdentity) + // if there is not CookiePath claim, then exit + if (userIdentity.HasClaim(x => x.Type == ClaimTypes.CookiePath) == false) { - // if there is not CookiePath claim, then exit - if (userIdentity.HasClaim(x => x.Type == ClaimTypes.CookiePath) == false) - { - await next(); - return; - } - } - - var httpContext = context.HttpContext; - var validateResult = await _antiforgery.ValidateRequestAsync(httpContext); - if (!validateResult.Success) - { - httpContext.SetReasonPhrase(validateResult.Result); - context.Result = new StatusCodeResult((int)HttpStatusCode.ExpectationFailed); + await next(); return; } - - await next(); } + HttpContext httpContext = context.HttpContext; + Attempt validateResult = await _antiforgery.ValidateRequestAsync(httpContext); + if (!validateResult.Success) + { + httpContext.SetReasonPhrase(validateResult.Result); + context.Result = new StatusCodeResult((int)HttpStatusCode.ExpectationFailed); + return; + } + + await next(); } } } diff --git a/src/Umbraco.Web.BackOffice/Filters/ValidationFilterAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/ValidationFilterAttribute.cs index b29607a9c7..61e4f53867 100644 --- a/src/Umbraco.Web.BackOffice/Filters/ValidationFilterAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/ValidationFilterAttribute.cs @@ -1,21 +1,21 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace Umbraco.Cms.Web.BackOffice.Filters +namespace Umbraco.Cms.Web.BackOffice.Filters; + +/// +/// An action filter used to do basic validation against the model and return a result +/// straight away if it fails. +/// +internal sealed class ValidationFilterAttribute : ActionFilterAttribute { - /// - /// An action filter used to do basic validation against the model and return a result - /// straight away if it fails. - /// - internal sealed class ValidationFilterAttribute : ActionFilterAttribute + public override void OnActionExecuting(ActionExecutingContext context) { - public override void OnActionExecuting(ActionExecutingContext context) + ModelStateDictionary modelState = context.ModelState; + if (!modelState.IsValid) { - var modelState = context.ModelState; - if (!modelState.IsValid) - { - context.Result = new BadRequestObjectResult(modelState); - } + context.Result = new BadRequestObjectResult(modelState); } } } diff --git a/src/Umbraco.Web.BackOffice/HealthChecks/HealthCheckController.cs b/src/Umbraco.Web.BackOffice/HealthChecks/HealthCheckController.cs index cf2264ab5c..34adf15a94 100644 --- a/src/Umbraco.Web.BackOffice/HealthChecks/HealthCheckController.cs +++ b/src/Umbraco.Web.BackOffice/HealthChecks/HealthCheckController.cs @@ -1,116 +1,112 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.HealthChecks; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.HealthChecks +namespace Umbraco.Cms.Web.BackOffice.HealthChecks; + +/// +/// The API controller used to display the health check info and execute any actions +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] +public class HealthCheckController : UmbracoAuthorizedJsonController { + private readonly HealthCheckCollection _checks; + private readonly IList _disabledCheckIds; + private readonly ILogger _logger; + /// - /// The API controller used to display the health check info and execute any actions + /// Initializes a new instance of the class. /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] - public class HealthCheckController : UmbracoAuthorizedJsonController + public HealthCheckController(HealthCheckCollection checks, ILogger logger, IOptions healthChecksSettings) { - private readonly HealthCheckCollection _checks; - private readonly IList _disabledCheckIds; - private readonly ILogger _logger; + _checks = checks ?? throw new ArgumentNullException(nameof(checks)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - /// - /// Initializes a new instance of the class. - /// - public HealthCheckController(HealthCheckCollection checks, ILogger logger, IOptions healthChecksSettings) + HealthChecksSettings healthCheckConfig = + healthChecksSettings.Value ?? throw new ArgumentNullException(nameof(healthChecksSettings)); + _disabledCheckIds = healthCheckConfig.DisabledChecks + .Select(x => x.Id) + .ToList(); + } + + /// + /// Gets a grouped list of health checks, but doesn't actively check the status of each health check. + /// + /// Returns a collection of anonymous objects representing each group. + public object GetAllHealthChecks() + { + IOrderedEnumerable> groups = _checks + .Where(x => _disabledCheckIds.Contains(x.Id) == false) + .GroupBy(x => x.Group) + .OrderBy(x => x.Key); + var healthCheckGroups = new List(); + foreach (IGrouping healthCheckGroup in groups) { - _checks = checks ?? throw new ArgumentNullException(nameof(checks)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - HealthChecksSettings healthCheckConfig = healthChecksSettings.Value ?? throw new ArgumentNullException(nameof(healthChecksSettings)); - _disabledCheckIds = healthCheckConfig.DisabledChecks - .Select(x => x.Id) - .ToList(); + var hcGroup = new HealthCheckGroup + { + Name = healthCheckGroup.Key, + Checks = healthCheckGroup + .OrderBy(x => x.Name) + .ToList() + }; + healthCheckGroups.Add(hcGroup); } - /// - /// Gets a grouped list of health checks, but doesn't actively check the status of each health check. - /// - /// Returns a collection of anonymous objects representing each group. - public object GetAllHealthChecks() - { - IOrderedEnumerable> groups = _checks - .Where(x => _disabledCheckIds.Contains(x.Id) == false) - .GroupBy(x => x.Group) - .OrderBy(x => x.Key); - var healthCheckGroups = new List(); - foreach (IGrouping healthCheckGroup in groups) - { - var hcGroup = new HealthCheckGroup - { - Name = healthCheckGroup.Key, - Checks = healthCheckGroup - .OrderBy(x => x.Name) - .ToList() - }; - healthCheckGroups.Add(hcGroup); - } + return healthCheckGroups; + } - return healthCheckGroups; + /// + /// Gets the status of the HealthCheck with the specified id. + /// + [HttpGet] + public async Task GetStatus(Guid id) + { + HealthCheck check = GetCheckById(id); + + try + { + _logger.LogDebug("Running health check: " + check.Name); + return await check.GetStatus(); } - - /// - /// Gets the status of the HealthCheck with the specified id. - /// - [HttpGet] - public async Task GetStatus(Guid id) + catch (Exception ex) { - HealthCheck check = GetCheckById(id); - - try - { - _logger.LogDebug("Running health check: " + check.Name); - return await check.GetStatus(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Exception in health check: {HealthCheckName}", check.Name); - throw; - } - } - - /// - /// Executes a given action from a HealthCheck. - /// - [HttpPost] - public HealthCheckStatus ExecuteAction(HealthCheckAction action) - { - HealthCheck check = GetCheckById(action.HealthCheckId); - return check.ExecuteAction(action); - } - - private HealthCheck GetCheckById(Guid? id) - { - HealthCheck? check = _checks - .Where(x => _disabledCheckIds.Contains(x.Id) == false) - .FirstOrDefault(x => x.Id == id); - - if (check == null) - { - throw new InvalidOperationException($"No health check found with id {id}"); - } - - return check; + _logger.LogError(ex, "Exception in health check: {HealthCheckName}", check.Name); + throw; } } + + /// + /// Executes a given action from a HealthCheck. + /// + [HttpPost] + public HealthCheckStatus ExecuteAction(HealthCheckAction action) + { + HealthCheck check = GetCheckById(action.HealthCheckId); + return check.ExecuteAction(action); + } + + private HealthCheck GetCheckById(Guid? id) + { + HealthCheck? check = _checks + .Where(x => _disabledCheckIds.Contains(x.Id) == false) + .FirstOrDefault(x => x.Id == id); + + if (check == null) + { + throw new InvalidOperationException($"No health check found with id {id}"); + } + + return check; + } } diff --git a/src/Umbraco.Web.BackOffice/Install/CreateUnattendedUserNotificationHandler.cs b/src/Umbraco.Web.BackOffice/Install/CreateUnattendedUserNotificationHandler.cs index b5f691162e..13896c8912 100644 --- a/src/Umbraco.Web.BackOffice/Install/CreateUnattendedUserNotificationHandler.cs +++ b/src/Umbraco.Web.BackOffice/Install/CreateUnattendedUserNotificationHandler.cs @@ -1,9 +1,7 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models.Membership; @@ -12,89 +10,93 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Install +namespace Umbraco.Cms.Web.BackOffice.Install; + +public class CreateUnattendedUserNotificationHandler : INotificationAsyncHandler { - public class CreateUnattendedUserNotificationHandler : INotificationAsyncHandler + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IOptions _unattendedSettings; + private readonly IUserService _userService; + + public CreateUnattendedUserNotificationHandler(IOptions unattendedSettings, + IUserService userService, IServiceScopeFactory serviceScopeFactory) { - private readonly IOptions _unattendedSettings; - private readonly IUserService _userService; - private readonly IServiceScopeFactory _serviceScopeFactory; + _unattendedSettings = unattendedSettings; + _userService = userService; + _serviceScopeFactory = serviceScopeFactory; + } - public CreateUnattendedUserNotificationHandler(IOptions unattendedSettings, IUserService userService, IServiceScopeFactory serviceScopeFactory) + /// + /// Listening for when the UnattendedInstallNotification fired after a sucessfulk + /// + /// + public async Task HandleAsync(UnattendedInstallNotification notification, CancellationToken cancellationToken) + { + UnattendedSettings? unattendedSettings = _unattendedSettings.Value; + // Ensure we have the setting enabled (Sanity check) + // In theory this should always be true as the event only fired when a sucessfull + if (_unattendedSettings.Value.InstallUnattended == false) { - _unattendedSettings = unattendedSettings; - _userService = userService; - _serviceScopeFactory = serviceScopeFactory; + return; } - /// - /// Listening for when the UnattendedInstallNotification fired after a sucessfulk - /// - /// - public async Task HandleAsync(UnattendedInstallNotification notification, CancellationToken cancellationToken) + var unattendedName = unattendedSettings.UnattendedUserName; + var unattendedEmail = unattendedSettings.UnattendedUserEmail; + var unattendedPassword = unattendedSettings.UnattendedUserPassword; + + // Missing configuration values (json, env variables etc) + if (unattendedName.IsNullOrWhiteSpace() + || unattendedEmail.IsNullOrWhiteSpace() + || unattendedPassword.IsNullOrWhiteSpace()) { - - var unattendedSettings = _unattendedSettings.Value; - // Ensure we have the setting enabled (Sanity check) - // In theory this should always be true as the event only fired when a sucessfull - if (_unattendedSettings.Value.InstallUnattended == false) - { - return; - } - - var unattendedName = unattendedSettings.UnattendedUserName; - var unattendedEmail = unattendedSettings.UnattendedUserEmail; - var unattendedPassword = unattendedSettings.UnattendedUserPassword; - - // Missing configuration values (json, env variables etc) - if (unattendedName.IsNullOrWhiteSpace() - || unattendedEmail.IsNullOrWhiteSpace() - || unattendedPassword.IsNullOrWhiteSpace()) - { - return; - } - - IUser? admin = _userService.GetUserById(Core.Constants.Security.SuperUserId); - if (admin == null) - { - throw new InvalidOperationException("Could not find the super user!"); - } - - // User email/login has already been modified - if (admin.Email == unattendedEmail) - { - return; - } - - // Update name, email & login & save user - admin.Name = unattendedName!.Trim(); - admin.Email = unattendedEmail!.Trim(); - admin.Username = unattendedEmail.Trim(); - _userService.Save(admin); - - // Change Password for the default user we ship out of the box - // Uses same approach as NewInstall Step - using IServiceScope scope = _serviceScopeFactory.CreateScope(); - IBackOfficeUserManager backOfficeUserManager = scope.ServiceProvider.GetRequiredService(); - BackOfficeIdentityUser membershipUser = await backOfficeUserManager.FindByIdAsync(Core.Constants.Security.SuperUserIdAsString); - if (membershipUser == null) - { - throw new InvalidOperationException($"No user found in membership provider with id of {Core.Constants.Security.SuperUserIdAsString}."); - } - - //To change the password here we actually need to reset it since we don't have an old one to use to change - var resetToken = await backOfficeUserManager.GeneratePasswordResetTokenAsync(membershipUser); - if (string.IsNullOrWhiteSpace(resetToken)) - { - throw new InvalidOperationException("Could not reset password: unable to generate internal reset token"); - } - - IdentityResult resetResult = await backOfficeUserManager.ChangePasswordWithResetAsync(membershipUser.Id, resetToken, unattendedPassword!.Trim()); - if (!resetResult.Succeeded) - { - throw new InvalidOperationException("Could not reset password: " + string.Join(", ", resetResult.Errors.ToErrorMessage())); - } + return; } + IUser? admin = _userService.GetUserById(Constants.Security.SuperUserId); + if (admin == null) + { + throw new InvalidOperationException("Could not find the super user!"); + } + + // User email/login has already been modified + if (admin.Email == unattendedEmail) + { + return; + } + + // Update name, email & login & save user + admin.Name = unattendedName!.Trim(); + admin.Email = unattendedEmail!.Trim(); + admin.Username = unattendedEmail.Trim(); + _userService.Save(admin); + + // Change Password for the default user we ship out of the box + // Uses same approach as NewInstall Step + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackOfficeUserManager backOfficeUserManager = + scope.ServiceProvider.GetRequiredService(); + BackOfficeIdentityUser membershipUser = + await backOfficeUserManager.FindByIdAsync(Constants.Security.SuperUserIdAsString); + if (membershipUser == null) + { + throw new InvalidOperationException( + $"No user found in membership provider with id of {Constants.Security.SuperUserIdAsString}."); + } + + //To change the password here we actually need to reset it since we don't have an old one to use to change + var resetToken = await backOfficeUserManager.GeneratePasswordResetTokenAsync(membershipUser); + if (string.IsNullOrWhiteSpace(resetToken)) + { + throw new InvalidOperationException("Could not reset password: unable to generate internal reset token"); + } + + IdentityResult resetResult = + await backOfficeUserManager.ChangePasswordWithResetAsync(membershipUser.Id, resetToken, + unattendedPassword!.Trim()); + if (!resetResult.Succeeded) + { + throw new InvalidOperationException("Could not reset password: " + + string.Join(", ", resetResult.Errors.ToErrorMessage())); + } } } diff --git a/src/Umbraco.Web.BackOffice/Install/InstallApiController.cs b/src/Umbraco.Web.BackOffice/Install/InstallApiController.cs index c0c69e8358..3dfc9f51b1 100644 --- a/src/Umbraco.Web.BackOffice/Install/InstallApiController.cs +++ b/src/Umbraco.Web.BackOffice/Install/InstallApiController.cs @@ -1,11 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Install; using Umbraco.Cms.Core.Install.Models; using Umbraco.Cms.Core.Logging; @@ -19,235 +16,264 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Install +namespace Umbraco.Cms.Web.BackOffice.Install; + +[UmbracoApiController] +[AngularJsonOnlyConfiguration] +[InstallAuthorize] +[Area(Constants.Web.Mvc.InstallArea)] +public class InstallApiController : ControllerBase { - [UmbracoApiController] - [AngularJsonOnlyConfiguration] - [InstallAuthorize] - [Area(Cms.Core.Constants.Web.Mvc.InstallArea)] - public class InstallApiController : ControllerBase + private readonly IBackOfficeSignInManager _backOfficeSignInManager; + private readonly IBackOfficeUserManager _backOfficeUserManager; + private readonly DatabaseBuilder _databaseBuilder; + private readonly InstallStatusTracker _installStatusTracker; + private readonly InstallStepCollection _installSteps; + private readonly ILogger _logger; + private readonly IProfilingLogger _proflog; + private readonly IRuntime _runtime; + + public InstallApiController( + DatabaseBuilder databaseBuilder, + IProfilingLogger proflog, + ILogger logger, + InstallHelper installHelper, + InstallStepCollection installSteps, + InstallStatusTracker installStatusTracker, + IRuntime runtime, + IBackOfficeUserManager backOfficeUserManager, + IBackOfficeSignInManager backOfficeSignInManager) { - private readonly DatabaseBuilder _databaseBuilder; - private readonly InstallStatusTracker _installStatusTracker; - private readonly IRuntime _runtime; - private readonly IBackOfficeUserManager _backOfficeUserManager; - private readonly IBackOfficeSignInManager _backOfficeSignInManager; - private readonly InstallStepCollection _installSteps; - private readonly ILogger _logger; - private readonly IProfilingLogger _proflog; + _databaseBuilder = databaseBuilder ?? throw new ArgumentNullException(nameof(databaseBuilder)); + _proflog = proflog ?? throw new ArgumentNullException(nameof(proflog)); + _installSteps = installSteps; + _installStatusTracker = installStatusTracker; + _runtime = runtime; + _backOfficeUserManager = backOfficeUserManager; + _backOfficeSignInManager = backOfficeSignInManager; + InstallHelper = installHelper; + _logger = logger; + } - public InstallApiController( - DatabaseBuilder databaseBuilder, - IProfilingLogger proflog, - ILogger logger, - InstallHelper installHelper, - InstallStepCollection installSteps, - InstallStatusTracker installStatusTracker, - IRuntime runtime, - IBackOfficeUserManager backOfficeUserManager, - IBackOfficeSignInManager backOfficeSignInManager) + + internal InstallHelper InstallHelper { get; } + + public bool PostValidateDatabaseConnection(DatabaseModel databaseSettings) + => _databaseBuilder.ConfigureDatabaseConnection(databaseSettings, true); + + /// + /// Gets the install setup. + /// + public InstallSetup GetSetup() + { + var setup = new InstallSetup(); + + // TODO: Check for user/site token + + var steps = new List(); + + InstallSetupStep[] installSteps = _installSteps.GetStepsForCurrentInstallType().ToArray(); + + //only get the steps that are targeting the current install type + steps.AddRange(installSteps); + setup.Steps = steps; + + _installStatusTracker.Initialize(setup.InstallId, installSteps); + + return setup; + } + + [HttpPost] + public async Task CompleteInstall() + { + await _runtime.RestartAsync(); + + BackOfficeIdentityUser identityUser = + await _backOfficeUserManager.FindByIdAsync(Constants.Security.SuperUserIdAsString); + _backOfficeSignInManager.SignInAsync(identityUser, false); + + return NoContent(); + } + + /// + /// Installs. + /// + public async Task> PostPerformInstall(InstallInstructions installModel) + { + if (installModel == null) { - _databaseBuilder = databaseBuilder ?? throw new ArgumentNullException(nameof(databaseBuilder)); - _proflog = proflog ?? throw new ArgumentNullException(nameof(proflog)); - _installSteps = installSteps; - _installStatusTracker = installStatusTracker; - _runtime = runtime; - _backOfficeUserManager = backOfficeUserManager; - _backOfficeSignInManager = backOfficeSignInManager; - InstallHelper = installHelper; - _logger = logger; + throw new ArgumentNullException(nameof(installModel)); } - - internal InstallHelper InstallHelper { get; } - - public bool PostValidateDatabaseConnection(DatabaseModel databaseSettings) - => _databaseBuilder.ConfigureDatabaseConnection(databaseSettings, isTrialRun: true); - - /// - /// Gets the install setup. - /// - public InstallSetup GetSetup() + InstallTrackingItem[] status = InstallStatusTracker.GetStatus().ToArray(); + //there won't be any statuses returned if the app pool has restarted so we need to re-read from file. + if (status.Any() == false) { - var setup = new InstallSetup(); - - // TODO: Check for user/site token - - var steps = new List(); - - var installSteps = _installSteps.GetStepsForCurrentInstallType().ToArray(); - - //only get the steps that are targeting the current install type - steps.AddRange(installSteps); - setup.Steps = steps; - - _installStatusTracker.Initialize(setup.InstallId, installSteps); - - return setup; + status = _installStatusTracker.InitializeFromFile(installModel.InstallId).ToArray(); } - [HttpPost] - public async Task CompleteInstall() + //create a new queue of the non-finished ones + var queue = new Queue(status.Where(x => x.IsComplete == false)); + while (queue.Count > 0) { + InstallTrackingItem item = queue.Dequeue(); + InstallSetupStep step = _installSteps.GetAllSteps().Single(x => x.Name == item.Name); - await _runtime.RestartAsync(); + // if this step has any instructions then extract them + var instruction = GetInstruction(installModel, item, step); - var identityUser = await _backOfficeUserManager.FindByIdAsync(Core.Constants.Security.SuperUserIdAsString); - _backOfficeSignInManager.SignInAsync(identityUser, false); - - return NoContent(); - } - - /// - /// Installs. - /// - public async Task> PostPerformInstall(InstallInstructions installModel) - { - if (installModel == null) throw new ArgumentNullException(nameof(installModel)); - - var status = InstallStatusTracker.GetStatus().ToArray(); - //there won't be any statuses returned if the app pool has restarted so we need to re-read from file. - if (status.Any() == false) + // if this step doesn't require execution then continue to the next one, this is just a fail-safe check. + if (StepRequiresExecution(step, instruction) == false) { - status = _installStatusTracker.InitializeFromFile(installModel.InstallId).ToArray(); + // set this as complete and continue + _installStatusTracker.SetComplete(installModel.InstallId, item.Name); + continue; } - //create a new queue of the non-finished ones - var queue = new Queue(status.Where(x => x.IsComplete == false)); - while (queue.Count > 0) + try { - var item = queue.Dequeue(); - var step = _installSteps.GetAllSteps().Single(x => x.Name == item.Name); + InstallSetupResult? setupData = await ExecuteStepAsync(step, instruction); - // if this step has any instructions then extract them - var instruction = GetInstruction(installModel, item, step); + // update the status + _installStatusTracker.SetComplete(installModel.InstallId, step.Name, setupData?.SavedStepData); - // if this step doesn't require execution then continue to the next one, this is just a fail-safe check. - if (StepRequiresExecution(step, instruction) == false) + // determine's the next step in the queue and dequeue's any items that don't need to execute + var nextStep = IterateSteps(step, queue, installModel.InstallId, installModel); + + // check if there's a custom view to return for this step + if (setupData != null && setupData.View.IsNullOrWhiteSpace() == false) { - // set this as complete and continue - _installStatusTracker.SetComplete(installModel.InstallId, item.Name); - continue; + return new InstallProgressResultModel(false, step.Name, nextStep, setupData.View, setupData.ViewModel); } - try + return new InstallProgressResultModel(false, step.Name, nextStep); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred during installation step {Step}", step.Name); + + if (ex is TargetInvocationException && ex.InnerException != null) { - var setupData = await ExecuteStepAsync(step, instruction); - - // update the status - _installStatusTracker.SetComplete(installModel.InstallId, step.Name, setupData?.SavedStepData); - - // determine's the next step in the queue and dequeue's any items that don't need to execute - var nextStep = IterateSteps(step, queue, installModel.InstallId, installModel); - - // check if there's a custom view to return for this step - if (setupData != null && setupData.View.IsNullOrWhiteSpace() == false) - { - return new InstallProgressResultModel(false, step.Name, nextStep, setupData.View, - setupData.ViewModel); - } - - return new InstallProgressResultModel(false, step.Name, nextStep); + ex = ex.InnerException; } - catch (Exception ex) + + if (ex is InstallException installException) { - _logger.LogError(ex, "An error occurred during installation step {Step}", - step.Name); - - if (ex is TargetInvocationException && ex.InnerException != null) - { - ex = ex.InnerException; - } - - var installException = ex as InstallException; - if (installException != null) - { - return new ValidationErrorResult(new - { - view = installException.View, - model = installException.ViewModel, - message = installException.Message - }); - } - return new ValidationErrorResult(new { - step = step.Name, - view = "error", - message = ex.Message + view = installException.View, + model = installException.ViewModel, + message = installException.Message }); } - } - _installStatusTracker.Reset(); - return new InstallProgressResultModel(true, "", ""); + return new ValidationErrorResult(new { step = step.Name, view = "error", message = ex.Message }); + } } - private static object? GetInstruction(InstallInstructions installModel, InstallTrackingItem item, - InstallSetupStep step) - { - object? instruction = null; - installModel.Instructions?.TryGetValue(item.Name, out instruction); // else null + _installStatusTracker.Reset(); + return new InstallProgressResultModel(true, string.Empty, string.Empty); + } - if (instruction is JObject jObject) + private static object? GetInstruction(InstallInstructions installModel, InstallTrackingItem item, InstallSetupStep step) + { + object? instruction = null; + installModel.Instructions?.TryGetValue(item.Name, out instruction); // else null + + if (instruction is JObject jObject) + { + instruction = jObject?.ToObject(step.StepType); + } + + return instruction; + } + + /// + /// We'll peek ahead and check if it's RequiresExecution is returning true. If it + /// is not, we'll dequeue that step and peek ahead again (recurse) + /// + /// + /// + /// + /// + /// + private string IterateSteps(InstallSetupStep current, Queue queue, Guid installId, InstallInstructions installModel) + { + while (queue.Count > 0) + { + InstallTrackingItem item = queue.Peek(); + + // if the current step restarts the app pool then we must simply return the next one in the queue, + // we cannot peek ahead as the next step might rely on the app restart and therefore RequiresExecution + // will rely on that too. + if (current.PerformsAppRestart) { - instruction = jObject?.ToObject(step.StepType); + return item.Name; } - return instruction; - } + InstallSetupStep step = _installSteps.GetAllSteps().Single(x => x.Name == item.Name); - /// - /// We'll peek ahead and check if it's RequiresExecution is returning true. If it - /// is not, we'll dequeue that step and peek ahead again (recurse) - /// - /// - /// - /// - /// - /// - private string IterateSteps(InstallSetupStep current, Queue queue, Guid installId, - InstallInstructions installModel) - { - while (queue.Count > 0) + // if this step has any instructions then extract them + var instruction = GetInstruction(installModel, item, step); + + // if the step requires execution then return its name + if (StepRequiresExecution(step, instruction)) { - var item = queue.Peek(); - - // if the current step restarts the app pool then we must simply return the next one in the queue, - // we cannot peek ahead as the next step might rely on the app restart and therefore RequiresExecution - // will rely on that too. - if (current.PerformsAppRestart) - return item.Name; - - var step = _installSteps.GetAllSteps().Single(x => x.Name == item.Name); - - // if this step has any instructions then extract them - var instruction = GetInstruction(installModel, item, step); - - // if the step requires execution then return its name - if (StepRequiresExecution(step, instruction)) - return step.Name; - - // no longer requires execution, could be due to a new config change during installation - // dequeue - queue.Dequeue(); - - // complete - _installStatusTracker.SetComplete(installId, step.Name); - - // and continue - current = step; + return step.Name; } - return string.Empty; + // no longer requires execution, could be due to a new config change during installation + // dequeue + queue.Dequeue(); + + // complete + _installStatusTracker.SetComplete(installId, step.Name); + + // and continue + current = step; } - // determines whether the step requires execution - internal bool StepRequiresExecution(InstallSetupStep step, object? instruction) - { - if (step == null) throw new ArgumentNullException(nameof(step)); + return string.Empty; + } - var modelAttempt = instruction.TryConvertTo(step.StepType); + // determines whether the step requires execution + internal bool StepRequiresExecution(InstallSetupStep step, object? instruction) + { + if (step == null) + { + throw new ArgumentNullException(nameof(step)); + } + + Attempt modelAttempt = instruction.TryConvertTo(step.StepType); + if (!modelAttempt.Success) + { + throw new InvalidCastException( + $"Cannot cast/convert {step.GetType().FullName} into {step.StepType.FullName}"); + } + + var model = modelAttempt.Result; + Type genericStepType = typeof(InstallSetupStep<>); + Type[] typeArgs = { step.StepType }; + Type typedStepType = genericStepType.MakeGenericType(typeArgs); + try + { + MethodInfo method = typedStepType.GetMethods().Single(x => x.Name == "RequiresExecution"); + var result = (bool?)method.Invoke(step, new[] { model }); + return result ?? false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Checking if step requires execution ({Step}) failed.", step.Name); + throw; + } + } + + // executes the step + internal async Task ExecuteStepAsync(InstallSetupStep step, object? instruction) + { + using (_proflog.TraceDuration($"Executing installation step: '{step.Name}'.", "Step completed")) + { + Attempt modelAttempt = instruction.TryConvertTo(step.StepType); if (!modelAttempt.Success) { throw new InvalidCastException( @@ -255,52 +281,20 @@ namespace Umbraco.Cms.Web.BackOffice.Install } var model = modelAttempt.Result; - var genericStepType = typeof(InstallSetupStep<>); + Type genericStepType = typeof(InstallSetupStep<>); Type[] typeArgs = { step.StepType }; - var typedStepType = genericStepType.MakeGenericType(typeArgs); + Type typedStepType = genericStepType.MakeGenericType(typeArgs); try { - var method = typedStepType.GetMethods().Single(x => x.Name == "RequiresExecution"); - var result = (bool?) method.Invoke(step, new[] { model }); - return result ?? false; + MethodInfo method = typedStepType.GetMethods().Single(x => x.Name == "ExecuteAsync"); + var task = (Task?)method.Invoke(step, new[] { model }); + return await task!; } catch (Exception ex) { - _logger.LogError(ex, "Checking if step requires execution ({Step}) failed.", - step.Name); + _logger.LogError(ex, "Installation step {Step} failed.", step.Name); throw; } } - - // executes the step - internal async Task ExecuteStepAsync(InstallSetupStep step, object? instruction) - { - using (_proflog.TraceDuration($"Executing installation step: '{step.Name}'.", - "Step completed")) - { - var modelAttempt = instruction.TryConvertTo(step.StepType); - if (!modelAttempt.Success) - { - throw new InvalidCastException( - $"Cannot cast/convert {step.GetType().FullName} into {step.StepType.FullName}"); - } - - var model = modelAttempt.Result; - var genericStepType = typeof(InstallSetupStep<>); - Type[] typeArgs = { step.StepType }; - var typedStepType = genericStepType.MakeGenericType(typeArgs); - try - { - var method = typedStepType.GetMethods().Single(x => x.Name == "ExecuteAsync"); - var task = (Task?) method.Invoke(step, new[] { model }); - return await task!; - } - catch (Exception ex) - { - _logger.LogError(ex, "Installation step {Step} failed.", step.Name); - throw; - } - } - } } } diff --git a/src/Umbraco.Web.BackOffice/Install/InstallAreaRoutes.cs b/src/Umbraco.Web.BackOffice/Install/InstallAreaRoutes.cs index 5d0239303a..26eb3e9302 100644 --- a/src/Umbraco.Web.BackOffice/Install/InstallAreaRoutes.cs +++ b/src/Umbraco.Web.BackOffice/Install/InstallAreaRoutes.cs @@ -1,4 +1,3 @@ -using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Umbraco.Cms.Core; @@ -7,60 +6,57 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Routing; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Install +namespace Umbraco.Cms.Web.BackOffice.Install; + +public class InstallAreaRoutes : IAreaRoutes { + private readonly IHostingEnvironment _hostingEnvironment; + private readonly LinkGenerator _linkGenerator; + private readonly IRuntimeState _runtime; - public class InstallAreaRoutes : IAreaRoutes + public InstallAreaRoutes(IRuntimeState runtime, IHostingEnvironment hostingEnvironment, LinkGenerator linkGenerator) { - private readonly IRuntimeState _runtime; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly LinkGenerator _linkGenerator; + _runtime = runtime; + _hostingEnvironment = hostingEnvironment; + _linkGenerator = linkGenerator; + } - public InstallAreaRoutes(IRuntimeState runtime, IHostingEnvironment hostingEnvironment, LinkGenerator linkGenerator) + public void CreateRoutes(IEndpointRouteBuilder endpoints) + { + var installPathSegment = _hostingEnvironment.ToAbsolute(Constants.SystemDirectories.Install).TrimStart('/'); + + switch (_runtime.Level) { - _runtime = runtime; - _hostingEnvironment = hostingEnvironment; - _linkGenerator = linkGenerator; + case var _ when _runtime.EnableInstaller(): + + endpoints.MapUmbracoRoute(installPathSegment, Constants.Web.Mvc.InstallArea, + "api", includeControllerNameInRoute: false); + endpoints.MapUmbracoRoute(installPathSegment, Constants.Web.Mvc.InstallArea, + string.Empty, includeControllerNameInRoute: false); + + // register catch all because if we are in install/upgrade mode then we'll catch everything and redirect + endpoints.MapFallbackToAreaController( + "Redirect", + ControllerExtensions.GetControllerName(), + Constants.Web.Mvc.InstallArea); + + + break; + case RuntimeLevel.Run: + + // when we are in run mode redirect to the back office if the installer endpoint is hit + endpoints.MapGet($"{installPathSegment}/{{controller?}}/{{action?}}", context => + { + // redirect to umbraco + context.Response.Redirect(_linkGenerator.GetBackOfficeUrl(_hostingEnvironment)!, false); + return Task.CompletedTask; + }); + + break; + case RuntimeLevel.BootFailed: + case RuntimeLevel.Unknown: + case RuntimeLevel.Boot: + break; } - - public void CreateRoutes(IEndpointRouteBuilder endpoints) - { - var installPathSegment = _hostingEnvironment.ToAbsolute(Cms.Core.Constants.SystemDirectories.Install).TrimStart('/'); - - switch (_runtime.Level) - { - case var _ when _runtime.EnableInstaller(): - - endpoints.MapUmbracoRoute(installPathSegment, Cms.Core.Constants.Web.Mvc.InstallArea, "api", includeControllerNameInRoute: false); - endpoints.MapUmbracoRoute(installPathSegment, Cms.Core.Constants.Web.Mvc.InstallArea, string.Empty, includeControllerNameInRoute: false); - - // register catch all because if we are in install/upgrade mode then we'll catch everything and redirect - endpoints.MapFallbackToAreaController( - "Redirect", - ControllerExtensions.GetControllerName(), - Cms.Core.Constants.Web.Mvc.InstallArea); - - - break; - case RuntimeLevel.Run: - - // when we are in run mode redirect to the back office if the installer endpoint is hit - endpoints.MapGet($"{installPathSegment}/{{controller?}}/{{action?}}", context => - { - // redirect to umbraco - context.Response.Redirect(_linkGenerator.GetBackOfficeUrl(_hostingEnvironment)!, false); - return Task.CompletedTask; - }); - - break; - case RuntimeLevel.BootFailed: - case RuntimeLevel.Unknown: - case RuntimeLevel.Boot: - break; - - } - } - - } } diff --git a/src/Umbraco.Web.BackOffice/Install/InstallAuthorizeAttribute.cs b/src/Umbraco.Web.BackOffice/Install/InstallAuthorizeAttribute.cs index 429b204ec6..428f21932c 100644 --- a/src/Umbraco.Web.BackOffice/Install/InstallAuthorizeAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Install/InstallAuthorizeAttribute.cs @@ -1,60 +1,56 @@ -using System; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Install +namespace Umbraco.Cms.Web.BackOffice.Install; + +/// +/// Ensures authorization occurs for the installer if it has already completed. +/// If install has not yet occurred then the authorization is successful. +/// +public class InstallAuthorizeAttribute : TypeFilterAttribute { - /// - /// Ensures authorization occurs for the installer if it has already completed. - /// If install has not yet occurred then the authorization is successful. - /// - public class InstallAuthorizeAttribute : TypeFilterAttribute + public InstallAuthorizeAttribute() : base(typeof(InstallAuthorizeFilter)) { - public InstallAuthorizeAttribute() : base(typeof(InstallAuthorizeFilter)) + } + + private class InstallAuthorizeFilter : IAuthorizationFilter + { + private readonly ILogger _logger; + private readonly IRuntimeState _runtimeState; + + public InstallAuthorizeFilter( + IRuntimeState runtimeState, + ILogger logger) { + _runtimeState = runtimeState; + _logger = logger; } - private class InstallAuthorizeFilter : IAuthorizationFilter + public void OnAuthorization(AuthorizationFilterContext authorizationFilterContext) { - private readonly IRuntimeState _runtimeState; - private readonly ILogger _logger; - - public InstallAuthorizeFilter( - IRuntimeState runtimeState, - ILogger logger) + if (!IsAllowed(authorizationFilterContext)) { - _runtimeState = runtimeState; - _logger = logger; + authorizationFilterContext.Result = new ForbidResult(); } + } - public void OnAuthorization(AuthorizationFilterContext authorizationFilterContext) + private bool IsAllowed(AuthorizationFilterContext authorizationFilterContext) + { + try { - if (!IsAllowed(authorizationFilterContext)) - { - authorizationFilterContext.Result = new ForbidResult(); - } + // if not configured (install or upgrade) then we can continue + // otherwise we need to ensure that a user is logged in + return _runtimeState.EnableInstaller() + || (authorizationFilterContext.HttpContext.User?.Identity?.IsAuthenticated ?? false); } - - private bool IsAllowed(AuthorizationFilterContext authorizationFilterContext) + catch (Exception ex) { - try - { - // if not configured (install or upgrade) then we can continue - // otherwise we need to ensure that a user is logged in - return _runtimeState.EnableInstaller() - || (authorizationFilterContext.HttpContext.User?.Identity?.IsAuthenticated ?? false); - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred determining authorization"); - return false; - } + _logger.LogError(ex, "An error occurred determining authorization"); + return false; } } } - } diff --git a/src/Umbraco.Web.BackOffice/Install/InstallController.cs b/src/Umbraco.Web.BackOffice/Install/InstallController.cs index c0cebfb9d7..ab6029cc43 100644 --- a/src/Umbraco.Web.BackOffice/Install/InstallController.cs +++ b/src/Umbraco.Web.BackOffice/Install/InstallController.cs @@ -1,5 +1,5 @@ -using System.IO; -using System.Threading.Tasks; +using System.Net; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; @@ -17,109 +17,113 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Install +namespace Umbraco.Cms.Web.BackOffice.Install; + +/// +/// The Installation controller +/// +[InstallAuthorize] +[Area(Constants.Web.Mvc.InstallArea)] +public class InstallController : Controller { + private static bool _reported; + private static RuntimeLevel _reportedLevel; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly GlobalSettings _globalSettings; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly InstallHelper _installHelper; + private readonly LinkGenerator _linkGenerator; + private readonly ILogger _logger; + private readonly IRuntimeState _runtime; + private readonly IRuntimeMinifier _runtimeMinifier; + private readonly IUmbracoVersion _umbracoVersion; + + public InstallController( + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + InstallHelper installHelper, + IRuntimeState runtime, + IOptions globalSettings, + IRuntimeMinifier runtimeMinifier, + IHostingEnvironment hostingEnvironment, + IUmbracoVersion umbracoVersion, + ILogger logger, + LinkGenerator linkGenerator) + { + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _installHelper = installHelper; + _runtime = runtime; + _globalSettings = globalSettings.Value; + _runtimeMinifier = runtimeMinifier; + _hostingEnvironment = hostingEnvironment; + _umbracoVersion = umbracoVersion; + _logger = logger; + _linkGenerator = linkGenerator; + } + + [HttpGet] + [StatusCodeResult(HttpStatusCode.ServiceUnavailable)] + [TypeFilter(typeof(StatusCodeResultAttribute), Arguments = new object[] { HttpStatusCode.ServiceUnavailable })] + public async Task Index() + { + var umbracoPath = Url.GetBackOfficeUrl(); + + if (_runtime.Level == RuntimeLevel.Run) + { + return Redirect(umbracoPath!); + } + + // TODO: Update for package migrations + if (_runtime.Level == RuntimeLevel.Upgrade) + { + AuthenticateResult authResult = await this.AuthenticateBackOfficeAsync(); + + if (!authResult.Succeeded) + { + return Redirect(_globalSettings.UmbracoPath + "/AuthorizeUpgrade?redir=" + Request.GetEncodedUrl()); + } + } + + // gen the install base URL + ViewData.SetInstallApiBaseUrl(_linkGenerator.GetInstallerApiUrl()); + + // get the base umbraco folder + var baseFolder = _hostingEnvironment.ToAbsolute(_globalSettings.UmbracoPath); + ViewData.SetUmbracoBaseFolder(baseFolder); + + ViewData.SetUmbracoVersion(_umbracoVersion.SemanticVersion); + + await _installHelper.SetInstallStatusAsync(false, string.Empty); + + return View(Path.Combine(Constants.SystemDirectories.Umbraco.TrimStart("~"), Constants.Web.Mvc.InstallArea, nameof(Index) + ".cshtml")); + } /// - /// The Installation controller + /// Used to perform the redirect to the installer when the runtime level is or + /// /// - [InstallAuthorize] - [Area(Cms.Core.Constants.Web.Mvc.InstallArea)] - public class InstallController : Controller + /// + [HttpGet] + [IgnoreFromNotFoundSelectorPolicy] + public ActionResult Redirect() { - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly InstallHelper _installHelper; - private readonly IRuntimeState _runtime; - private readonly GlobalSettings _globalSettings; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly IUmbracoVersion _umbracoVersion; - private readonly ILogger _logger; - private readonly LinkGenerator _linkGenerator; - private readonly IRuntimeMinifier _runtimeMinifier; + var uri = HttpContext.Request.GetEncodedUrl(); - public InstallController( - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - InstallHelper installHelper, - IRuntimeState runtime, - IOptions globalSettings, - IRuntimeMinifier runtimeMinifier, - IHostingEnvironment hostingEnvironment, - IUmbracoVersion umbracoVersion, - ILogger logger, - LinkGenerator linkGenerator) + // redirect to install + ReportRuntime(_logger, _runtime.Level, "Umbraco must install or upgrade."); + + var installUrl = $"{_linkGenerator.GetInstallerUrl()}?redir=true&url={uri}"; + return Redirect(installUrl); + } + + private static void ReportRuntime(ILogger logger, RuntimeLevel level, string message) + { + if (_reported && _reportedLevel == level) { - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _installHelper = installHelper; - _runtime = runtime; - _globalSettings = globalSettings.Value; - _runtimeMinifier = runtimeMinifier; - _hostingEnvironment = hostingEnvironment; - _umbracoVersion = umbracoVersion; - _logger = logger; - _linkGenerator = linkGenerator; + return; } - [HttpGet] - [StatusCodeResult(System.Net.HttpStatusCode.ServiceUnavailable)] - [TypeFilter(typeof(StatusCodeResultAttribute), Arguments = new object []{System.Net.HttpStatusCode.ServiceUnavailable})] - public async Task Index() - { - var umbracoPath = Url.GetBackOfficeUrl(); - - if (_runtime.Level == RuntimeLevel.Run) - return Redirect(umbracoPath!); - - // TODO: Update for package migrations - if (_runtime.Level == RuntimeLevel.Upgrade) - { - var authResult = await this.AuthenticateBackOfficeAsync(); - - if (!authResult.Succeeded) - { - return Redirect(_globalSettings.UmbracoPath + "/AuthorizeUpgrade?redir=" + Request.GetEncodedUrl()); - } - } - - // gen the install base URL - ViewData.SetInstallApiBaseUrl(_linkGenerator.GetInstallerApiUrl()); - - // get the base umbraco folder - var baseFolder = _hostingEnvironment.ToAbsolute(_globalSettings.UmbracoPath); - ViewData.SetUmbracoBaseFolder(baseFolder); - - ViewData.SetUmbracoVersion(_umbracoVersion.SemanticVersion); - - await _installHelper.SetInstallStatusAsync(false, ""); - - return View(Path.Combine(Constants.SystemDirectories.Umbraco.TrimStart("~") , Cms.Core.Constants.Web.Mvc.InstallArea, nameof(Index) + ".cshtml")); - } - - /// - /// Used to perform the redirect to the installer when the runtime level is or - /// - /// - [HttpGet] - [IgnoreFromNotFoundSelectorPolicy] - public ActionResult Redirect() - { - var uri = HttpContext.Request.GetEncodedUrl(); - - // redirect to install - ReportRuntime(_logger, _runtime.Level, "Umbraco must install or upgrade."); - - var installUrl = $"{_linkGenerator.GetInstallerUrl()}?redir=true&url={uri}"; - return Redirect(installUrl); - } - - private static bool _reported; - private static RuntimeLevel _reportedLevel; - - private static void ReportRuntime(ILogger logger, RuntimeLevel level, string message) - { - if (_reported && _reportedLevel == level) return; - _reported = true; - _reportedLevel = level; - logger.LogWarning(message); - } + _reported = true; + _reportedLevel = level; + logger.LogWarning(message); } } diff --git a/src/Umbraco.Web.BackOffice/Mapping/CommonTreeNodeMapper.cs b/src/Umbraco.Web.BackOffice/Mapping/CommonTreeNodeMapper.cs index efee8ba3e6..c0362e1a0d 100644 --- a/src/Umbraco.Web.BackOffice/Mapping/CommonTreeNodeMapper.cs +++ b/src/Umbraco.Web.BackOffice/Mapping/CommonTreeNodeMapper.cs @@ -1,27 +1,21 @@ -using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Web.BackOffice.Trees; using Umbraco.Cms.Web.Common.Controllers; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Mapping +namespace Umbraco.Cms.Web.BackOffice.Mapping; + +public class CommonTreeNodeMapper { - public class CommonTreeNodeMapper - { - private readonly LinkGenerator _linkGenerator; + private readonly LinkGenerator _linkGenerator; - public CommonTreeNodeMapper( LinkGenerator linkGenerator) - { - _linkGenerator = linkGenerator; - } + public CommonTreeNodeMapper(LinkGenerator linkGenerator) => _linkGenerator = linkGenerator; - public string? GetTreeNodeUrl(IContentBase source) - where TController : UmbracoApiController, ITreeNodeController - { - return _linkGenerator.GetUmbracoApiService(controller => controller.GetTreeNode(source.Key.ToString("N"), null)); - } - - } + public string? GetTreeNodeUrl(IContentBase source) + where TController : UmbracoApiController, ITreeNodeController => + _linkGenerator.GetUmbracoApiService(controller => + controller.GetTreeNode(source.Key.ToString("N"), null)); } diff --git a/src/Umbraco.Web.BackOffice/Mapping/ContentMapDefinition.cs b/src/Umbraco.Web.BackOffice/Mapping/ContentMapDefinition.cs index 24cd1c5cbe..f93512863b 100644 --- a/src/Umbraco.Web.BackOffice/Mapping/ContentMapDefinition.cs +++ b/src/Umbraco.Web.BackOffice/Mapping/ContentMapDefinition.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; @@ -18,400 +15,435 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Web.BackOffice.Trees; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Mapping +namespace Umbraco.Cms.Web.BackOffice.Mapping; + +/// +/// Declares how model mappings for content +/// +internal class ContentMapDefinition : IMapDefinition { - /// - /// Declares how model mappings for content - /// - internal class ContentMapDefinition : IMapDefinition + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly ContentBasicSavedStateMapper _basicStateMapper; + private readonly CommonMapper _commonMapper; + private readonly CommonTreeNodeMapper _commonTreeNodeMapper; + private readonly IContentService _contentService; + private readonly IContentTypeService _contentTypeService; + private readonly ContentVariantMapper _contentVariantMapper; + private readonly ICultureDictionary _cultureDictionary; + private readonly IEntityService _entityService; + private readonly IFileService _fileService; + private readonly ILocalizationService _localizationService; + private readonly ILocalizedTextService _localizedTextService; + private readonly ILoggerFactory _loggerFactory; + private readonly IPublishedRouter _publishedRouter; + private readonly IPublishedUrlProvider _publishedUrlProvider; + private readonly ContentSavedStateMapper _stateMapper; + private readonly TabsAndPropertiesMapper _tabsAndPropertiesMapper; + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly UriUtility _uriUtility; + private readonly IUserService _userService; + private readonly IVariationContextAccessor _variationContextAccessor; + + + public ContentMapDefinition( + CommonMapper commonMapper, + CommonTreeNodeMapper commonTreeNodeMapper, + ICultureDictionary cultureDictionary, + ILocalizedTextService localizedTextService, + IContentService contentService, + IContentTypeService contentTypeService, + IFileService fileService, + IUmbracoContextAccessor umbracoContextAccessor, + IPublishedRouter publishedRouter, + ILocalizationService localizationService, + ILoggerFactory loggerFactory, + IUserService userService, + IVariationContextAccessor variationContextAccessor, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + UriUtility uriUtility, + IPublishedUrlProvider publishedUrlProvider, + IEntityService entityService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + AppCaches appCaches) { - private readonly CommonMapper _commonMapper; - private readonly CommonTreeNodeMapper _commonTreeNodeMapper; - private readonly ICultureDictionary _cultureDictionary; - private readonly ILocalizedTextService _localizedTextService; - private readonly IContentService _contentService; - private readonly IContentTypeService _contentTypeService; - private readonly IFileService _fileService; - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly IPublishedRouter _publishedRouter; - private readonly ILocalizationService _localizationService; - private readonly ILoggerFactory _loggerFactory; - private readonly IUserService _userService; - private readonly IEntityService _entityService; - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly IVariationContextAccessor _variationContextAccessor; - private readonly IPublishedUrlProvider _publishedUrlProvider; - private readonly UriUtility _uriUtility; - private readonly AppCaches _appCaches; - private readonly TabsAndPropertiesMapper _tabsAndPropertiesMapper; - private readonly ContentSavedStateMapper _stateMapper; - private readonly ContentBasicSavedStateMapper _basicStateMapper; - private readonly ContentVariantMapper _contentVariantMapper; + _commonMapper = commonMapper; + _commonTreeNodeMapper = commonTreeNodeMapper; + _cultureDictionary = cultureDictionary; + _localizedTextService = localizedTextService; + _contentService = contentService; + _contentTypeService = contentTypeService; + _fileService = fileService; + _umbracoContextAccessor = umbracoContextAccessor; + _publishedRouter = publishedRouter; + _localizationService = localizationService; + _loggerFactory = loggerFactory; + _userService = userService; + _entityService = entityService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _variationContextAccessor = variationContextAccessor; + _uriUtility = uriUtility; + _publishedUrlProvider = publishedUrlProvider; + _appCaches = appCaches; + _tabsAndPropertiesMapper = new TabsAndPropertiesMapper(cultureDictionary, localizedTextService, contentTypeBaseServiceProvider); + _stateMapper = new ContentSavedStateMapper(); + _basicStateMapper = new ContentBasicSavedStateMapper(); + _contentVariantMapper = new ContentVariantMapper(_localizationService, localizedTextService); + } - public ContentMapDefinition( - CommonMapper commonMapper, - CommonTreeNodeMapper commonTreeNodeMapper, - ICultureDictionary cultureDictionary, - ILocalizedTextService localizedTextService, - IContentService contentService, - IContentTypeService contentTypeService, - IFileService fileService, - IUmbracoContextAccessor umbracoContextAccessor, - IPublishedRouter publishedRouter, - ILocalizationService localizationService, - ILoggerFactory loggerFactory, - IUserService userService, - IVariationContextAccessor variationContextAccessor, - IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, - UriUtility uriUtility, - IPublishedUrlProvider publishedUrlProvider, - IEntityService entityService, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - AppCaches appCaches) + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define>( + (source, context) => new ContentItemBasic(), Map); + mapper.Define((source, context) => new ContentPropertyCollectionDto(), Map); + + mapper.Define((source, context) => new ContentItemDisplay(), Map); + mapper.Define( + (source, context) => new ContentItemDisplayWithSchedule(), Map); + + mapper.Define((source, context) => new ContentVariantDisplay(), Map); + mapper.Define((source, context) => new ContentVariantScheduleDisplay(), Map); + } + + // Umbraco.Code.MapAll + private static void Map(IContent source, ContentPropertyCollectionDto target, MapperContext context) => + target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); + + // Umbraco.Code.MapAll -AllowPreview -Errors -PersistedContent + private void Map(IContent source, ContentItemDisplay target, MapperContext context) + where TVariant : ContentVariantDisplay + { + // Both GetActions and DetermineIsChildOfListView use parent, so get it once here + // Parent might already be in context, so check there before using content service + IContent? parent; + if (context.Items.TryGetValue("Parent", out var parentObj) && + parentObj is IContent typedParent) { - _commonMapper = commonMapper; - _commonTreeNodeMapper = commonTreeNodeMapper; - _cultureDictionary = cultureDictionary; - _localizedTextService = localizedTextService; - _contentService = contentService; - _contentTypeService = contentTypeService; - _fileService = fileService; - _umbracoContextAccessor = umbracoContextAccessor; - _publishedRouter = publishedRouter; - _localizationService = localizationService; - _loggerFactory = loggerFactory; - _userService = userService; - _entityService = entityService; - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _variationContextAccessor = variationContextAccessor; - _uriUtility = uriUtility; - _publishedUrlProvider = publishedUrlProvider; - _appCaches = appCaches; - - _tabsAndPropertiesMapper = new TabsAndPropertiesMapper(cultureDictionary, localizedTextService, contentTypeBaseServiceProvider); - _stateMapper = new ContentSavedStateMapper(); - _basicStateMapper = new ContentBasicSavedStateMapper(); - _contentVariantMapper = new ContentVariantMapper(_localizationService, localizedTextService); + parent = typedParent; + } + else + { + parent = _contentService.GetParent(source); } - public void DefineMaps(IUmbracoMapper mapper) + target.AllowedActions = GetActions(source, parent, context); + target.AllowedTemplates = GetAllowedTemplates(source); + target.ContentApps = _commonMapper.GetContentAppsForEntity(source); + target.ContentTypeId = source.ContentType.Id; + target.ContentTypeKey = source.ContentType.Key; + target.ContentTypeAlias = source.ContentType.Alias; + target.ContentTypeName = + _localizedTextService.UmbracoDictionaryTranslate(_cultureDictionary, source.ContentType.Name); + target.DocumentType = _commonMapper.GetContentType(source, context); + target.Icon = source.ContentType.Icon; + target.Id = source.Id; + target.IsBlueprint = source.Blueprint; + target.IsChildOfListView = DetermineIsChildOfListView(source, parent, context); + target.IsContainer = source.ContentType.IsContainer; + target.IsElement = source.ContentType.IsElement; + target.Key = source.Key; + target.Owner = _commonMapper.GetOwner(source, context); + target.ParentId = source.ParentId; + target.Path = source.Path; + target.SortOrder = source.SortOrder; + target.TemplateAlias = GetDefaultTemplate(source); + target.TemplateId = source.TemplateId ?? default; + target.Trashed = source.Trashed; + target.TreeNodeUrl = _commonTreeNodeMapper.GetTreeNodeUrl(source); + target.Udi = + Udi.Create(source.Blueprint ? Constants.UdiEntityType.DocumentBlueprint : Constants.UdiEntityType.Document, source.Key); + target.UpdateDate = source.UpdateDate; + target.Updater = _commonMapper.GetCreator(source, context); + target.Urls = GetUrls(source); + target.Variants = _contentVariantMapper.Map(source, context); + + target.ContentDto = new ContentPropertyCollectionDto { - mapper.Define>((source, context) => new ContentItemBasic(), Map); - mapper.Define((source, context) => new ContentPropertyCollectionDto(), Map); + Properties = context.MapEnumerable(source.Properties).WhereNotNull() + }; + } - mapper.Define((source, context) => new ContentItemDisplay(), Map); - mapper.Define((source, context) => new ContentItemDisplayWithSchedule(), Map); + // Umbraco.Code.MapAll -Segment -Language -DisplayName + private void Map(IContent source, ContentVariantDisplay target, MapperContext context) + { + target.CreateDate = source.CreateDate; + target.Name = source.Name; + target.PublishDate = source.PublishDate; + target.State = _stateMapper.Map(source, context); + target.Tabs = _tabsAndPropertiesMapper.Map(source, context); + target.UpdateDate = source.UpdateDate; + } - mapper.Define((source, context) => new ContentVariantDisplay(), Map); - mapper.Define((source, context) => new ContentVariantScheduleDisplay(), Map); + private void Map(IContent source, ContentVariantScheduleDisplay target, MapperContext context) + { + Map(source, (ContentVariantDisplay)target, context); + target.ReleaseDate = GetScheduledDate(source, ContentScheduleAction.Release, context); + target.ExpireDate = GetScheduledDate(source, ContentScheduleAction.Expire, context); + } + + // Umbraco.Code.MapAll -Alias + private void Map(IContent source, ContentItemBasic target, MapperContext context) + { + target.ContentTypeId = source.ContentType.Id; + target.ContentTypeAlias = source.ContentType.Alias; + target.CreateDate = source.CreateDate; + target.Edited = source.Edited; + target.Icon = source.ContentType.Icon; + target.Id = source.Id; + target.Key = source.Key; + target.Name = GetName(source, context); + target.Owner = _commonMapper.GetOwner(source, context); + target.ParentId = source.ParentId; + target.Path = source.Path; + target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); + target.SortOrder = source.SortOrder; + target.State = _basicStateMapper.Map(source, context); + target.Trashed = source.Trashed; + target.Udi = + Udi.Create(source.Blueprint ? Constants.UdiEntityType.DocumentBlueprint : Constants.UdiEntityType.Document, source.Key); + target.UpdateDate = GetUpdateDate(source, context); + target.Updater = _commonMapper.GetCreator(source, context); + target.VariesByCulture = source.ContentType.VariesByCulture(); + } + + private IEnumerable GetActions(IContent source, IContent? parent, MapperContext context) + { + IBackOfficeSecurity? backOfficeSecurity = _backOfficeSecurityAccessor.BackOfficeSecurity; + + //cannot check permissions without a context + if (backOfficeSecurity is null) + { + return Enumerable.Empty(); } - // Umbraco.Code.MapAll - private static void Map(IContent source, ContentPropertyCollectionDto target, MapperContext context) + string path; + if (source.HasIdentity) { - target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); + path = source.Path; + } + else + { + path = parent == null ? "-1" : parent.Path; } - // Umbraco.Code.MapAll -AllowPreview -Errors -PersistedContent - private void Map(IContent source, ContentItemDisplay target, MapperContext context) where TVariant : ContentVariantDisplay + // A bit of a mess, but we need to ensure that all the required values are here AND that they're the right type. + if (context.Items.TryGetValue("CurrentUser", out var userObject) && + context.Items.TryGetValue("Permissions", out var permissionsObject) && + userObject is IUser currentUser && + permissionsObject is Dictionary permissionsDict) { - // Both GetActions and DetermineIsChildOfListView use parent, so get it once here - // Parent might already be in context, so check there before using content service - IContent? parent; - if (context.Items.TryGetValue("Parent", out var parentObj) && - parentObj is IContent typedParent) + // If we already have permissions for a given path, + // and the current user is the same as was used to generate the permissions, return the stored permissions. + if (backOfficeSecurity.CurrentUser?.Id == currentUser.Id && + permissionsDict.TryGetValue(path, out EntityPermissionSet? permissions)) { - parent = typedParent; + return permissions.GetAllPermissions(); } - else - { - parent = _contentService.GetParent(source); - } - - target.AllowedActions = GetActions(source, parent, context); - target.AllowedTemplates = GetAllowedTemplates(source); - target.ContentApps = _commonMapper.GetContentAppsForEntity(source); - target.ContentTypeId = source.ContentType.Id; - target.ContentTypeKey = source.ContentType.Key; - target.ContentTypeAlias = source.ContentType.Alias; - target.ContentTypeName = _localizedTextService.UmbracoDictionaryTranslate(_cultureDictionary, source.ContentType.Name); - target.DocumentType = _commonMapper.GetContentType(source, context); - target.Icon = source.ContentType.Icon; - target.Id = source.Id; - target.IsBlueprint = source.Blueprint; - target.IsChildOfListView = DetermineIsChildOfListView(source, parent, context); - target.IsContainer = source.ContentType.IsContainer; - target.IsElement = source.ContentType.IsElement; - target.Key = source.Key; - target.Owner = _commonMapper.GetOwner(source, context); - target.ParentId = source.ParentId; - target.Path = source.Path; - target.SortOrder = source.SortOrder; - target.TemplateAlias = GetDefaultTemplate(source); - target.TemplateId = source.TemplateId ?? default; - target.Trashed = source.Trashed; - target.TreeNodeUrl = _commonTreeNodeMapper.GetTreeNodeUrl(source); - target.Udi = Udi.Create(source.Blueprint ? Constants.UdiEntityType.DocumentBlueprint : Constants.UdiEntityType.Document, source.Key); - target.UpdateDate = source.UpdateDate; - target.Updater = _commonMapper.GetCreator(source, context); - target.Urls = GetUrls(source); - target.Variants = _contentVariantMapper.Map(source, context); - - target.ContentDto = new ContentPropertyCollectionDto - { - Properties = context.MapEnumerable(source.Properties).WhereNotNull() - }; } - // Umbraco.Code.MapAll -Segment -Language -DisplayName - private void Map(IContent source, ContentVariantDisplay target, MapperContext context) + // TODO: This is certainly not ideal usage here - perhaps the best way to deal with this in the future is + // with the IUmbracoContextAccessor. In the meantime, if used outside of a web app this will throw a null + // reference exception :( + + return _userService.GetPermissionsForPath(backOfficeSecurity.CurrentUser, path).GetAllPermissions(); + } + + private UrlInfo[] GetUrls(IContent source) + { + if (source.ContentType.IsElement) { - target.CreateDate = source.CreateDate; - target.Name = source.Name; - target.PublishDate = source.PublishDate; - target.State = _stateMapper.Map(source, context); - target.Tabs = _tabsAndPropertiesMapper.Map(source, context); - target.UpdateDate = source.UpdateDate; + return Array.Empty(); } - private void Map(IContent source, ContentVariantScheduleDisplay target, MapperContext context) + if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext)) { - Map(source, (ContentVariantDisplay)target, context); - target.ReleaseDate = GetScheduledDate(source, ContentScheduleAction.Release, context); - target.ExpireDate = GetScheduledDate(source, ContentScheduleAction.Expire, context); + return new[] { UrlInfo.Message("Cannot generate URLs without a current Umbraco Context") }; } - // Umbraco.Code.MapAll -Alias - private void Map(IContent source, ContentItemBasic target, MapperContext context) + // NOTE: unfortunately we're not async, we'll use .Result and hope this won't cause a deadlock anywhere for now + UrlInfo[] urls = source.GetContentUrlsAsync( + _publishedRouter, + umbracoContext, + _localizationService, + _localizedTextService, + _contentService, + _variationContextAccessor, + _loggerFactory.CreateLogger(), + _uriUtility, + _publishedUrlProvider) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult() + .ToArray(); + + return urls; + } + + private DateTime GetUpdateDate(IContent source, MapperContext context) + { + // invariant = global date + if (!source.ContentType.VariesByCulture()) { - target.ContentTypeId = source.ContentType.Id; - target.ContentTypeAlias = source.ContentType.Alias; - target.CreateDate = source.CreateDate; - target.Edited = source.Edited; - target.Icon = source.ContentType.Icon; - target.Id = source.Id; - target.Key = source.Key; - target.Name = GetName(source, context); - target.Owner = _commonMapper.GetOwner(source, context); - target.ParentId = source.ParentId; - target.Path = source.Path; - target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); - target.SortOrder = source.SortOrder; - target.State = _basicStateMapper.Map(source, context); - target.Trashed = source.Trashed; - target.Udi = Udi.Create(source.Blueprint ? Constants.UdiEntityType.DocumentBlueprint : Constants.UdiEntityType.Document, source.Key); - target.UpdateDate = GetUpdateDate(source, context); - target.Updater = _commonMapper.GetCreator(source, context); - target.VariesByCulture = source.ContentType.VariesByCulture(); + return source.UpdateDate; } - private IEnumerable GetActions(IContent source, IContent? parent, MapperContext context) + // variant = depends on culture + var culture = context.GetCulture(); + + // if there's no culture here, the issue is somewhere else (UI, whatever) - throw! + if (culture == null) { - var backOfficeSecurity = _backOfficeSecurityAccessor.BackOfficeSecurity; + throw new InvalidOperationException("Missing culture in mapping options."); + } - //cannot check permissions without a context - if (backOfficeSecurity is null) - return Enumerable.Empty(); + // if we don't have a date for a culture, it means the culture is not available, and + // hey we should probably not be mapping it, but it's too late, return a fallback date + DateTime? date = source.GetUpdateDate(culture); + return date ?? source.UpdateDate; + } - string path; - if (source.HasIdentity) - path = source.Path; - else + private string? GetName(IContent source, MapperContext context) + { + // invariant = only 1 name + if (!source.ContentType.VariesByCulture()) + { + return source.Name; + } + + // variant = depends on culture + var culture = context.GetCulture(); + + // if there's no culture here, the issue is somewhere else (UI, whatever) - throw! + if (culture == null) + { + throw new InvalidOperationException("Missing culture in mapping options."); + } + + // if we don't have a name for a culture, it means the culture is not available, and + // hey we should probably not be mapping it, but it's too late, return a fallback name + return source.CultureInfos is not null && + source.CultureInfos.TryGetValue(culture, out ContentCultureInfos name) && !name.Name.IsNullOrWhiteSpace() + ? name.Name + : $"({source.Name})"; + } + + /// + /// Checks if the content item is a descendant of a list view + /// + /// + /// + /// + /// + /// Returns true if the content item is a descendant of a list view and where the content is + /// not a current user's start node. + /// + /// + /// We must check if it's the current user's start node because in that case we will actually be + /// rendering the tree node underneath the list view to visually show context. In this case we return + /// false because the item is technically not being rendered as part of a list view but instead as a + /// real tree node. If we didn't perform this check then tree syncing wouldn't work correctly. + /// + private bool DetermineIsChildOfListView(IContent source, IContent? parent, MapperContext context) + { + var userStartNodes = Array.Empty(); + + // In cases where a user's start node is below a list view, we will actually render + // out the tree to that start node and in that case for that start node, we want to return + // false here. + if (context.HasItems && context.Items.TryGetValue("CurrentUser", out var usr) && usr is IUser currentUser) + { + userStartNodes = currentUser.CalculateContentStartNodeIds(_entityService, _appCaches); + if (!userStartNodes?.Contains(Constants.System.Root) ?? false) { - path = parent == null ? "-1" : parent.Path; - } - - // A bit of a mess, but we need to ensure that all the required values are here AND that they're the right type. - if (context.Items.TryGetValue("CurrentUser", out var userObject) && - context.Items.TryGetValue("Permissions", out var permissionsObject) && - userObject is IUser currentUser && - permissionsObject is Dictionary permissionsDict) - { - // If we already have permissions for a given path, - // and the current user is the same as was used to generate the permissions, return the stored permissions. - if (backOfficeSecurity.CurrentUser?.Id == currentUser.Id && - permissionsDict.TryGetValue(path, out var permissions)) + // return false if this is the user's actual start node, the node will be rendered in the tree + // regardless of if it's a list view or not + if (userStartNodes?.Contains(source.Id) ?? false) { - return permissions.GetAllPermissions(); + return false; } } - - // TODO: This is certainly not ideal usage here - perhaps the best way to deal with this in the future is - // with the IUmbracoContextAccessor. In the meantime, if used outside of a web app this will throw a null - // reference exception :( - - return _userService.GetPermissionsForPath(backOfficeSecurity.CurrentUser, path).GetAllPermissions(); } - private UrlInfo[] GetUrls(IContent source) + if (parent == null) { - if (source.ContentType.IsElement) + return false; + } + + var pathParts = parent.Path.Split(Constants.CharArrays.Comma).Select(x => + int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) ? i : 0).ToList(); + + if (userStartNodes is not null) + { + // reduce the path parts so we exclude top level content items that + // are higher up than a user's start nodes + foreach (var n in userStartNodes) { - return Array.Empty(); - } - if (!_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext)) - { - return new[] { UrlInfo.Message("Cannot generate URLs without a current Umbraco Context") }; - } - - // NOTE: unfortunately we're not async, we'll use .Result and hope this won't cause a deadlock anywhere for now - var urls = source.GetContentUrlsAsync(_publishedRouter, umbracoContext, _localizationService, _localizedTextService, _contentService, _variationContextAccessor, _loggerFactory.CreateLogger(), _uriUtility, _publishedUrlProvider) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult() - .ToArray(); - - return urls; - } - - private DateTime GetUpdateDate(IContent source, MapperContext context) - { - // invariant = global date - if (!source.ContentType.VariesByCulture()) - return source.UpdateDate; - - // variant = depends on culture - var culture = context.GetCulture(); - - // if there's no culture here, the issue is somewhere else (UI, whatever) - throw! - if (culture == null) - throw new InvalidOperationException("Missing culture in mapping options."); - - // if we don't have a date for a culture, it means the culture is not available, and - // hey we should probably not be mapping it, but it's too late, return a fallback date - var date = source.GetUpdateDate(culture); - return date ?? source.UpdateDate; - } - - private string? GetName(IContent source, MapperContext context) - { - // invariant = only 1 name - if (!source.ContentType.VariesByCulture()) - return source.Name; - - // variant = depends on culture - var culture = context.GetCulture(); - - // if there's no culture here, the issue is somewhere else (UI, whatever) - throw! - if (culture == null) - throw new InvalidOperationException("Missing culture in mapping options."); - - // if we don't have a name for a culture, it means the culture is not available, and - // hey we should probably not be mapping it, but it's too late, return a fallback name - return source.CultureInfos is not null && source.CultureInfos.TryGetValue(culture, out var name) && !name.Name.IsNullOrWhiteSpace() ? name.Name : $"({source.Name})"; - } - - /// - /// Checks if the content item is a descendant of a list view - /// - /// - /// - /// - /// - /// Returns true if the content item is a descendant of a list view and where the content is - /// not a current user's start node. - /// - /// - /// We must check if it's the current user's start node because in that case we will actually be - /// rendering the tree node underneath the list view to visually show context. In this case we return - /// false because the item is technically not being rendered as part of a list view but instead as a - /// real tree node. If we didn't perform this check then tree syncing wouldn't work correctly. - /// - private bool DetermineIsChildOfListView(IContent source, IContent? parent, MapperContext context) - { - var userStartNodes = Array.Empty(); - - // In cases where a user's start node is below a list view, we will actually render - // out the tree to that start node and in that case for that start node, we want to return - // false here. - if (context.HasItems && context.Items.TryGetValue("CurrentUser", out var usr) && usr is IUser currentUser) - { - userStartNodes = currentUser.CalculateContentStartNodeIds(_entityService, _appCaches); - if (!userStartNodes?.Contains(Constants.System.Root) ?? false) + var index = pathParts.IndexOf(n); + if (index != -1) { - // return false if this is the user's actual start node, the node will be rendered in the tree - // regardless of if it's a list view or not - if (userStartNodes?.Contains(source.Id) ?? false) - return false; - } - } - - if (parent == null) - return false; - - var pathParts = parent.Path.Split(Constants.CharArrays.Comma).Select(x => int.TryParse(x, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) ? i : 0).ToList(); - - if (userStartNodes is not null) - { - // reduce the path parts so we exclude top level content items that - // are higher up than a user's start nodes - foreach (var n in userStartNodes) - { - var index = pathParts.IndexOf(n); - if (index != -1) + // now trim all top level start nodes to the found index + for (var i = 0; i < index; i++) { - // now trim all top level start nodes to the found index - for (var i = 0; i < index; i++) - { - pathParts.RemoveAt(0); - } + pathParts.RemoveAt(0); } } } - - return parent.ContentType.IsContainer || _contentTypeService.HasContainerInPath(pathParts.ToArray()); } + return parent.ContentType.IsContainer || _contentTypeService.HasContainerInPath(pathParts.ToArray()); + } - private DateTime? GetScheduledDate(IContent source, ContentScheduleAction action, MapperContext context) + + private DateTime? GetScheduledDate(IContent source, ContentScheduleAction action, MapperContext context) + { + _ = context.Items.TryGetValue("Schedule", out var untypedSchedule); + + if (untypedSchedule is not ContentScheduleCollection scheduleCollection) { - _ = context.Items.TryGetValue("Schedule", out var untypedSchedule); - - if (untypedSchedule is not ContentScheduleCollection scheduleCollection) - { - throw new ApplicationException("GetScheduledDate requires a ContentScheduleCollection in the MapperContext for Key: Schedule"); - } - - var culture = context.GetCulture() ?? string.Empty; - IEnumerable schedule = scheduleCollection.GetSchedule(culture, action); - return schedule.FirstOrDefault()?.Date; // take the first, it's ordered by date + throw new ApplicationException( + "GetScheduledDate requires a ContentScheduleCollection in the MapperContext for Key: Schedule"); } - private IDictionary? GetAllowedTemplates(IContent source) + var culture = context.GetCulture() ?? string.Empty; + IEnumerable schedule = scheduleCollection.GetSchedule(culture, action); + return schedule.FirstOrDefault()?.Date; // take the first, it's ordered by date + } + + private IDictionary? GetAllowedTemplates(IContent source) + { + // Element types can't have templates, so no need to query to get the content type + if (source.ContentType.IsElement) { - // Element types can't have templates, so no need to query to get the content type - if (source.ContentType.IsElement) - { - return new Dictionary(); - } - - var contentType = _contentTypeService.Get(source.ContentTypeId); - - return contentType?.AllowedTemplates? - .Where(t => t.Alias.IsNullOrWhiteSpace() == false && t.Name.IsNullOrWhiteSpace() == false) - .ToDictionary(t => t.Alias, t => _localizedTextService.UmbracoDictionaryTranslate(_cultureDictionary, t.Name)); + return new Dictionary(); } - private string? GetDefaultTemplate(IContent source) + IContentType? contentType = _contentTypeService.Get(source.ContentTypeId); + + return contentType?.AllowedTemplates? + .Where(t => t.Alias.IsNullOrWhiteSpace() == false && t.Name.IsNullOrWhiteSpace() == false) + .ToDictionary(t => t.Alias, t => _localizedTextService.UmbracoDictionaryTranslate(_cultureDictionary, t.Name)); + } + + private string? GetDefaultTemplate(IContent source) + { + if (source == null) { - if (source == null) - return null; - - // If no template id was set... - if (!source.TemplateId.HasValue) - { - // ... and no default template is set, return null... - // ... otherwise return the content type default template alias. - return string.IsNullOrWhiteSpace(source.ContentType.DefaultTemplate?.Alias) - ? null - : source.ContentType.DefaultTemplate?.Alias; - } - - var template = _fileService.GetTemplate(source.TemplateId.Value); - return template?.Alias; + return null; } + + // If no template id was set... + if (!source.TemplateId.HasValue) + { + // ... and no default template is set, return null... + // ... otherwise return the content type default template alias. + return string.IsNullOrWhiteSpace(source.ContentType.DefaultTemplate?.Alias) + ? null + : source.ContentType.DefaultTemplate?.Alias; + } + + ITemplate? template = _fileService.GetTemplate(source.TemplateId.Value); + return template?.Alias; } } diff --git a/src/Umbraco.Web.BackOffice/Mapping/MediaMapDefinition.cs b/src/Umbraco.Web.BackOffice/Mapping/MediaMapDefinition.cs index 1e4cd0fcbd..681017aed1 100644 --- a/src/Umbraco.Web.BackOffice/Mapping/MediaMapDefinition.cs +++ b/src/Umbraco.Web.BackOffice/Mapping/MediaMapDefinition.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -11,104 +10,106 @@ using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Trees; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Mapping +namespace Umbraco.Cms.Web.BackOffice.Mapping; + +/// +/// Declares model mappings for media. +/// +public class MediaMapDefinition : IMapDefinition { - /// - /// Declares model mappings for media. - /// - public class MediaMapDefinition : IMapDefinition + private readonly CommonMapper _commonMapper; + private readonly CommonTreeNodeMapper _commonTreeNodeMapper; + private readonly ContentSettings _contentSettings; + private readonly IMediaService _mediaService; + private readonly IMediaTypeService _mediaTypeService; + private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; + private readonly TabsAndPropertiesMapper _tabsAndPropertiesMapper; + + public MediaMapDefinition(ICultureDictionary cultureDictionary, CommonMapper commonMapper, + CommonTreeNodeMapper commonTreeNodeMapper, IMediaService mediaService, IMediaTypeService mediaTypeService, + ILocalizedTextService localizedTextService, MediaUrlGeneratorCollection mediaUrlGenerators, + IOptions contentSettings, IContentTypeBaseServiceProvider contentTypeBaseServiceProvider) { - private readonly CommonMapper _commonMapper; - private readonly CommonTreeNodeMapper _commonTreeNodeMapper; - private readonly IMediaService _mediaService; - private readonly IMediaTypeService _mediaTypeService; - private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; - private readonly TabsAndPropertiesMapper _tabsAndPropertiesMapper; - private readonly ContentSettings _contentSettings; + _commonMapper = commonMapper; + _commonTreeNodeMapper = commonTreeNodeMapper; + _mediaService = mediaService; + _mediaTypeService = mediaTypeService; + _mediaUrlGenerators = mediaUrlGenerators; + _contentSettings = contentSettings.Value ?? throw new ArgumentNullException(nameof(contentSettings)); - public MediaMapDefinition(ICultureDictionary cultureDictionary, CommonMapper commonMapper, CommonTreeNodeMapper commonTreeNodeMapper, IMediaService mediaService, IMediaTypeService mediaTypeService, - ILocalizedTextService localizedTextService, MediaUrlGeneratorCollection mediaUrlGenerators, IOptions contentSettings, IContentTypeBaseServiceProvider contentTypeBaseServiceProvider) - { - _commonMapper = commonMapper; - _commonTreeNodeMapper = commonTreeNodeMapper; - _mediaService = mediaService; - _mediaTypeService = mediaTypeService; - _mediaUrlGenerators = mediaUrlGenerators; - _contentSettings = contentSettings.Value ?? throw new ArgumentNullException(nameof(contentSettings)); + _tabsAndPropertiesMapper = + new TabsAndPropertiesMapper(cultureDictionary, localizedTextService, + contentTypeBaseServiceProvider); + } - _tabsAndPropertiesMapper = new TabsAndPropertiesMapper(cultureDictionary, localizedTextService, contentTypeBaseServiceProvider); - } + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define((source, context) => new ContentPropertyCollectionDto(), + Map); + mapper.Define((source, context) => new MediaItemDisplay(), Map); + mapper.Define>( + (source, context) => new ContentItemBasic(), Map); + } - public void DefineMaps(IUmbracoMapper mapper) - { - mapper.Define((source, context) => new ContentPropertyCollectionDto(), Map); - mapper.Define((source, context) => new MediaItemDisplay(), Map); - mapper.Define>((source, context) => new ContentItemBasic(), Map); - } + // Umbraco.Code.MapAll + private static void Map(IMedia source, ContentPropertyCollectionDto target, MapperContext context) => + target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); - // Umbraco.Code.MapAll - private static void Map(IMedia source, ContentPropertyCollectionDto target, MapperContext context) - { - target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); - } + // Umbraco.Code.MapAll -Properties -Errors -Edited -Updater -Alias -IsContainer + private void Map(IMedia source, MediaItemDisplay target, MapperContext context) + { + target.ContentApps = _commonMapper.GetContentAppsForEntity(source); + target.ContentType = _commonMapper.GetContentType(source, context); + target.ContentTypeId = source.ContentType.Id; + target.ContentTypeAlias = source.ContentType.Alias; + target.ContentTypeName = source.ContentType.Name; + target.CreateDate = source.CreateDate; + target.Icon = source.ContentType.Icon; + target.Id = source.Id; + target.IsChildOfListView = DetermineIsChildOfListView(source); + target.Key = source.Key; + target.MediaLink = string.Join(",", source.GetUrls(_contentSettings, _mediaUrlGenerators)); + target.Name = source.Name; + target.Owner = _commonMapper.GetOwner(source, context); + target.ParentId = source.ParentId; + target.Path = source.Path; + target.SortOrder = source.SortOrder; + target.State = null; + target.Tabs = _tabsAndPropertiesMapper.Map(source, context); + target.Trashed = source.Trashed; + target.TreeNodeUrl = _commonTreeNodeMapper.GetTreeNodeUrl(source); + target.Udi = Udi.Create(Constants.UdiEntityType.Media, source.Key); + target.UpdateDate = source.UpdateDate; + target.VariesByCulture = source.ContentType.VariesByCulture(); + } - // Umbraco.Code.MapAll -Properties -Errors -Edited -Updater -Alias -IsContainer - private void Map(IMedia source, MediaItemDisplay target, MapperContext context) - { - target.ContentApps = _commonMapper.GetContentAppsForEntity(source); - target.ContentType = _commonMapper.GetContentType(source, context); - target.ContentTypeId = source.ContentType.Id; - target.ContentTypeAlias = source.ContentType.Alias; - target.ContentTypeName = source.ContentType.Name; - target.CreateDate = source.CreateDate; - target.Icon = source.ContentType.Icon; - target.Id = source.Id; - target.IsChildOfListView = DetermineIsChildOfListView(source); - target.Key = source.Key; - target.MediaLink = string.Join(",", source.GetUrls(_contentSettings, _mediaUrlGenerators)); - target.Name = source.Name; - target.Owner = _commonMapper.GetOwner(source, context); - target.ParentId = source.ParentId; - target.Path = source.Path; - target.SortOrder = source.SortOrder; - target.State = null; - target.Tabs = _tabsAndPropertiesMapper.Map(source, context); - target.Trashed = source.Trashed; - target.TreeNodeUrl = _commonTreeNodeMapper.GetTreeNodeUrl(source); - target.Udi = Udi.Create(Constants.UdiEntityType.Media, source.Key); - target.UpdateDate = source.UpdateDate; - target.VariesByCulture = source.ContentType.VariesByCulture(); - } + // Umbraco.Code.MapAll -Edited -Updater -Alias + private void Map(IMedia source, ContentItemBasic target, MapperContext context) + { + target.ContentTypeId = source.ContentType.Id; + target.ContentTypeAlias = source.ContentType.Alias; + target.CreateDate = source.CreateDate; + target.Icon = source.ContentType.Icon; + target.Id = source.Id; + target.Key = source.Key; + target.Name = source.Name; + target.Owner = _commonMapper.GetOwner(source, context); + target.ParentId = source.ParentId; + target.Path = source.Path; + target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); + target.SortOrder = source.SortOrder; + target.State = null; + target.Trashed = source.Trashed; + target.Udi = Udi.Create(Constants.UdiEntityType.Media, source.Key); + target.UpdateDate = source.UpdateDate; + target.VariesByCulture = source.ContentType.VariesByCulture(); + } - // Umbraco.Code.MapAll -Edited -Updater -Alias - private void Map(IMedia source, ContentItemBasic target, MapperContext context) - { - target.ContentTypeId = source.ContentType.Id; - target.ContentTypeAlias = source.ContentType.Alias; - target.CreateDate = source.CreateDate; - target.Icon = source.ContentType.Icon; - target.Id = source.Id; - target.Key = source.Key; - target.Name = source.Name; - target.Owner = _commonMapper.GetOwner(source, context); - target.ParentId = source.ParentId; - target.Path = source.Path; - target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); - target.SortOrder = source.SortOrder; - target.State = null; - target.Trashed = source.Trashed; - target.Udi = Udi.Create(Constants.UdiEntityType.Media, source.Key); - target.UpdateDate = source.UpdateDate; - target.VariesByCulture = source.ContentType.VariesByCulture(); - } - - private bool DetermineIsChildOfListView(IMedia source) - { - // map the IsChildOfListView (this is actually if it is a descendant of a list view!) - var parent = _mediaService.GetParent(source); - return parent != null && (parent.ContentType.IsContainer || _mediaTypeService.HasContainerInPath(parent.Path)); - } + private bool DetermineIsChildOfListView(IMedia source) + { + // map the IsChildOfListView (this is actually if it is a descendant of a list view!) + IMedia? parent = _mediaService.GetParent(source); + return parent != null && (parent.ContentType.IsContainer || _mediaTypeService.HasContainerInPath(parent.Path)); } } diff --git a/src/Umbraco.Web.BackOffice/Mapping/MemberMapDefinition.cs b/src/Umbraco.Web.BackOffice/Mapping/MemberMapDefinition.cs index c8a0348417..541c6f2710 100644 --- a/src/Umbraco.Web.BackOffice/Mapping/MemberMapDefinition.cs +++ b/src/Umbraco.Web.BackOffice/Mapping/MemberMapDefinition.cs @@ -1,6 +1,3 @@ -using System; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; @@ -9,114 +6,112 @@ using Umbraco.Cms.Core.Models.Mapping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Web.BackOffice.Trees; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Mapping +namespace Umbraco.Cms.Web.BackOffice.Mapping; + +/// +/// Declares model mappings for members. +/// +public class MemberMapDefinition : IMapDefinition { - /// - /// Declares model mappings for members. - /// - public class MemberMapDefinition : IMapDefinition + private readonly CommonMapper _commonMapper; + private readonly CommonTreeNodeMapper _commonTreeNodeMapper; + private readonly MemberTabsAndPropertiesMapper _tabsAndPropertiesMapper; + + public MemberMapDefinition(CommonMapper commonMapper, CommonTreeNodeMapper commonTreeNodeMapper, + MemberTabsAndPropertiesMapper tabsAndPropertiesMapper) { - private readonly CommonMapper _commonMapper; - private readonly CommonTreeNodeMapper _commonTreeNodeMapper; - private readonly MemberTabsAndPropertiesMapper _tabsAndPropertiesMapper; - - public MemberMapDefinition(CommonMapper commonMapper, CommonTreeNodeMapper commonTreeNodeMapper, MemberTabsAndPropertiesMapper tabsAndPropertiesMapper) - { - _commonMapper = commonMapper; - _commonTreeNodeMapper = commonTreeNodeMapper; - _tabsAndPropertiesMapper = tabsAndPropertiesMapper; - } - - public void DefineMaps(IUmbracoMapper mapper) - { - mapper.Define((source, context) => new MemberDisplay(), Map); - mapper.Define((source, context) => new MemberBasic(), Map); - mapper.Define((source, context) => new MemberGroupDisplay(), Map); - mapper.Define((source, context) => new MemberGroupDisplay(), Map); - mapper.Define((source, context) => new ContentPropertyCollectionDto(), Map); - } - - // Umbraco.Code.MapAll -Properties -Errors -Edited -Updater -Alias -IsChildOfListView - // Umbraco.Code.MapAll -Trashed -IsContainer -VariesByCulture - private void Map(IMember source, MemberDisplay target, MapperContext context) - { - target.ContentApps = _commonMapper.GetContentAppsForEntity(source); - target.ContentType = _commonMapper.GetContentType(source, context); - target.ContentTypeId = source.ContentType.Id; - target.ContentTypeAlias = source.ContentType.Alias; - target.ContentTypeName = source.ContentType.Name; - target.CreateDate = source.CreateDate; - target.Icon = source.ContentType.Icon; - target.Id = source.Id; - target.Key = source.Key; - target.Name = source.Name; - target.Owner = _commonMapper.GetOwner(source, context); - target.ParentId = source.ParentId; - target.Path = source.Path; - target.SortOrder = source.SortOrder; - target.State = null; - target.Tabs = _tabsAndPropertiesMapper.Map(source, context); - target.TreeNodeUrl = _commonTreeNodeMapper.GetTreeNodeUrl(source); - target.Udi = Udi.Create(Constants.UdiEntityType.Member, source.Key); - target.UpdateDate = source.UpdateDate; - - //Membership - target.Username = source.Username; - target.Email = source.Email; - target.IsLockedOut = source.IsLockedOut; - target.IsApproved = source.IsApproved; - target.MembershipProperties = _tabsAndPropertiesMapper.MapMembershipProperties(source, context); - } - - // Umbraco.Code.MapAll -Trashed -Edited -Updater -Alias -VariesByCulture - private void Map(IMember source, MemberBasic target, MapperContext context) - { - target.ContentTypeId = source.ContentType.Id; - target.ContentTypeAlias = source.ContentType.Alias; - target.CreateDate = source.CreateDate; - target.Email = source.Email; - target.Icon = source.ContentType.Icon; - target.Id = int.MaxValue; - target.Key = source.Key; - target.Name = source.Name; - target.Owner = _commonMapper.GetOwner(source, context); - target.ParentId = source.ParentId; - target.Path = source.Path; - target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); - target.SortOrder = source.SortOrder; - target.State = null; - target.Udi = Udi.Create(Constants.UdiEntityType.Member, source.Key); - target.UpdateDate = source.UpdateDate; - target.Username = source.Username; - } - - // Umbraco.Code.MapAll -Icon -Trashed -ParentId -Alias - private void Map(IMemberGroup source, MemberGroupDisplay target, MapperContext context) - { - target.Icon = Constants.Icons.MemberGroup; - target.Id = source.Id; - target.Key = source.Key; - target.Name = source.Name; - target.Path = $"-1,{source.Id}"; - target.Udi = Udi.Create(Constants.UdiEntityType.MemberGroup, source.Key); - } - - // Umbraco.Code.MapAll -Icon -Trashed -ParentId -Alias -Key -Udi - private void Map(UmbracoIdentityRole source, MemberGroupDisplay target, MapperContext context) - { - target.Id = source.Id; - //target.Key = source.Key; - target.Name = source.Name; - target.Path = $"-1,{source.Id}"; - //target.Udi = Udi.Create(Constants.UdiEntityType.MemberGroup, source.Key); - } - - // Umbraco.Code.MapAll - private static void Map(IMember source, ContentPropertyCollectionDto target, MapperContext context) - { - target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); - } + _commonMapper = commonMapper; + _commonTreeNodeMapper = commonTreeNodeMapper; + _tabsAndPropertiesMapper = tabsAndPropertiesMapper; } + + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define((source, context) => new MemberDisplay(), Map); + mapper.Define((source, context) => new MemberBasic(), Map); + mapper.Define((source, context) => new MemberGroupDisplay(), Map); + mapper.Define((source, context) => new MemberGroupDisplay(), Map); + mapper.Define((source, context) => new ContentPropertyCollectionDto(), + Map); + } + + // Umbraco.Code.MapAll -Properties -Errors -Edited -Updater -Alias -IsChildOfListView + // Umbraco.Code.MapAll -Trashed -IsContainer -VariesByCulture + private void Map(IMember source, MemberDisplay target, MapperContext context) + { + target.ContentApps = _commonMapper.GetContentAppsForEntity(source); + target.ContentType = _commonMapper.GetContentType(source, context); + target.ContentTypeId = source.ContentType.Id; + target.ContentTypeAlias = source.ContentType.Alias; + target.ContentTypeName = source.ContentType.Name; + target.CreateDate = source.CreateDate; + target.Icon = source.ContentType.Icon; + target.Id = source.Id; + target.Key = source.Key; + target.Name = source.Name; + target.Owner = _commonMapper.GetOwner(source, context); + target.ParentId = source.ParentId; + target.Path = source.Path; + target.SortOrder = source.SortOrder; + target.State = null; + target.Tabs = _tabsAndPropertiesMapper.Map(source, context); + target.TreeNodeUrl = _commonTreeNodeMapper.GetTreeNodeUrl(source); + target.Udi = Udi.Create(Constants.UdiEntityType.Member, source.Key); + target.UpdateDate = source.UpdateDate; + + //Membership + target.Username = source.Username; + target.Email = source.Email; + target.IsLockedOut = source.IsLockedOut; + target.IsApproved = source.IsApproved; + target.MembershipProperties = _tabsAndPropertiesMapper.MapMembershipProperties(source, context); + } + + // Umbraco.Code.MapAll -Trashed -Edited -Updater -Alias -VariesByCulture + private void Map(IMember source, MemberBasic target, MapperContext context) + { + target.ContentTypeId = source.ContentType.Id; + target.ContentTypeAlias = source.ContentType.Alias; + target.CreateDate = source.CreateDate; + target.Email = source.Email; + target.Icon = source.ContentType.Icon; + target.Id = int.MaxValue; + target.Key = source.Key; + target.Name = source.Name; + target.Owner = _commonMapper.GetOwner(source, context); + target.ParentId = source.ParentId; + target.Path = source.Path; + target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); + target.SortOrder = source.SortOrder; + target.State = null; + target.Udi = Udi.Create(Constants.UdiEntityType.Member, source.Key); + target.UpdateDate = source.UpdateDate; + target.Username = source.Username; + } + + // Umbraco.Code.MapAll -Icon -Trashed -ParentId -Alias + private void Map(IMemberGroup source, MemberGroupDisplay target, MapperContext context) + { + target.Icon = Constants.Icons.MemberGroup; + target.Id = source.Id; + target.Key = source.Key; + target.Name = source.Name; + target.Path = $"-1,{source.Id}"; + target.Udi = Udi.Create(Constants.UdiEntityType.MemberGroup, source.Key); + } + + // Umbraco.Code.MapAll -Icon -Trashed -ParentId -Alias -Key -Udi + private void Map(UmbracoIdentityRole source, MemberGroupDisplay target, MapperContext context) + { + target.Id = source.Id; + //target.Key = source.Key; + target.Name = source.Name; + target.Path = $"-1,{source.Id}"; + //target.Udi = Udi.Create(Constants.UdiEntityType.MemberGroup, source.Key); + } + + // Umbraco.Code.MapAll + private static void Map(IMember source, ContentPropertyCollectionDto target, MapperContext context) => + target.Properties = context.MapEnumerable(source.Properties).WhereNotNull(); } diff --git a/src/Umbraco.Web.BackOffice/Middleware/BackOfficeExternalLoginProviderErrorMiddleware.cs b/src/Umbraco.Web.BackOffice/Middleware/BackOfficeExternalLoginProviderErrorMiddleware.cs index b093611282..9dbe09e119 100644 --- a/src/Umbraco.Web.BackOffice/Middleware/BackOfficeExternalLoginProviderErrorMiddleware.cs +++ b/src/Umbraco.Web.BackOffice/Middleware/BackOfficeExternalLoginProviderErrorMiddleware.cs @@ -1,52 +1,53 @@ -using System; using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Newtonsoft.Json; +using Umbraco.Cms.Core.Security; using Umbraco.Extensions; -using HttpRequestExtensions = Umbraco.Extensions.HttpRequestExtensions; -namespace Umbraco.Cms.Web.BackOffice.Middleware +namespace Umbraco.Cms.Web.BackOffice.Middleware; + +/// +/// Used to handle errors registered by external login providers +/// +/// +/// When an external login provider registers an error with +/// during the OAuth process, +/// this middleware will detect that, store the errors into cookie data and redirect to the back office login so we can +/// read the errors back out. +/// +public class BackOfficeExternalLoginProviderErrorMiddleware : IMiddleware { - - /// - /// Used to handle errors registered by external login providers - /// - /// - /// When an external login provider registers an error with during the OAuth process, - /// this middleware will detect that, store the errors into cookie data and redirect to the back office login so we can read the errors back out. - /// - public class BackOfficeExternalLoginProviderErrorMiddleware : IMiddleware + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { - public async Task InvokeAsync(HttpContext context, RequestDelegate next) + var shortCircuit = false; + if (!context.Request.IsClientSideRequest()) { - var shortCircuit = false; - if (!HttpRequestExtensions.IsClientSideRequest(context.Request)) + // check if we have any errors registered + BackOfficeExternalLoginProviderErrors? errors = context.GetExternalLoginProviderErrors(); + if (errors != null) { - // check if we have any errors registered - var errors = context.GetExternalLoginProviderErrors(); - if (errors != null) - { - shortCircuit = true; + shortCircuit = true; - var serialized = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(errors))); + var serialized = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(errors))); - context.Response.Cookies.Append(ViewDataExtensions.TokenExternalSignInError, serialized, new CookieOptions + context.Response.Cookies.Append( + ViewDataExtensions.TokenExternalSignInError, + serialized, + new CookieOptions { Expires = DateTime.Now.AddMinutes(5), HttpOnly = true, Secure = context.Request.IsHttps }); - context.Response.Redirect(context.Request.GetEncodedUrl()); - } + context.Response.Redirect(context.Request.GetEncodedUrl()); } + } - if (next != null && !shortCircuit) - { - await next(context); - } + if (next != null && !shortCircuit) + { + await next(context); } } } diff --git a/src/Umbraco.Web.BackOffice/Middleware/ConfigureGlobalOptionsForKeepAliveMiddlware.cs b/src/Umbraco.Web.BackOffice/Middleware/ConfigureGlobalOptionsForKeepAliveMiddlware.cs index d08e1d7d1f..15366ec113 100644 --- a/src/Umbraco.Web.BackOffice/Middleware/ConfigureGlobalOptionsForKeepAliveMiddlware.cs +++ b/src/Umbraco.Web.BackOffice/Middleware/ConfigureGlobalOptionsForKeepAliveMiddlware.cs @@ -1,22 +1,23 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; -namespace Umbraco.Cms.Web.BackOffice.Middleware +namespace Umbraco.Cms.Web.BackOffice.Middleware; + +/// +/// Ensures the Keep Alive middleware is part of +/// +public sealed class ConfigureGlobalOptionsForKeepAliveMiddlware : IPostConfigureOptions { + private readonly IOptions _keepAliveSettings; + + public ConfigureGlobalOptionsForKeepAliveMiddlware(IOptions keepAliveSettings) => + _keepAliveSettings = keepAliveSettings; + /// - /// Ensures the Keep Alive middleware is part of + /// Append the keep alive ping url to the reserved URLs /// - public sealed class ConfigureGlobalOptionsForKeepAliveMiddlware : IPostConfigureOptions - { - private readonly IOptions _keepAliveSettings; - - public ConfigureGlobalOptionsForKeepAliveMiddlware(IOptions keepAliveSettings) => _keepAliveSettings = keepAliveSettings; - - /// - /// Append the keep alive ping url to the reserved URLs - /// - /// - /// - public void PostConfigure(string name, GlobalSettings options) => options.ReservedUrls += _keepAliveSettings.Value.KeepAlivePingUrl; - } + /// + /// + public void PostConfigure(string name, GlobalSettings options) => + options.ReservedUrls += _keepAliveSettings.Value.KeepAlivePingUrl; } diff --git a/src/Umbraco.Web.BackOffice/Middleware/KeepAliveMiddleware.cs b/src/Umbraco.Web.BackOffice/Middleware/KeepAliveMiddleware.cs index 733b1699aa..a5817c5f02 100644 --- a/src/Umbraco.Web.BackOffice/Middleware/KeepAliveMiddleware.cs +++ b/src/Umbraco.Web.BackOffice/Middleware/KeepAliveMiddleware.cs @@ -1,26 +1,22 @@ -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -namespace Umbraco.Cms.Web.BackOffice.Middleware +namespace Umbraco.Cms.Web.BackOffice.Middleware; + +/// +/// Used for the Umbraco keep alive service. This is terminating middleware. +/// +public class KeepAliveMiddleware : IMiddleware { - - /// - /// Used for the Umbraco keep alive service. This is terminating middleware. - /// - public class KeepAliveMiddleware : IMiddleware + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { - public async Task InvokeAsync(HttpContext context, RequestDelegate next) + if (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) { - if (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) - { - context.Response.StatusCode = StatusCodes.Status200OK; - await context.Response.WriteAsync("I'm alive"); - - } - else - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - } + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync("I'm alive"); + } + else + { + context.Response.StatusCode = StatusCodes.Status404NotFound; } } } diff --git a/src/Umbraco.Web.BackOffice/ModelBinders/BlueprintItemBinder.cs b/src/Umbraco.Web.BackOffice/ModelBinders/BlueprintItemBinder.cs index 355953d278..bc07497fcd 100644 --- a/src/Umbraco.Web.BackOffice/ModelBinders/BlueprintItemBinder.cs +++ b/src/Umbraco.Web.BackOffice/ModelBinders/BlueprintItemBinder.cs @@ -1,24 +1,19 @@ -using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Web.BackOffice.ModelBinders +namespace Umbraco.Cms.Web.BackOffice.ModelBinders; + +internal class BlueprintItemBinder : ContentItemBinder { - internal class BlueprintItemBinder : ContentItemBinder - { - private readonly IContentService _contentService; + private readonly IContentService _contentService; - public BlueprintItemBinder(IJsonSerializer jsonSerializer, IUmbracoMapper umbracoMapper, IContentService contentService, IContentTypeService contentTypeService, IHostingEnvironment hostingEnvironment) : base(jsonSerializer, umbracoMapper, contentService, contentTypeService, hostingEnvironment) - { - _contentService = contentService; - } + public BlueprintItemBinder(IJsonSerializer jsonSerializer, IUmbracoMapper umbracoMapper, IContentService contentService, IContentTypeService contentTypeService, IHostingEnvironment hostingEnvironment) + : base(jsonSerializer, umbracoMapper, contentService, contentTypeService, hostingEnvironment) => + _contentService = contentService; - protected override IContent? GetExisting(ContentItemSave model) - { - return _contentService.GetBlueprintById(model.Id); - } - } + protected override IContent? GetExisting(ContentItemSave model) => _contentService.GetBlueprintById(model.Id); } diff --git a/src/Umbraco.Web.BackOffice/ModelBinders/ContentItemBinder.cs b/src/Umbraco.Web.BackOffice/ModelBinders/ContentItemBinder.cs index 3cbaf4a592..0842ca2051 100644 --- a/src/Umbraco.Web.BackOffice/ModelBinders/ContentItemBinder.cs +++ b/src/Umbraco.Web.BackOffice/ModelBinders/ContentItemBinder.cs @@ -1,6 +1,3 @@ -using System; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Mapping; @@ -11,96 +8,97 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.ModelBinders +namespace Umbraco.Cms.Web.BackOffice.ModelBinders; + +/// +/// The model binder for +/// +internal class ContentItemBinder : IModelBinder { - /// - /// The model binder for - /// - internal class ContentItemBinder : IModelBinder + private readonly IContentService _contentService; + private readonly IContentTypeService _contentTypeService; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IJsonSerializer _jsonSerializer; + private readonly IUmbracoMapper _umbracoMapper; + private readonly ContentModelBinderHelper _modelBinderHelper; + + public ContentItemBinder( + IJsonSerializer jsonSerializer, + IUmbracoMapper umbracoMapper, + IContentService contentService, + IContentTypeService contentTypeService, + IHostingEnvironment hostingEnvironment) { - private readonly IJsonSerializer _jsonSerializer; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IContentService _contentService; - private readonly IContentTypeService _contentTypeService; - private readonly IHostingEnvironment _hostingEnvironment; - private ContentModelBinderHelper _modelBinderHelper; + _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); + _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); + _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); + _modelBinderHelper = new ContentModelBinderHelper(); + } - public ContentItemBinder( - IJsonSerializer jsonSerializer, - IUmbracoMapper umbracoMapper, - IContentService contentService, - IContentTypeService contentTypeService, - IHostingEnvironment hostingEnvironment) + + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) { - _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); - _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); - _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); - _modelBinderHelper = new ContentModelBinderHelper(); + throw new ArgumentNullException(nameof(bindingContext)); } - protected virtual IContent? GetExisting(ContentItemSave model) + ContentItemSave? model = + await _modelBinderHelper.BindModelFromMultipartRequestAsync(_jsonSerializer, + _hostingEnvironment, bindingContext); + + if (model is null) { - return _contentService.GetById(model.Id); + return; } - private IContent CreateNew(ContentItemSave model) + IContent? persistedContent = + ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model); + BindModel(model, persistedContent!, _modelBinderHelper, _umbracoMapper); + + bindingContext.Result = ModelBindingResult.Success(model); + } + + protected virtual IContent? GetExisting(ContentItemSave model) => _contentService.GetById(model.Id); + + private IContent CreateNew(ContentItemSave model) + { + IContentType? contentType = _contentTypeService.Get(model.ContentTypeAlias); + if (contentType == null) { - var contentType = _contentTypeService.Get(model.ContentTypeAlias); - if (contentType == null) - { - throw new InvalidOperationException("No content type found with alias " + model.ContentTypeAlias); - } - return new Content( - contentType.VariesByCulture() ? null : model.Variants.First().Name, - model.ParentId, - contentType); + throw new InvalidOperationException("No content type found with alias " + model.ContentTypeAlias); } + return new Content( + contentType.VariesByCulture() ? null : model.Variants.First().Name, + model.ParentId, + contentType); + } - public async Task BindModelAsync(ModelBindingContext bindingContext) + internal static void BindModel(ContentItemSave model, IContent persistedContent, + ContentModelBinderHelper modelBinderHelper, IUmbracoMapper umbracoMapper) + { + model.PersistedContent = persistedContent; + + //create the dto from the persisted model + if (model.PersistedContent != null) { - if (bindingContext == null) + foreach (ContentVariantSave variant in model.Variants) { - throw new ArgumentNullException(nameof(bindingContext)); - } + //map the property dto collection with the culture of the current variant + variant.PropertyCollectionDto = umbracoMapper.Map( + model.PersistedContent, + context => + { + // either of these may be null and that is ok, if it's invariant they will be null which is what is expected + context.SetCulture(variant.Culture); + context.SetSegment(variant.Segment); + }); - var model = await _modelBinderHelper.BindModelFromMultipartRequestAsync(_jsonSerializer, _hostingEnvironment, bindingContext); - - if (model is null) - { - return; - } - - var persistedContent = ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model); - BindModel(model, persistedContent!, _modelBinderHelper, _umbracoMapper); - - bindingContext.Result = ModelBindingResult.Success(model); - } - - internal static void BindModel(ContentItemSave model, IContent persistedContent, ContentModelBinderHelper modelBinderHelper, IUmbracoMapper umbracoMapper) - { - model.PersistedContent =persistedContent; - - //create the dto from the persisted model - if (model.PersistedContent != null) - { - foreach (var variant in model.Variants) - { - //map the property dto collection with the culture of the current variant - variant.PropertyCollectionDto = umbracoMapper.Map( - model.PersistedContent, - context => - { - // either of these may be null and that is ok, if it's invariant they will be null which is what is expected - context.SetCulture(variant.Culture); - context.SetSegment(variant.Segment); - }); - - //now map all of the saved values to the dto - modelBinderHelper.MapPropertyValuesFromSaved(variant, variant.PropertyCollectionDto); - } + //now map all of the saved values to the dto + modelBinderHelper.MapPropertyValuesFromSaved(variant, variant.PropertyCollectionDto); } } } diff --git a/src/Umbraco.Web.BackOffice/ModelBinders/ContentModelBinderHelper.cs b/src/Umbraco.Web.BackOffice/ModelBinders/ContentModelBinderHelper.cs index 9193b5db96..3632b42d8e 100644 --- a/src/Umbraco.Web.BackOffice/ModelBinders/ContentModelBinderHelper.cs +++ b/src/Umbraco.Web.BackOffice/ModelBinders/ContentModelBinderHelper.cs @@ -1,140 +1,144 @@ -using System; -using System.IO; -using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.Serialization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.ModelBinders +namespace Umbraco.Cms.Web.BackOffice.ModelBinders; + +/// +/// Helper methods to bind media/member models +/// +internal class ContentModelBinderHelper { - /// - /// Helper methods to bind media/member models - /// - internal class ContentModelBinderHelper + public async Task BindModelFromMultipartRequestAsync( + IJsonSerializer jsonSerializer, + IHostingEnvironment hostingEnvironment, + ModelBindingContext bindingContext) + where T : class, IHaveUploadedFiles { - public async Task BindModelFromMultipartRequestAsync( - IJsonSerializer jsonSerializer, - IHostingEnvironment hostingEnvironment, - ModelBindingContext bindingContext) - where T: class, IHaveUploadedFiles + var modelName = bindingContext.ModelName; + + ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + + if (valueProviderResult == ValueProviderResult.None) { - var modelName = bindingContext.ModelName; - - var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); - - if (valueProviderResult == ValueProviderResult.None) - { - return null; - } - bindingContext.ModelState.SetModelValue(modelName, valueProviderResult); - - var value = valueProviderResult.FirstValue; - - // Check if the argument value is null or empty - if (string.IsNullOrEmpty(value)) - { - return null; - } - var model = jsonSerializer.Deserialize(value); - if (model is null) - { - // Non-integer arguments result in model state errors - bindingContext.ModelState.TryAddModelError( - modelName, $"Cannot deserialize {modelName} as {nameof(T)}."); - - return null; - } - - //Handle file uploads - foreach (var formFile in bindingContext.HttpContext.Request.Form.Files) - { - //The name that has been assigned in JS has 2 or more parts. The second part indicates the property id - // for which the file belongs, the remaining parts are just metadata that can be used by the property editor. - var parts = formFile.Name.Trim(Constants.CharArrays.DoubleQuote).Split(Constants.CharArrays.Underscore); - if (parts.Length < 2) - { - bindingContext.HttpContext.SetReasonPhrase( "The request was not formatted correctly the file name's must be underscore delimited"); - return null; - } - var propAlias = parts[1]; - - //if there are 3 parts part 3 is always culture - string? culture = null; - if (parts.Length > 2) - { - culture = parts[2]; - //normalize to null if empty - if (culture.IsNullOrWhiteSpace()) - { - culture = null; - } - } - - //if there are 4 parts part 4 is always segment - string? segment = null; - if (parts.Length > 3) - { - segment = parts[3]; - //normalize to null if empty - if (segment.IsNullOrWhiteSpace()) - { - segment = null; - } - } - - // TODO: anything after 4 parts we can put in metadata - - var fileName = formFile.FileName.Trim(Constants.CharArrays.DoubleQuote); - - var tempFileUploadFolder = hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); - Directory.CreateDirectory(tempFileUploadFolder); - var tempFilePath = Path.Combine(tempFileUploadFolder, Guid.NewGuid().ToString()); - - using (var stream = System.IO.File.Create(tempFilePath)) - { - await formFile.CopyToAsync(stream); - } - - model.UploadedFiles.Add(new ContentPropertyFile - { - TempFilePath = tempFilePath, - PropertyAlias = propAlias, - Culture = culture, - Segment = segment, - FileName = fileName - }); - } - - return model; + return null; } - /// - /// we will now assign all of the values in the 'save' model to the DTO object - /// - /// - /// - public void MapPropertyValuesFromSaved(IContentProperties saveModel, - ContentPropertyCollectionDto? dto) + bindingContext.ModelState.SetModelValue(modelName, valueProviderResult); + + var value = valueProviderResult.FirstValue; + + // Check if the argument value is null or empty + if (string.IsNullOrEmpty(value)) { - //NOTE: Don't convert this to linq, this is much quicker - foreach (var p in saveModel.Properties) + return null; + } + + T? model = jsonSerializer.Deserialize(value); + if (model is null) + { + // Non-integer arguments result in model state errors + bindingContext.ModelState.TryAddModelError( + modelName, $"Cannot deserialize {modelName} as {nameof(T)}."); + + return null; + } + + //Handle file uploads + foreach (IFormFile formFile in bindingContext.HttpContext.Request.Form.Files) + { + //The name that has been assigned in JS has 2 or more parts. The second part indicates the property id + // for which the file belongs, the remaining parts are just metadata that can be used by the property editor. + var parts = formFile.Name.Trim(Constants.CharArrays.DoubleQuote).Split(Constants.CharArrays.Underscore); + if (parts.Length < 2) { - if (dto is not null) + bindingContext.HttpContext.SetReasonPhrase( + "The request was not formatted correctly the file name's must be underscore delimited"); + return null; + } + + var propAlias = parts[1]; + + //if there are 3 parts part 3 is always culture + string? culture = null; + if (parts.Length > 2) + { + culture = parts[2]; + //normalize to null if empty + if (culture.IsNullOrWhiteSpace()) { - foreach (var propertyDto in dto.Properties) + culture = null; + } + } + + //if there are 4 parts part 4 is always segment + string? segment = null; + if (parts.Length > 3) + { + segment = parts[3]; + //normalize to null if empty + if (segment.IsNullOrWhiteSpace()) + { + segment = null; + } + } + + // TODO: anything after 4 parts we can put in metadata + + var fileName = formFile.FileName.Trim(Constants.CharArrays.DoubleQuote); + + var tempFileUploadFolder = + hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); + Directory.CreateDirectory(tempFileUploadFolder); + var tempFilePath = Path.Combine(tempFileUploadFolder, Guid.NewGuid().ToString()); + + using (FileStream stream = File.Create(tempFilePath)) + { + await formFile.CopyToAsync(stream); + } + + model.UploadedFiles.Add(new ContentPropertyFile + { + TempFilePath = tempFilePath, + PropertyAlias = propAlias, + Culture = culture, + Segment = segment, + FileName = fileName + }); + } + + return model; + } + + /// + /// we will now assign all of the values in the 'save' model to the DTO object + /// + /// + /// + public void MapPropertyValuesFromSaved(IContentProperties saveModel, + ContentPropertyCollectionDto? dto) + { + //NOTE: Don't convert this to linq, this is much quicker + foreach (ContentPropertyBasic p in saveModel.Properties) + { + if (dto is not null) + { + foreach (ContentPropertyDto propertyDto in dto.Properties) + { + if (propertyDto.Alias != p.Alias) { - if (propertyDto.Alias != p.Alias) continue; - propertyDto.Value = p.Value; - break; + continue; } + + propertyDto.Value = p.Value; + break; } } } - - } } diff --git a/src/Umbraco.Web.BackOffice/ModelBinders/FromJsonPathAttribute.cs b/src/Umbraco.Web.BackOffice/ModelBinders/FromJsonPathAttribute.cs index 274a150465..996181e354 100644 --- a/src/Umbraco.Web.BackOffice/ModelBinders/FromJsonPathAttribute.cs +++ b/src/Umbraco.Web.BackOffice/ModelBinders/FromJsonPathAttribute.cs @@ -1,95 +1,89 @@ -using System; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core; using Umbraco.Extensions; -using HttpMethod = System.Net.Http.HttpMethod; -namespace Umbraco.Cms.Web.BackOffice.ModelBinders +namespace Umbraco.Cms.Web.BackOffice.ModelBinders; + +/// +/// Used to bind a value from an inner json property +/// +/// +/// An example would be if you had json like: +/// { ids: [1,2,3,4] } +/// And you had an action like: GetByIds(int[] ids, UmbracoEntityTypes type) +/// The ids array will not bind because the object being sent up is an object and not an array so the +/// normal json formatter will not figure this out. +/// This would also let you bind sub levels of the JSON being sent up too if you wanted with any jsonpath +/// +public class FromJsonPathAttribute : ModelBinderAttribute { - /// - /// Used to bind a value from an inner json property - /// - /// - /// An example would be if you had json like: - /// { ids: [1,2,3,4] } - /// And you had an action like: GetByIds(int[] ids, UmbracoEntityTypes type) - /// The ids array will not bind because the object being sent up is an object and not an array so the - /// normal json formatter will not figure this out. - /// This would also let you bind sub levels of the JSON being sent up too if you wanted with any jsonpath - /// - public class FromJsonPathAttribute : ModelBinderAttribute + public FromJsonPathAttribute() : base(typeof(JsonPathBinder)) { - public FromJsonPathAttribute() : base(typeof(JsonPathBinder)) - { + } + internal class JsonPathBinder : IModelBinder + { + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext.HttpContext.Request.Method.Equals(HttpMethod.Get.ToString(), StringComparison.InvariantCultureIgnoreCase)) + { + return; + } + + if (TryModelBindFromHttpContextItems(bindingContext)) + { + return; + } + + var strJson = await bindingContext.HttpContext.Request.GetRawBodyStringAsync(); + + + if (string.IsNullOrWhiteSpace(strJson)) + { + return; + } + + JObject? json = JsonConvert.DeserializeObject(strJson); + + //if no explicit json path then use the model name + JToken? match = json?.SelectToken(bindingContext.FieldName ?? bindingContext.ModelName); + + if (match == null) + { + return; + } + + var model = match.ToObject(bindingContext.ModelType); + + bindingContext.Result = ModelBindingResult.Success(model); } - internal class JsonPathBinder : IModelBinder + public static bool TryModelBindFromHttpContextItems(ModelBindingContext bindingContext) { - public async Task BindModelAsync(ModelBindingContext bindingContext) + const string key = Constants.HttpContext.Items.RequestBodyAsJObject; + + if (!bindingContext.HttpContext.Items.TryGetValue(key, out var cached)) { - if (bindingContext.HttpContext.Request.Method.Equals(HttpMethod.Get.ToString(), StringComparison.InvariantCultureIgnoreCase)) - { - return; - } - - if (TryModelBindFromHttpContextItems(bindingContext)) - { - return; - } - - var strJson = await bindingContext.HttpContext.Request.GetRawBodyStringAsync(); - - - if (string.IsNullOrWhiteSpace(strJson)) - { - return; - } - - var json = JsonConvert.DeserializeObject(strJson); - - //if no explicit json path then use the model name - var match = json?.SelectToken(bindingContext.FieldName ?? bindingContext.ModelName); - - if (match == null) - { - return; - } - - var model = match.ToObject(bindingContext.ModelType); - - bindingContext.Result = ModelBindingResult.Success(model); + return false; } - public static bool TryModelBindFromHttpContextItems(ModelBindingContext bindingContext) + if (cached is not JObject json) { - const string key = Constants.HttpContext.Items.RequestBodyAsJObject; - - if (!bindingContext.HttpContext.Items.TryGetValue(key, out var cached)) - { - return false; - } - - if (cached is not JObject json) - { - return false; - } - - JToken? match = json.SelectToken(bindingContext.FieldName); - - // ReSharper disable once InvertIf - if (match != null) - { - bindingContext.Result = ModelBindingResult.Success(match.ToObject(bindingContext.ModelType)); - } - - return true; + return false; } + + JToken? match = json.SelectToken(bindingContext.FieldName); + + // ReSharper disable once InvertIf + if (match != null) + { + bindingContext.Result = ModelBindingResult.Success(match.ToObject(bindingContext.ModelType)); + } + + return true; } } } diff --git a/src/Umbraco.Web.BackOffice/ModelBinders/MediaItemBinder.cs b/src/Umbraco.Web.BackOffice/ModelBinders/MediaItemBinder.cs index fcedb0a0af..a25496dc96 100644 --- a/src/Umbraco.Web.BackOffice/ModelBinders/MediaItemBinder.cs +++ b/src/Umbraco.Web.BackOffice/ModelBinders/MediaItemBinder.cs @@ -1,5 +1,3 @@ -using System; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Mapping; @@ -9,75 +7,79 @@ using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Controllers; -namespace Umbraco.Cms.Web.BackOffice.ModelBinders +namespace Umbraco.Cms.Web.BackOffice.ModelBinders; + +/// +/// The model binder for +/// +internal class MediaItemBinder : IModelBinder { - /// - /// The model binder for - /// - internal class MediaItemBinder : IModelBinder + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IJsonSerializer _jsonSerializer; + private readonly IMediaService _mediaService; + private readonly IMediaTypeService _mediaTypeService; + private readonly ContentModelBinderHelper _modelBinderHelper; + private readonly IUmbracoMapper _umbracoMapper; + + + public MediaItemBinder( + IJsonSerializer jsonSerializer, + IHostingEnvironment hostingEnvironment, + IMediaService mediaService, + IUmbracoMapper umbracoMapper, + IMediaTypeService mediaTypeService) { - private readonly IJsonSerializer _jsonSerializer; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly IMediaService _mediaService; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IMediaTypeService _mediaTypeService; - private readonly ContentModelBinderHelper _modelBinderHelper; + _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); + _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); + _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _mediaTypeService = mediaTypeService ?? throw new ArgumentNullException(nameof(mediaTypeService)); + _modelBinderHelper = new ContentModelBinderHelper(); + } - public MediaItemBinder(IJsonSerializer jsonSerializer, IHostingEnvironment hostingEnvironment, IMediaService mediaService, IUmbracoMapper umbracoMapper, IMediaTypeService mediaTypeService) + /// + /// Creates the model from the request and binds it to the context + /// + /// + /// + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + MediaItemSave? model = + await _modelBinderHelper.BindModelFromMultipartRequestAsync(_jsonSerializer, _hostingEnvironment, bindingContext); + if (model == null) { - _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); - _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); - _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _mediaTypeService = mediaTypeService ?? throw new ArgumentNullException(nameof(mediaTypeService)); - - _modelBinderHelper = new ContentModelBinderHelper(); + return; } - /// - /// Creates the model from the request and binds it to the context - /// - /// - /// - public async Task BindModelAsync(ModelBindingContext bindingContext) + model.PersistedContent = ContentControllerBase.IsCreatingAction(model.Action) + ? CreateNew(model) + : GetExisting(model)!; + + //create the dto from the persisted model + if (model.PersistedContent != null) { - - var model = await _modelBinderHelper.BindModelFromMultipartRequestAsync(_jsonSerializer, _hostingEnvironment, bindingContext); - if (model == null) - { - return; - } - - model.PersistedContent = ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model)!; - - //create the dto from the persisted model - if (model.PersistedContent != null) - { - model.PropertyCollectionDto = _umbracoMapper.Map(model.PersistedContent); - //now map all of the saved values to the dto - _modelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto); - } - - model.Name = model.Name?.Trim(); - - bindingContext.Result = ModelBindingResult.Success(model); + model.PropertyCollectionDto = + _umbracoMapper.Map(model.PersistedContent); + //now map all of the saved values to the dto + _modelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto); } - private IMedia? GetExisting(MediaItemSave model) + model.Name = model.Name?.Trim(); + + bindingContext.Result = ModelBindingResult.Success(model); + } + + private IMedia? GetExisting(MediaItemSave model) => _mediaService.GetById(Convert.ToInt32(model.Id)); + + private IMedia CreateNew(MediaItemSave model) + { + IMediaType? mediaType = _mediaTypeService.Get(model.ContentTypeAlias); + if (mediaType == null) { - return _mediaService.GetById(Convert.ToInt32(model.Id)); - } - - private IMedia CreateNew(MediaItemSave model) - { - var mediaType = _mediaTypeService.Get(model.ContentTypeAlias); - if (mediaType == null) - { - throw new InvalidOperationException("No media type found with alias " + model.ContentTypeAlias); - } - return new Cms.Core.Models.Media(model.Name, model.ParentId, mediaType); + throw new InvalidOperationException("No media type found with alias " + model.ContentTypeAlias); } + return new Media(model.Name, model.ParentId, mediaType); } } diff --git a/src/Umbraco.Web.BackOffice/ModelBinders/MemberBinder.cs b/src/Umbraco.Web.BackOffice/ModelBinders/MemberBinder.cs index 6f0c5bd2ec..4ae47bccc0 100644 --- a/src/Umbraco.Web.BackOffice/ModelBinders/MemberBinder.cs +++ b/src/Umbraco.Web.BackOffice/ModelBinders/MemberBinder.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Hosting; @@ -13,137 +9,136 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Web.BackOffice.Controllers; -namespace Umbraco.Cms.Web.BackOffice.ModelBinders +namespace Umbraco.Cms.Web.BackOffice.ModelBinders; + +/// +/// The model binder for +/// +internal class MemberBinder : IModelBinder { - /// - /// The model binder for - /// - internal class MemberBinder : IModelBinder + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IJsonSerializer _jsonSerializer; + private readonly IMemberService _memberService; + private readonly IMemberTypeService _memberTypeService; + private readonly ContentModelBinderHelper _modelBinderHelper; + private readonly IShortStringHelper _shortStringHelper; + private readonly IUmbracoMapper _umbracoMapper; + + public MemberBinder( + IJsonSerializer jsonSerializer, + IHostingEnvironment hostingEnvironment, + IShortStringHelper shortStringHelper, + IUmbracoMapper umbracoMapper, + IMemberService memberService, + IMemberTypeService memberTypeService) { - private readonly ContentModelBinderHelper _modelBinderHelper; - private readonly IJsonSerializer _jsonSerializer; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly IShortStringHelper _shortStringHelper; - private readonly IUmbracoMapper _umbracoMapper; - private readonly IMemberService _memberService; - private readonly IMemberTypeService _memberTypeService; + _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); + _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); + _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); + _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); + _modelBinderHelper = new ContentModelBinderHelper(); + } - public MemberBinder( - IJsonSerializer jsonSerializer, - IHostingEnvironment hostingEnvironment, - IShortStringHelper shortStringHelper, - IUmbracoMapper umbracoMapper, - IMemberService memberService, - IMemberTypeService memberTypeService) + /// + /// Creates the model from the request and binds it to the context + /// + /// + /// + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + MemberSave? model = + await _modelBinderHelper.BindModelFromMultipartRequestAsync(_jsonSerializer, _hostingEnvironment, bindingContext); + if (model == null) { - _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); - _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); - _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); - _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); - _modelBinderHelper = new ContentModelBinderHelper(); + return; } - /// - /// Creates the model from the request and binds it to the context - /// - /// - /// - /// - public async Task BindModelAsync(ModelBindingContext bindingContext) + model.PersistedContent = + ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model); + + //create the dto from the persisted model + if (model.PersistedContent != null) { - var model = await _modelBinderHelper.BindModelFromMultipartRequestAsync(_jsonSerializer, _hostingEnvironment, bindingContext); - if (model == null) + model.PropertyCollectionDto = + _umbracoMapper.Map(model.PersistedContent); + //now map all of the saved values to the dto + _modelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto); + } + + model.Name = model.Name?.Trim(); + + bindingContext.Result = ModelBindingResult.Success(model); + } + + /// + /// Returns an IMember instance used to bind values to and save (depending on the membership scenario) + /// + /// + /// + private IMember GetExisting(MemberSave model) => GetExisting(model.Key); + + private IMember GetExisting(Guid key) + { + IMember? member = _memberService.GetByKey(key); + if (member == null) + { + throw new InvalidOperationException("Could not find member with key " + key); + } + + return member; + } + + /// + /// Gets an instance of IMember used when creating a member + /// + /// + /// + /// + /// Depending on whether a custom membership provider is configured this will return different results. + /// + private IMember CreateNew(MemberSave model) + { + IMemberType? contentType = _memberTypeService.Get(model.ContentTypeAlias); + if (contentType == null) + { + throw new InvalidOperationException("No member type found with alias " + model.ContentTypeAlias); + } + + //remove all membership properties, these values are set with the membership provider. + FilterMembershipProviderProperties(contentType); + + //return the new member with the details filled in + return new Member(model.Name, model.Email, model.Username, model.Password?.NewPassword, contentType); + } + + /// + /// This will remove all of the special membership provider properties which were required to display the property + /// editors + /// for editing - but the values have been mapped back to the MemberSave object directly - we don't want to keep these + /// properties + /// on the IMember because they will attempt to be persisted which we don't want since they might not even exist. + /// + /// + private void FilterMembershipProviderProperties(IContentTypeBase contentType) + { + Dictionary defaultProps = + ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper); + //remove all membership properties, these values are set with the membership provider. + var exclude = defaultProps.Select(x => x.Value.Alias).ToArray(); + FilterContentTypeProperties(contentType, exclude); + } + + private void FilterContentTypeProperties(IContentTypeBase contentType, IEnumerable exclude) + { + //remove all properties based on the exclusion list + foreach (var remove in exclude) + { + if (contentType.PropertyTypeExists(remove)) { - return; - } - - model.PersistedContent = ContentControllerBase.IsCreatingAction(model.Action) ? CreateNew(model) : GetExisting(model); - - //create the dto from the persisted model - if (model.PersistedContent != null) - { - model.PropertyCollectionDto = _umbracoMapper.Map(model.PersistedContent); - //now map all of the saved values to the dto - _modelBinderHelper.MapPropertyValuesFromSaved(model, model.PropertyCollectionDto); - } - - model.Name = model.Name?.Trim(); - - bindingContext.Result = ModelBindingResult.Success(model); - } - - /// - /// Returns an IMember instance used to bind values to and save (depending on the membership scenario) - /// - /// - /// - private IMember GetExisting(MemberSave model) - { - return GetExisting(model.Key); - } - - private IMember GetExisting(Guid key) - { - var member = _memberService.GetByKey(key); - if (member == null) - { - throw new InvalidOperationException("Could not find member with key " + key); - } - - return member; - } - - /// - /// Gets an instance of IMember used when creating a member - /// - /// - /// - /// - /// Depending on whether a custom membership provider is configured this will return different results. - /// - private IMember CreateNew(MemberSave model) - { - var contentType = _memberTypeService.Get(model.ContentTypeAlias); - if (contentType == null) - { - throw new InvalidOperationException("No member type found with alias " + model.ContentTypeAlias); - } - - //remove all membership properties, these values are set with the membership provider. - FilterMembershipProviderProperties(contentType); - - //return the new member with the details filled in - return new Member(model.Name, model.Email, model.Username, model.Password?.NewPassword, contentType); - } - - /// - /// This will remove all of the special membership provider properties which were required to display the property editors - /// for editing - but the values have been mapped back to the MemberSave object directly - we don't want to keep these properties - /// on the IMember because they will attempt to be persisted which we don't want since they might not even exist. - /// - /// - private void FilterMembershipProviderProperties(IContentTypeBase contentType) - { - var defaultProps = ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper); - //remove all membership properties, these values are set with the membership provider. - var exclude = defaultProps.Select(x => x.Value.Alias).ToArray(); - FilterContentTypeProperties(contentType, exclude); - } - - private void FilterContentTypeProperties(IContentTypeBase contentType, IEnumerable exclude) - { - //remove all properties based on the exclusion list - foreach (var remove in exclude) - { - if (contentType.PropertyTypeExists(remove)) - { - contentType.RemovePropertyType(remove); - } + contentType.RemovePropertyType(remove); } } - - } } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidator.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidator.cs index 5a73c7e3fe..31ac14f5d0 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidator.cs @@ -2,17 +2,16 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models.ContentEditing; -namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder +namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder; + +/// +/// Used to validate the aliases for the content type when MB is enabled to ensure that +/// no illegal aliases are used +/// +// ReSharper disable once UnusedMember.Global - This is typed scanned +public class ContentTypeModelValidator : ContentTypeModelValidatorBase { - /// - /// Used to validate the aliases for the content type when MB is enabled to ensure that - /// no illegal aliases are used - /// - // ReSharper disable once UnusedMember.Global - This is typed scanned - public class ContentTypeModelValidator : ContentTypeModelValidatorBase + public ContentTypeModelValidator(IOptions config) : base(config) { - public ContentTypeModelValidator(IOptions config) : base(config) - { - } } } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidatorBase.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidatorBase.cs index 0f70cb1326..ef59126c1c 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidatorBase.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidatorBase.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; @@ -10,85 +7,83 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder +namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder; + +public abstract class ContentTypeModelValidatorBase : EditorValidator + where TModel : ContentTypeSave + where TProperty : PropertyTypeBasic { - public abstract class ContentTypeModelValidatorBase : EditorValidator - where TModel : ContentTypeSave - where TProperty : PropertyTypeBasic + private readonly IOptions _config; + + public ContentTypeModelValidatorBase(IOptions config) => _config = config; + + protected override IEnumerable Validate(TModel model) { - private readonly IOptions _config; - - public ContentTypeModelValidatorBase(IOptions config) => _config = config; - - protected override IEnumerable Validate(TModel model) + // don't do anything if we're not enabled + if (_config.Value.ModelsMode == ModelsMode.Nothing) { - // don't do anything if we're not enabled - if (_config.Value.ModelsMode == ModelsMode.Nothing) - { - yield break; - } - - // list of reserved/disallowed aliases for content/media/member types - more can be added as the need arises - var reservedModelAliases = new[] { "system" }; - if (reservedModelAliases.Contains(model.Alias, StringComparer.OrdinalIgnoreCase)) - { - yield return new ValidationResult($"The model alias {model.Alias} is a reserved term and cannot be used", new[] { "Alias" }); - } - - TProperty[] properties = model.Groups.SelectMany(x => x.Properties) - .Where(x => x.Inherited == false) - .ToArray(); - - foreach (TProperty prop in properties) - { - PropertyGroupBasic propertyGroup = model.Groups.Single(x => x.Properties.Contains(prop)); - - if (model.Alias.ToLowerInvariant() == prop.Alias.ToLowerInvariant()) - { - string[] memberNames = new[] - { - $"Groups[{model.Groups.IndexOf(propertyGroup)}].Properties[{propertyGroup.Properties.IndexOf(prop)}].Alias" - }; - - yield return new ValidationResult( - string.Format("With Models Builder enabled, you can't have a property with a the alias \"{0}\" when the content type alias is also \"{0}\".", prop.Alias), - memberNames); - } - - // we need to return the field name with an index so it's wired up correctly - var groupIndex = model.Groups.IndexOf(propertyGroup); - var propertyIndex = propertyGroup.Properties.IndexOf(prop); - - ValidationResult? validationResult = ValidateProperty(prop, groupIndex, propertyIndex); - if (validationResult != null) - { - yield return validationResult; - } - } + yield break; } - private ValidationResult? ValidateProperty(PropertyTypeBasic property, int groupIndex, int propertyIndex) + // list of reserved/disallowed aliases for content/media/member types - more can be added as the need arises + var reservedModelAliases = new[] { "system" }; + if (reservedModelAliases.Contains(model.Alias, StringComparer.OrdinalIgnoreCase)) { - // don't let them match any properties or methods in IPublishedContent - // TODO: There are probably more! - var reservedProperties = typeof(IPublishedContent).GetProperties().Select(x => x.Name).ToArray(); - var reservedMethods = typeof(IPublishedContent).GetMethods().Select(x => x.Name).ToArray(); + yield return new ValidationResult($"The model alias {model.Alias} is a reserved term and cannot be used", new[] { "Alias" }); + } - var alias = property.Alias; + TProperty[] properties = model.Groups.SelectMany(x => x.Properties) + .Where(x => x.Inherited == false) + .ToArray(); - if (reservedProperties.InvariantContains(alias) || reservedMethods.InvariantContains(alias)) + foreach (TProperty prop in properties) + { + PropertyGroupBasic propertyGroup = model.Groups.Single(x => x.Properties.Contains(prop)); + + if (model.Alias.ToLowerInvariant() == prop.Alias.ToLowerInvariant()) { - string[] memberNames = new[] - { - $"Groups[{groupIndex}].Properties[{propertyIndex}].Alias" - }; + string[] memberNames = + { + $"Groups[{model.Groups.IndexOf(propertyGroup)}].Properties[{propertyGroup.Properties.IndexOf(prop)}].Alias" + }; - return new ValidationResult( - $"The alias {alias} is a reserved term and cannot be used", + yield return new ValidationResult( + string.Format( + "With Models Builder enabled, you can't have a property with a the alias \"{0}\" when the content type alias is also \"{0}\".", + prop.Alias), memberNames); } - return null; + // we need to return the field name with an index so it's wired up correctly + var groupIndex = model.Groups.IndexOf(propertyGroup); + var propertyIndex = propertyGroup.Properties.IndexOf(prop); + + ValidationResult? validationResult = ValidateProperty(prop, groupIndex, propertyIndex); + if (validationResult != null) + { + yield return validationResult; + } } } + + private ValidationResult? ValidateProperty(PropertyTypeBasic property, int groupIndex, int propertyIndex) + { + // don't let them match any properties or methods in IPublishedContent + // TODO: There are probably more! + var reservedProperties = typeof(IPublishedContent).GetProperties().Select(x => x.Name).ToArray(); + var reservedMethods = typeof(IPublishedContent).GetMethods().Select(x => x.Name).ToArray(); + + var alias = property.Alias; + + if (reservedProperties.InvariantContains(alias) || reservedMethods.InvariantContains(alias)) + { + string[] memberNames = { $"Groups[{groupIndex}].Properties[{propertyIndex}].Alias" }; + + return new ValidationResult( + $"The alias {alias} is a reserved term and cannot be used", + memberNames); + } + + return null; + } } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/DashboardReport.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/DashboardReport.cs index e52846051d..481cda39b9 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/DashboardReport.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/DashboardReport.cs @@ -1,78 +1,83 @@ using System.Text; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Infrastructure.ModelsBuilder; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder +namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder; + +internal class DashboardReport { - internal class DashboardReport + private readonly ModelsBuilderSettings _config; + private readonly ModelsGenerationError _mbErrors; + private readonly OutOfDateModelsStatus _outOfDateModels; + + public DashboardReport(IOptions config, OutOfDateModelsStatus outOfDateModels, + ModelsGenerationError mbErrors) { - private readonly ModelsBuilderSettings _config; - private readonly OutOfDateModelsStatus _outOfDateModels; - private readonly ModelsGenerationError _mbErrors; + _config = config.Value; + _outOfDateModels = outOfDateModels; + _mbErrors = mbErrors; + } - public DashboardReport(IOptions config, OutOfDateModelsStatus outOfDateModels, ModelsGenerationError mbErrors) + public bool CanGenerate() => _config.ModelsMode.SupportsExplicitGeneration(); + + public bool AreModelsOutOfDate() => _outOfDateModels.IsOutOfDate; + + public string? LastError() => _mbErrors.GetLastError(); + + public string Text() + { + var sb = new StringBuilder(); + + sb.Append("

Version: "); + sb.Append(ApiVersion.Current.Version); + sb.Append("

"); + + sb.Append("

ModelsBuilder is enabled, with the following configuration:

"); + + sb.Append("
    "); + + sb.Append("
  • The models mode is '"); + sb.Append(_config.ModelsMode.ToString()); + sb.Append("'. "); + + switch (_config.ModelsMode) { - _config = config.Value; - _outOfDateModels = outOfDateModels; - _mbErrors = mbErrors; + case ModelsMode.Nothing: + sb.Append( + "Strongly typed models are not generated. All content and cache will operate from instance of IPublishedContent only."); + break; + case ModelsMode.InMemoryAuto: + sb.Append( + "Strongly typed models are re-generated on startup and anytime schema changes (i.e. Content Type) are made. No recompilation necessary but the generated models are not available to code outside of Razor."); + break; + case ModelsMode.SourceCodeManual: + sb.Append( + "Strongly typed models are generated on demand. Recompilation is necessary and models are available to all CSharp code."); + break; + case ModelsMode.SourceCodeAuto: + sb.Append( + "Strong typed models are generated on demand and anytime schema changes (i.e. Content Type) are made. Recompilation is necessary and models are available to all CSharp code."); + break; } - public bool CanGenerate() => _config.ModelsMode.SupportsExplicitGeneration(); + sb.Append("
  • "); - public bool AreModelsOutOfDate() => _outOfDateModels.IsOutOfDate; - - public string? LastError() => _mbErrors.GetLastError(); - - public string Text() + if (_config.ModelsMode != ModelsMode.Nothing) { - var sb = new StringBuilder(); + sb.Append( + $"
  • Models namespace is {_config.ModelsNamespace ?? Constants.ModelsBuilder.DefaultModelsNamespace}.
  • "); - sb.Append("

    Version: "); - sb.Append(ApiVersion.Current.Version); - sb.Append("

    "); - - sb.Append("

    ModelsBuilder is enabled, with the following configuration:

    "); - - sb.Append("
      "); - - sb.Append("
    • The models mode is '"); - sb.Append(_config.ModelsMode.ToString()); - sb.Append("'. "); - - switch (_config.ModelsMode) - { - case ModelsMode.Nothing: - sb.Append("Strongly typed models are not generated. All content and cache will operate from instance of IPublishedContent only."); - break; - case ModelsMode.InMemoryAuto: - sb.Append("Strongly typed models are re-generated on startup and anytime schema changes (i.e. Content Type) are made. No recompilation necessary but the generated models are not available to code outside of Razor."); - break; - case ModelsMode.SourceCodeManual: - sb.Append("Strongly typed models are generated on demand. Recompilation is necessary and models are available to all CSharp code."); - break; - case ModelsMode.SourceCodeAuto: - sb.Append("Strong typed models are generated on demand and anytime schema changes (i.e. Content Type) are made. Recompilation is necessary and models are available to all CSharp code."); - break; - } - - sb.Append("
    • "); - - if (_config.ModelsMode != ModelsMode.Nothing) - { - sb.Append($"
    • Models namespace is {_config.ModelsNamespace ?? Constants.ModelsBuilder.DefaultModelsNamespace}.
    • "); - - sb.Append("
    • Tracking of out-of-date models is "); - sb.Append(_config.FlagOutOfDateModels ? "enabled" : "not enabled"); - sb.Append(".
    • "); - } - - sb.Append("
    "); - - return sb.ToString(); + sb.Append("
  • Tracking of out-of-date models is "); + sb.Append(_config.FlagOutOfDateModels ? "enabled" : "not enabled"); + sb.Append(".
  • "); } + + sb.Append("
"); + + return sb.ToString(); } } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/DisableModelsBuilderNotificationHandler.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/DisableModelsBuilderNotificationHandler.cs index e41b6d3d83..970fe7e778 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/DisableModelsBuilderNotificationHandler.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/DisableModelsBuilderNotificationHandler.cs @@ -2,22 +2,21 @@ using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Features; using Umbraco.Cms.Core.Notifications; -namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder +namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder; + +/// +/// Used in conjunction with +/// +internal class DisableModelsBuilderNotificationHandler : INotificationHandler { + private readonly UmbracoFeatures _features; + + public DisableModelsBuilderNotificationHandler(UmbracoFeatures features) => _features = features; + /// - /// Used in conjunction with + /// Handles the notification to disable MB controller features /// - internal class DisableModelsBuilderNotificationHandler : INotificationHandler - { - private readonly UmbracoFeatures _features; - - public DisableModelsBuilderNotificationHandler(UmbracoFeatures features) => _features = features; - - /// - /// Handles the notification to disable MB controller features - /// - public void Handle(UmbracoApplicationStartingNotification notification) => - // disable the embedded dashboard controller - _features.Disabled.Controllers.Add(); - } + public void Handle(UmbracoApplicationStartingNotification notification) => + // disable the embedded dashboard controller + _features.Disabled.Controllers.Add(); } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/MediaTypeModelValidator.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/MediaTypeModelValidator.cs index e3a99de492..1322df860d 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/MediaTypeModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/MediaTypeModelValidator.cs @@ -2,17 +2,16 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models.ContentEditing; -namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder +namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder; + +/// +/// Used to validate the aliases for the content type when MB is enabled to ensure that +/// no illegal aliases are used +/// +// ReSharper disable once UnusedMember.Global - This is typed scanned +public class MediaTypeModelValidator : ContentTypeModelValidatorBase { - /// - /// Used to validate the aliases for the content type when MB is enabled to ensure that - /// no illegal aliases are used - /// - // ReSharper disable once UnusedMember.Global - This is typed scanned - public class MediaTypeModelValidator : ContentTypeModelValidatorBase + public MediaTypeModelValidator(IOptions config) : base(config) { - public MediaTypeModelValidator(IOptions config) : base(config) - { - } } } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/MemberTypeModelValidator.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/MemberTypeModelValidator.cs index 995b0616f0..b0cee23b56 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/MemberTypeModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/MemberTypeModelValidator.cs @@ -2,17 +2,16 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models.ContentEditing; -namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder +namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder; + +/// +/// Used to validate the aliases for the content type when MB is enabled to ensure that +/// no illegal aliases are used +/// +// ReSharper disable once UnusedMember.Global - This is typed scanned +public class MemberTypeModelValidator : ContentTypeModelValidatorBase { - /// - /// Used to validate the aliases for the content type when MB is enabled to ensure that - /// no illegal aliases are used - /// - // ReSharper disable once UnusedMember.Global - This is typed scanned - public class MemberTypeModelValidator : ContentTypeModelValidatorBase + public MemberTypeModelValidator(IOptions config) : base(config) { - public MemberTypeModelValidator(IOptions config) : base(config) - { - } } } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/ModelsBuilderDashboardController.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/ModelsBuilderDashboardController.cs index ec81197733..1444307c2e 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/ModelsBuilderDashboardController.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/ModelsBuilderDashboardController.cs @@ -1,4 +1,3 @@ -using System; using System.Runtime.Serialization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -11,130 +10,122 @@ using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder +namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder; + +/// +/// API controller for use in the Umbraco back office with Angular resources +/// +/// +/// We've created a different controller for the backoffice/angular specifically this is to ensure that the +/// correct CSRF security is adhered to for angular and it also ensures that this controller is not subseptipal to +/// global WebApi formatters being changed since this is always forced to only return Angular JSON Specific formats. +/// +[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] +public class ModelsBuilderDashboardController : UmbracoAuthorizedJsonController { - /// - /// API controller for use in the Umbraco back office with Angular resources - /// - /// - /// We've created a different controller for the backoffice/angular specifically this is to ensure that the - /// correct CSRF security is adhered to for angular and it also ensures that this controller is not subseptipal to - /// global WebApi formatters being changed since this is always forced to only return Angular JSON Specific formats. - /// - [Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] - public class ModelsBuilderDashboardController : UmbracoAuthorizedJsonController + public enum OutOfDateType { - private readonly ModelsBuilderSettings _config; - private readonly ModelsGenerator _modelGenerator; - private readonly OutOfDateModelsStatus _outOfDateModels; - private readonly ModelsGenerationError _mbErrors; - private readonly DashboardReport _dashboardReport; + OutOfDate, + Current, + Unknown = 100 + } - public ModelsBuilderDashboardController(IOptions config, ModelsGenerator modelsGenerator, OutOfDateModelsStatus outOfDateModels, ModelsGenerationError mbErrors) + private readonly ModelsBuilderSettings _config; + private readonly DashboardReport _dashboardReport; + private readonly ModelsGenerationError _mbErrors; + private readonly ModelsGenerator _modelGenerator; + private readonly OutOfDateModelsStatus _outOfDateModels; + + public ModelsBuilderDashboardController(IOptions config, ModelsGenerator modelsGenerator, + OutOfDateModelsStatus outOfDateModels, ModelsGenerationError mbErrors) + { + _config = config.Value; + _modelGenerator = modelsGenerator; + _outOfDateModels = outOfDateModels; + _mbErrors = mbErrors; + _dashboardReport = new DashboardReport(config, outOfDateModels, mbErrors); + } + + // invoked by the dashboard + // requires that the user is logged into the backoffice and has access to the settings section + // beware! the name of the method appears in modelsbuilder.controller.js + [HttpPost] // use the http one, not mvc, with api controllers! + public IActionResult BuildModels() + { + try { - _config = config.Value; - _modelGenerator = modelsGenerator; - _outOfDateModels = outOfDateModels; - _mbErrors = mbErrors; - _dashboardReport = new DashboardReport(config, outOfDateModels, mbErrors); - } - - // invoked by the dashboard - // requires that the user is logged into the backoffice and has access to the settings section - // beware! the name of the method appears in modelsbuilder.controller.js - [HttpPost] // use the http one, not mvc, with api controllers! - public IActionResult BuildModels() - { - try + if (!_config.ModelsMode.SupportsExplicitGeneration()) { - if (!_config.ModelsMode.SupportsExplicitGeneration()) - { - var result2 = new BuildResult { Success = false, Message = "Models generation is not enabled." }; + var result2 = new BuildResult { Success = false, Message = "Models generation is not enabled." }; - return Ok(result2); - } - - _modelGenerator.GenerateModels(); - _mbErrors.Clear(); - } - catch (Exception e) - { - _mbErrors.Report("Failed to build models.", e); + return Ok(result2); } - return Ok(GetDashboardResult()); + _modelGenerator.GenerateModels(); + _mbErrors.Clear(); + } + catch (Exception e) + { + _mbErrors.Report("Failed to build models.", e); } - // invoked by the back-office - // requires that the user is logged into the backoffice and has access to the settings section - [HttpGet] // use the http one, not mvc, with api controllers! - public ActionResult GetModelsOutOfDateStatus() - { - var status = _outOfDateModels.IsEnabled - ? _outOfDateModels.IsOutOfDate - ? new OutOfDateStatus { Status = OutOfDateType.OutOfDate } - : new OutOfDateStatus { Status = OutOfDateType.Current } - : new OutOfDateStatus { Status = OutOfDateType.Unknown }; + return Ok(GetDashboardResult()); + } - return status; - } + // invoked by the back-office + // requires that the user is logged into the backoffice and has access to the settings section + [HttpGet] // use the http one, not mvc, with api controllers! + public ActionResult GetModelsOutOfDateStatus() + { + OutOfDateStatus status = _outOfDateModels.IsEnabled + ? _outOfDateModels.IsOutOfDate + ? new OutOfDateStatus { Status = OutOfDateType.OutOfDate } + : new OutOfDateStatus { Status = OutOfDateType.Current } + : new OutOfDateStatus { Status = OutOfDateType.Unknown }; - // invoked by the back-office - // requires that the user is logged into the backoffice and has access to the settings section - // beware! the name of the method appears in modelsbuilder.controller.js - [HttpGet] // use the http one, not mvc, with api controllers! - public ActionResult GetDashboard() => GetDashboardResult(); + return status; + } - private Dashboard GetDashboardResult() => new Dashboard - { - Mode = _config.ModelsMode, - Text = _dashboardReport.Text(), - CanGenerate = _dashboardReport.CanGenerate(), - OutOfDateModels = _dashboardReport.AreModelsOutOfDate(), - LastError = _dashboardReport.LastError(), - }; + // invoked by the back-office + // requires that the user is logged into the backoffice and has access to the settings section + // beware! the name of the method appears in modelsbuilder.controller.js + [HttpGet] // use the http one, not mvc, with api controllers! + public ActionResult GetDashboard() => GetDashboardResult(); - [DataContract] - public class BuildResult - { - [DataMember(Name = "success")] - public bool Success { get; set; } + private Dashboard GetDashboardResult() => new() + { + Mode = _config.ModelsMode, + Text = _dashboardReport.Text(), + CanGenerate = _dashboardReport.CanGenerate(), + OutOfDateModels = _dashboardReport.AreModelsOutOfDate(), + LastError = _dashboardReport.LastError() + }; - [DataMember(Name = "message")] - public string? Message { get; set; } - } + [DataContract] + public class BuildResult + { + [DataMember(Name = "success")] public bool Success { get; set; } - [DataContract] - public class Dashboard - { - [DataMember(Name = "mode")] - public ModelsMode Mode { get; set; } + [DataMember(Name = "message")] public string? Message { get; set; } + } - [DataMember(Name = "text")] - public string? Text { get; set; } + [DataContract] + public class Dashboard + { + [DataMember(Name = "mode")] public ModelsMode Mode { get; set; } - [DataMember(Name = "canGenerate")] - public bool CanGenerate { get; set; } + [DataMember(Name = "text")] public string? Text { get; set; } - [DataMember(Name = "outOfDateModels")] - public bool OutOfDateModels { get; set; } + [DataMember(Name = "canGenerate")] public bool CanGenerate { get; set; } - [DataMember(Name = "lastError")] - public string? LastError { get; set; } - } + [DataMember(Name = "outOfDateModels")] public bool OutOfDateModels { get; set; } - public enum OutOfDateType - { - OutOfDate, - Current, - Unknown = 100 - } + [DataMember(Name = "lastError")] public string? LastError { get; set; } + } - [DataContract] - public class OutOfDateStatus - { - [DataMember(Name = "status")] - public OutOfDateType Status { get; set; } - } + [DataContract] + public class OutOfDateStatus + { + [DataMember(Name = "status")] public OutOfDateType Status { get; set; } } } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/ModelsBuilderDashboardProvider.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/ModelsBuilderDashboardProvider.cs index 78bd7568a5..abad45634e 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/ModelsBuilderDashboardProvider.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/ModelsBuilderDashboardProvider.cs @@ -2,19 +2,15 @@ using Microsoft.AspNetCore.Routing; using Umbraco.Cms.Web.Common.ModelsBuilder; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder +namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder; + +public class ModelsBuilderDashboardProvider : IModelsBuilderDashboardProvider { - public class ModelsBuilderDashboardProvider: IModelsBuilderDashboardProvider - { - private readonly LinkGenerator _linkGenerator; + private readonly LinkGenerator _linkGenerator; - public ModelsBuilderDashboardProvider(LinkGenerator linkGenerator) - { - _linkGenerator = linkGenerator; - } + public ModelsBuilderDashboardProvider(LinkGenerator linkGenerator) => _linkGenerator = linkGenerator; - public string? GetUrl() => - _linkGenerator.GetUmbracoApiServiceBaseUrl(controller => - controller.BuildModels()); - } + public string? GetUrl() => + _linkGenerator.GetUmbracoApiServiceBaseUrl(controller => + controller.BuildModels()); } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/UmbracoBuilderExtensions.cs index a808c80578..f9bdcd1b77 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/UmbracoBuilderExtensions.cs @@ -3,29 +3,28 @@ using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Web.Common.ModelsBuilder; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder +namespace Umbraco.Cms.Web.BackOffice.ModelsBuilder; + +/// +/// Extension methods for for the common Umbraco functionality +/// +public static class UmbracoBuilderExtensions { /// - /// Extension methods for for the common Umbraco functionality + /// Adds the ModelsBuilder dashboard. /// - public static class UmbracoBuilderExtensions + public static IUmbracoBuilder AddModelsBuilderDashboard(this IUmbracoBuilder builder) { - /// - /// Adds the ModelsBuilder dashboard. - /// - public static IUmbracoBuilder AddModelsBuilderDashboard(this IUmbracoBuilder builder) - { - builder.Services.AddUnique(); - return builder; - } + builder.Services.AddUnique(); + return builder; + } - /// - /// Can be called if using an external models builder to remove the embedded models builder controller features - /// - public static IUmbracoBuilder DisableModelsBuilderControllers(this IUmbracoBuilder builder) - { - builder.Services.AddSingleton(); - return builder; - } + /// + /// Can be called if using an external models builder to remove the embedded models builder controller features + /// + public static IUmbracoBuilder DisableModelsBuilderControllers(this IUmbracoBuilder builder) + { + builder.Services.AddSingleton(); + return builder; } } diff --git a/src/Umbraco.Web.BackOffice/Profiling/WebProfilingController.cs b/src/Umbraco.Web.BackOffice/Profiling/WebProfilingController.cs index 211c5261e4..dbbe0adbbf 100644 --- a/src/Umbraco.Web.BackOffice/Profiling/WebProfilingController.cs +++ b/src/Umbraco.Web.BackOffice/Profiling/WebProfilingController.cs @@ -1,31 +1,23 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Profiling +namespace Umbraco.Cms.Web.BackOffice.Profiling; + +/// +/// The API controller used to display the state of the web profiler +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] +public class WebProfilingController : UmbracoAuthorizedJsonController { - /// - /// The API controller used to display the state of the web profiler - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - [Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] - public class WebProfilingController : UmbracoAuthorizedJsonController - { - private readonly IHostingEnvironment _hosting; + private readonly IHostingEnvironment _hosting; - public WebProfilingController(IHostingEnvironment hosting) - { - _hosting = hosting; - } + public WebProfilingController(IHostingEnvironment hosting) => _hosting = hosting; - public object GetStatus() - { - return new - { - Enabled = _hosting.IsDebugMode - }; - } - }} + public object GetStatus() => + new { Enabled = _hosting.IsDebugMode }; +} diff --git a/src/Umbraco.Web.BackOffice/PropertyEditors/NestedContentController.cs b/src/Umbraco.Web.BackOffice/PropertyEditors/NestedContentController.cs index b975a0114b..6cb8deb71b 100644 --- a/src/Umbraco.Web.BackOffice/PropertyEditors/NestedContentController.cs +++ b/src/Umbraco.Web.BackOffice/PropertyEditors/NestedContentController.cs @@ -1,40 +1,36 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.PropertyEditors +namespace Umbraco.Cms.Web.BackOffice.PropertyEditors; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class NestedContentController : UmbracoAuthorizedJsonController { - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class NestedContentController : UmbracoAuthorizedJsonController - { - private readonly IContentTypeService _contentTypeService; + private readonly IContentTypeService _contentTypeService; - public NestedContentController(IContentTypeService contentTypeService) + public NestedContentController(IContentTypeService contentTypeService) => _contentTypeService = contentTypeService; + + [HttpGet] + public IEnumerable GetContentTypes() => _contentTypeService + .GetAllElementTypes() + .OrderBy(x => x.SortOrder) + .Select(x => new { - _contentTypeService = contentTypeService; - } - - [HttpGet] - public IEnumerable GetContentTypes() => _contentTypeService - .GetAllElementTypes() - .OrderBy(x => x.SortOrder) - .Select(x => new - { - id = x.Id, - guid = x.Key, - name = x.Name, - alias = x.Alias, - icon = x.Icon, - tabs = x.CompositionPropertyGroups.Where(x => x.Type == PropertyGroupType.Group && x.GetParentAlias() is null).Select(y => y.Name).Distinct() - }); - } + id = x.Id, + guid = x.Key, + name = x.Name, + alias = x.Alias, + icon = x.Icon, + tabs = x.CompositionPropertyGroups + .Where(x => x.Type == PropertyGroupType.Group && x.GetParentAlias() is null) + .Select(y => y.Name).Distinct() + }); } diff --git a/src/Umbraco.Web.BackOffice/PropertyEditors/RichTextPreValueController.cs b/src/Umbraco.Web.BackOffice/PropertyEditors/RichTextPreValueController.cs index 3d0d746d5a..602914fe43 100644 --- a/src/Umbraco.Web.BackOffice/PropertyEditors/RichTextPreValueController.cs +++ b/src/Umbraco.Web.BackOffice/PropertyEditors/RichTextPreValueController.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -6,43 +5,34 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Attributes; -namespace Umbraco.Cms.Web.BackOffice.PropertyEditors +namespace Umbraco.Cms.Web.BackOffice.PropertyEditors; + +/// +/// ApiController to provide RTE configuration with available plugins and commands from the RTE config +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class RichTextPreValueController : UmbracoAuthorizedJsonController { - /// - /// ApiController to provide RTE configuration with available plugins and commands from the RTE config - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class RichTextPreValueController : UmbracoAuthorizedJsonController + private readonly IOptions _richTextEditorSettings; + + public RichTextPreValueController(IOptions richTextEditorSettings) => + _richTextEditorSettings = richTextEditorSettings; + + public RichTextEditorConfiguration GetConfiguration() { - private readonly IOptions _richTextEditorSettings; + RichTextEditorSettings? settings = _richTextEditorSettings.Value; - public RichTextPreValueController(IOptions richTextEditorSettings) + var config = new RichTextEditorConfiguration { - _richTextEditorSettings = richTextEditorSettings; - } + Plugins = settings.Plugins.Select(x => new RichTextEditorPlugin { Name = x }), + Commands = + settings.Commands.Select(x => + new RichTextEditorCommand { Alias = x.Alias, Mode = x.Mode, Name = x.Name }), + ValidElements = settings.ValidElements, + InvalidElements = settings.InvalidElements, + CustomConfig = settings.CustomConfig + }; - public RichTextEditorConfiguration GetConfiguration() - { - var settings = _richTextEditorSettings.Value; - - var config = new RichTextEditorConfiguration - { - Plugins = settings.Plugins.Select(x=>new RichTextEditorPlugin() - { - Name = x - }), - Commands = settings.Commands.Select(x=>new RichTextEditorCommand() - { - Alias = x.Alias, - Mode = x.Mode, - Name = x.Name - }), - ValidElements = settings.ValidElements, - InvalidElements = settings.InvalidElements, - CustomConfig = settings.CustomConfig - }; - - return config; - } + return config; } } diff --git a/src/Umbraco.Web.BackOffice/PropertyEditors/RteEmbedController.cs b/src/Umbraco.Web.BackOffice/PropertyEditors/RteEmbedController.cs index a42c6cd0cc..622b038b32 100644 --- a/src/Umbraco.Web.BackOffice/PropertyEditors/RteEmbedController.cs +++ b/src/Umbraco.Web.BackOffice/PropertyEditors/RteEmbedController.cs @@ -1,73 +1,73 @@ -using System; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Media.EmbedProviders; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Attributes; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.PropertyEditors +namespace Umbraco.Cms.Web.BackOffice.PropertyEditors; + +/// +/// A controller used for the embed dialog +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class RteEmbedController : UmbracoAuthorizedJsonController { - /// - /// A controller used for the embed dialog - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class RteEmbedController : UmbracoAuthorizedJsonController - { - private readonly EmbedProvidersCollection _embedCollection; - private readonly ILogger _logger; + private readonly EmbedProvidersCollection _embedCollection; + private readonly ILogger _logger; - public RteEmbedController(EmbedProvidersCollection embedCollection, ILogger logger) + public RteEmbedController(EmbedProvidersCollection embedCollection, ILogger logger) + { + _embedCollection = embedCollection ?? throw new ArgumentNullException(nameof(embedCollection)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public OEmbedResult GetEmbed(string url, int width, int height) + { + var result = new OEmbedResult(); + var foundMatch = false; + IEmbedProvider? matchedProvider = null; + + foreach (IEmbedProvider provider in _embedCollection) { - _embedCollection = embedCollection ?? throw new ArgumentNullException(nameof(embedCollection)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + // UrlSchemeRegex is an array of possible regex patterns to match against the URL + foreach (var urlPattern in provider.UrlSchemeRegex) + { + var regexPattern = new Regex(urlPattern, RegexOptions.IgnoreCase); + if (regexPattern.IsMatch(url)) + { + foundMatch = true; + matchedProvider = provider; + break; + } + } + + if (foundMatch) + { + break; + } } - public OEmbedResult GetEmbed(string url, int width, int height) + if (foundMatch == false) { - var result = new OEmbedResult(); - var foundMatch = false; - IEmbedProvider? matchedProvider = null; - - foreach (var provider in _embedCollection) - { - // UrlSchemeRegex is an array of possible regex patterns to match against the URL - foreach (var urlPattern in provider.UrlSchemeRegex) - { - var regexPattern = new Regex(urlPattern, RegexOptions.IgnoreCase); - if (regexPattern.IsMatch(url)) - { - foundMatch = true; - matchedProvider = provider; - break; - } - } - - if (foundMatch) - break; - } - - if(foundMatch == false) - { - //No matches return/ exit - result.OEmbedStatus = OEmbedStatus.NotSupported; - return result; - } - - try - { - result.SupportsDimensions = true; - result.Markup = matchedProvider?.GetMarkup(url, width, height); - result.OEmbedStatus = OEmbedStatus.Success; - } - catch(Exception ex) - { - _logger.LogError(ex, "Error embedding URL {Url} - width: {Width} height: {Height}", url, width, height); - result.OEmbedStatus = OEmbedStatus.Error; - } - + //No matches return/ exit + result.OEmbedStatus = OEmbedStatus.NotSupported; return result; } + + try + { + result.SupportsDimensions = true; + result.Markup = matchedProvider?.GetMarkup(url, width, height); + result.OEmbedStatus = OEmbedStatus.Success; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error embedding URL {Url} - width: {Width} height: {Height}", url, width, height); + result.OEmbedStatus = OEmbedStatus.Error; + } + + return result; } } diff --git a/src/Umbraco.Web.BackOffice/PropertyEditors/TagsDataController.cs b/src/Umbraco.Web.BackOffice/PropertyEditors/TagsDataController.cs index 3775170b1f..c8b32b220c 100644 --- a/src/Umbraco.Web.BackOffice/PropertyEditors/TagsDataController.cs +++ b/src/Umbraco.Web.BackOffice/PropertyEditors/TagsDataController.cs @@ -1,56 +1,53 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.PropertyEditors +namespace Umbraco.Cms.Web.BackOffice.PropertyEditors; + +/// +/// A controller used for type-ahead values for tags +/// +/// +/// DO NOT inherit from UmbracoAuthorizedJsonController since we don't want to use the angularized +/// json formatter as it causes problems. +/// +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class TagsDataController : UmbracoAuthorizedApiController { + private readonly ITagQuery _tagQuery; + + public TagsDataController(ITagQuery tagQuery) => + _tagQuery = tagQuery ?? throw new ArgumentNullException(nameof(tagQuery)); + /// - /// A controller used for type-ahead values for tags + /// Returns all tags matching tagGroup, culture and an optional query /// - /// - /// DO NOT inherit from UmbracoAuthorizedJsonController since we don't want to use the angularized - /// json formatter as it causes problems. - /// - [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] - public class TagsDataController : UmbracoAuthorizedApiController + /// + /// + /// + /// + [AllowHttpJsonConfigration] + public IEnumerable GetTags(string tagGroup, string? culture, string? query = null) { - private readonly ITagQuery _tagQuery; - - public TagsDataController(ITagQuery tagQuery) + if (culture == string.Empty) { - _tagQuery = tagQuery ?? throw new ArgumentNullException(nameof(tagQuery)); + culture = null; } - /// - /// Returns all tags matching tagGroup, culture and an optional query - /// - /// - /// - /// - /// - /// - [AllowHttpJsonConfigration] - public IEnumerable GetTags(string tagGroup, string? culture, string? query = null) + + IEnumerable result = _tagQuery.GetAllTags(tagGroup, culture); + + + if (!query.IsNullOrWhiteSpace()) { - if (culture == string.Empty) culture = null; - - var result = _tagQuery.GetAllTags(tagGroup, culture); - - - if (!query.IsNullOrWhiteSpace()) - { - //TODO: add the query to TagQuery + the tag service, this is ugly but all we can do for now. - //currently we are post filtering this :( but works for now - result = result.Where(x => x?.Text?.InvariantContains(query!) ?? false); - } - - return result.WhereNotNull().OrderBy(x => x.Text); + //TODO: add the query to TagQuery + the tag service, this is ugly but all we can do for now. + //currently we are post filtering this :( but works for now + result = result.Where(x => x?.Text?.InvariantContains(query!) ?? false); } + + return result.WhereNotNull().OrderBy(x => x.Text); } } diff --git a/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ContentPropertyValidationResult.cs b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ContentPropertyValidationResult.cs index c3495bc9a5..6379cffc59 100644 --- a/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ContentPropertyValidationResult.cs +++ b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ContentPropertyValidationResult.cs @@ -1,51 +1,52 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Newtonsoft.Json; using Umbraco.Cms.Core.PropertyEditors.Validation; -namespace Umbraco.Cms.Web.BackOffice.PropertyEditors.Validation +namespace Umbraco.Cms.Web.BackOffice.PropertyEditors.Validation; + +/// +/// Custom for content properties +/// +/// +/// This clones the original result and then ensures the nested result if it's the correct type. +/// For a more indepth explanation of how server side validation works with the angular app, see this GitHub PR: +/// https://github.com/umbraco/Umbraco-CMS/pull/8339 +/// +public class ContentPropertyValidationResult : ValidationResult { + private readonly string _culture; + private readonly string _segment; + + public ContentPropertyValidationResult(ValidationResult nested, string culture, string segment) + : base(nested.ErrorMessage, nested.MemberNames) + { + ComplexEditorResults = nested as ComplexEditorValidationResult; + _culture = culture; + _segment = segment; + } + /// - /// Custom for content properties + /// Nested validation results for the content property /// /// - /// This clones the original result and then ensures the nested result if it's the correct type. - /// - /// For a more indepth explanation of how server side validation works with the angular app, see this GitHub PR: - /// https://github.com/umbraco/Umbraco-CMS/pull/8339 + /// There can be nested results for complex editors that contain other editors /// - public class ContentPropertyValidationResult : ValidationResult + public ComplexEditorValidationResult? ComplexEditorResults { get; } + + /// + /// Return the if is null, else the + /// serialized + /// complex validation results + /// + /// + public override string ToString() { - private readonly string _culture; - private readonly string _segment; - - public ContentPropertyValidationResult(ValidationResult nested, string culture, string segment) - : base(nested.ErrorMessage, nested.MemberNames) + if (ComplexEditorResults == null) { - ComplexEditorResults = nested as ComplexEditorValidationResult; - _culture = culture; - _segment = segment; + return base.ToString(); } - /// - /// Nested validation results for the content property - /// - /// - /// There can be nested results for complex editors that contain other editors - /// - public ComplexEditorValidationResult? ComplexEditorResults { get; } - - /// - /// Return the if is null, else the serialized - /// complex validation results - /// - /// - public override string ToString() - { - if (ComplexEditorResults == null) - return base.ToString(); - - var json = JsonConvert.SerializeObject(this, new ValidationResultConverter(_culture, _segment)); - return json; - } + var json = JsonConvert.SerializeObject(this, new ValidationResultConverter(_culture, _segment)); + return json; } } diff --git a/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ValidationResultConverter.cs b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ValidationResultConverter.cs index cb88f4537d..53bc3bbb7d 100644 --- a/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ValidationResultConverter.cs +++ b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ValidationResultConverter.cs @@ -1,156 +1,157 @@ -using System; using System.ComponentModel.DataAnnotations; -using System.Linq; using Microsoft.AspNetCore.Mvc.ModelBinding; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; using Umbraco.Cms.Core.PropertyEditors.Validation; -using Umbraco.Cms.Web.BackOffice.Extensions; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.PropertyEditors.Validation +namespace Umbraco.Cms.Web.BackOffice.PropertyEditors.Validation; + +/// +/// Custom json converter for and +/// +/// +/// This converter is specifically used to convert validation results for content in order to be able to have nested +/// validation results for complex editors. +/// For a more indepth explanation of how server side validation works with the angular app, see this GitHub PR: +/// https://github.com/umbraco/Umbraco-CMS/pull/8339 +/// +internal class ValidationResultConverter : JsonConverter { + private readonly string _culture; + private readonly string _segment; + /// - /// Custom json converter for and + /// Constructor /// - /// - /// This converter is specifically used to convert validation results for content in order to be able to have nested - /// validation results for complex editors. - /// - /// For a more indepth explanation of how server side validation works with the angular app, see this GitHub PR: - /// https://github.com/umbraco/Umbraco-CMS/pull/8339 - /// - internal class ValidationResultConverter : JsonConverter + /// The culture of the containing property which will be transfered to all child model state + /// The segment of the containing property which will be transfered to all child model state + public ValidationResultConverter(string culture = "", string segment = "") { - private readonly string _culture; - private readonly string _segment; + _culture = culture; + _segment = segment; + } - /// - /// Constructor - /// - /// The culture of the containing property which will be transfered to all child model state - /// The segment of the containing property which will be transfered to all child model state - public ValidationResultConverter(string culture = "", string segment = "") + public override bool CanConvert(Type objectType) => typeof(ValidationResult).IsAssignableFrom(objectType); + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) => throw new NotImplementedException(); + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + var camelCaseSerializer = new JsonSerializer { - _culture = culture; - _segment = segment; + ContractResolver = new CamelCasePropertyNamesContractResolver(), + DefaultValueHandling = DefaultValueHandling.Ignore + }; + foreach (JsonConverter c in serializer.Converters) + { + camelCaseSerializer.Converters.Add(c); } - public override bool CanConvert(Type objectType) => typeof(ValidationResult).IsAssignableFrom(objectType); + var validationResult = (ValidationResult?)value; - public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + if (validationResult is ComplexEditorValidationResult nestedResult && nestedResult.ValidationResults.Count > 0) { - throw new NotImplementedException(); - } - - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - var camelCaseSerializer = new JsonSerializer + var ja = new JArray(); + foreach (ComplexEditorElementTypeValidationResult nested in nestedResult.ValidationResults) { - ContractResolver = new CamelCasePropertyNamesContractResolver(), - DefaultValueHandling = DefaultValueHandling.Ignore - }; - foreach (var c in serializer.Converters) - camelCaseSerializer.Converters.Add(c); - - var validationResult = (ValidationResult?)value; - - if (validationResult is ComplexEditorValidationResult nestedResult && nestedResult.ValidationResults.Count > 0) - { - var ja = new JArray(); - foreach(var nested in nestedResult.ValidationResults) - { - // recurse to write out the ComplexEditorElementTypeValidationResult - var block = JObject.FromObject(nested, camelCaseSerializer); - ja.Add(block); - } - if (nestedResult.ValidationResults.Count > 0) - { - ja.WriteTo(writer); - } + // recurse to write out the ComplexEditorElementTypeValidationResult + var block = JObject.FromObject(nested, camelCaseSerializer); + ja.Add(block); } - else if (validationResult is ComplexEditorElementTypeValidationResult elementTypeValidationResult && elementTypeValidationResult.ValidationResults.Count > 0) + + if (nestedResult.ValidationResults.Count > 0) { - var joElementType = new JObject + ja.WriteTo(writer); + } + } + else if (validationResult is ComplexEditorElementTypeValidationResult elementTypeValidationResult && + elementTypeValidationResult.ValidationResults.Count > 0) + { + var joElementType = new JObject + { + {"$id", elementTypeValidationResult.BlockId}, + + // We don't use this anywhere, though it's nice for debugging + {"$elementTypeAlias", elementTypeValidationResult.ElementTypeAlias} + }; + + var modelState = new ModelStateDictionary(); + + // loop over property validations + foreach (ComplexEditorPropertyTypeValidationResult propTypeResult in elementTypeValidationResult + .ValidationResults) + { + // group the results by their type and iterate the groups + foreach (IGrouping result in propTypeResult.ValidationResults.GroupBy(x => + x.GetType())) { - { "$id", elementTypeValidationResult.BlockId }, + // if the group's type isn't ComplexEditorValidationResult then it will in 99% of cases be + // just ValidationResult for whcih we want to create the sub "ModelState" data. If it's not a normal + // ValidationResult it will still just be converted to normal ModelState. - // We don't use this anywhere, though it's nice for debugging - { "$elementTypeAlias", elementTypeValidationResult.ElementTypeAlias } - }; - - var modelState = new ModelStateDictionary(); - - // loop over property validations - foreach (var propTypeResult in elementTypeValidationResult.ValidationResults) - { - // group the results by their type and iterate the groups - foreach (var result in propTypeResult.ValidationResults.GroupBy(x => x.GetType())) + if (result.Key == typeof(ComplexEditorValidationResult)) { - // if the group's type isn't ComplexEditorValidationResult then it will in 99% of cases be - // just ValidationResult for whcih we want to create the sub "ModelState" data. If it's not a normal - // ValidationResult it will still just be converted to normal ModelState. - - if (result.Key == typeof(ComplexEditorValidationResult)) + // if it's ComplexEditorValidationResult then there can only be one which is validated so just get the single + if (result.Any()) { - // if it's ComplexEditorValidationResult then there can only be one which is validated so just get the single - if (result.Any()) - { - var complexResult = result.Single(); - // recurse to get the validation result object - var obj = JToken.FromObject(complexResult, camelCaseSerializer); - joElementType.Add(propTypeResult.PropertyTypeAlias, obj); + ValidationResult complexResult = result.Single(); + // recurse to get the validation result object + var obj = JToken.FromObject(complexResult, camelCaseSerializer); + joElementType.Add(propTypeResult.PropertyTypeAlias, obj); - // For any nested property error we add the model state as empty state for that nested property - // NOTE: Instead of the empty validation message we could put in the translated - // "errors/propertyHasErrors" message, however I think that leaves for less flexibility since it could/should be - // up to the front-end validator to show whatever message it wants (if any) for an error indicating a nested property error. - // Will leave blank. - modelState.AddPropertyValidationError(new ValidationResult(string.Empty), propTypeResult.PropertyTypeAlias, _culture, _segment); - } + // For any nested property error we add the model state as empty state for that nested property + // NOTE: Instead of the empty validation message we could put in the translated + // "errors/propertyHasErrors" message, however I think that leaves for less flexibility since it could/should be + // up to the front-end validator to show whatever message it wants (if any) for an error indicating a nested property error. + // Will leave blank. + modelState.AddPropertyValidationError(new ValidationResult(string.Empty), propTypeResult.PropertyTypeAlias, _culture, _segment); } - else + } + else + { + foreach (ValidationResult v in result) { - foreach (var v in result) - { - modelState.AddPropertyValidationError(v, propTypeResult.PropertyTypeAlias, _culture, _segment); - } + modelState.AddPropertyValidationError(v, propTypeResult.PropertyTypeAlias, _culture, _segment); } } } - - if (modelState.Count > 0) - { - joElementType.Add("ModelState", JObject.FromObject(modelState.ToErrorDictionary())); - } - - joElementType.WriteTo(writer); } - else + + if (modelState.Count > 0) { + joElementType.Add("ModelState", JObject.FromObject(modelState.ToErrorDictionary())); + } - if (validationResult is ContentPropertyValidationResult propertyValidationResult - && propertyValidationResult.ComplexEditorResults?.ValidationResults.Count > 0) - { - // recurse to write out the NestedValidationResults - var obj = JToken.FromObject(propertyValidationResult.ComplexEditorResults, camelCaseSerializer); - obj.WriteTo(writer); - } + joElementType.WriteTo(writer); + } + else + { + if (validationResult is ContentPropertyValidationResult propertyValidationResult + && propertyValidationResult.ComplexEditorResults?.ValidationResults.Count > 0) + { + // recurse to write out the NestedValidationResults + var obj = JToken.FromObject(propertyValidationResult.ComplexEditorResults, camelCaseSerializer); + obj.WriteTo(writer); + } - var jo = new JObject(); - if (!validationResult?.ErrorMessage.IsNullOrWhiteSpace() ?? false) - { - var errObj = JToken.FromObject(validationResult!.ErrorMessage!, camelCaseSerializer); - jo.Add("errorMessage", errObj); - } - if (validationResult?.MemberNames.Any() ?? false) - { - var memberNamesObj = JToken.FromObject(validationResult.MemberNames, camelCaseSerializer); - jo.Add("memberNames", memberNamesObj); - } - if (jo.HasValues) - jo.WriteTo(writer); + var jo = new JObject(); + if (!validationResult?.ErrorMessage.IsNullOrWhiteSpace() ?? false) + { + var errObj = JToken.FromObject(validationResult!.ErrorMessage!, camelCaseSerializer); + jo.Add("errorMessage", errObj); + } + + if (validationResult?.MemberNames.Any() ?? false) + { + var memberNamesObj = JToken.FromObject(validationResult.MemberNames, camelCaseSerializer); + jo.Add("memberNames", memberNamesObj); + } + + if (jo.HasValues) + { + jo.WriteTo(writer); } } } diff --git a/src/Umbraco.Web.BackOffice/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Web.BackOffice/Routing/BackOfficeAreaRoutes.cs index 13d57ccc43..1b95e836dc 100644 --- a/src/Umbraco.Web.BackOffice/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Web.BackOffice/Routing/BackOfficeAreaRoutes.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; @@ -10,108 +9,100 @@ using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.Common.Controllers; using Umbraco.Cms.Web.Common.Routing; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Routing +namespace Umbraco.Cms.Web.BackOffice.Routing; + +/// +/// Creates routes for the back office area +/// +public sealed class BackOfficeAreaRoutes : IAreaRoutes { + private readonly UmbracoApiControllerTypeCollection _apiControllers; + private readonly GlobalSettings _globalSettings; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IRuntimeState _runtimeState; + private readonly string _umbracoPathSegment; + /// - /// Creates routes for the back office area + /// Initializes a new instance of the class. /// - public sealed class BackOfficeAreaRoutes : IAreaRoutes + public BackOfficeAreaRoutes( + IOptions globalSettings, + IHostingEnvironment hostingEnvironment, + IRuntimeState runtimeState, + UmbracoApiControllerTypeCollection apiControllers) { - private readonly GlobalSettings _globalSettings; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly IRuntimeState _runtimeState; - private readonly UmbracoApiControllerTypeCollection _apiControllers; - private readonly string _umbracoPathSegment; + _globalSettings = globalSettings.Value; + _hostingEnvironment = hostingEnvironment; + _runtimeState = runtimeState; + _apiControllers = apiControllers; + _umbracoPathSegment = _globalSettings.GetUmbracoMvcArea(_hostingEnvironment); + } - /// - /// Initializes a new instance of the class. - /// - public BackOfficeAreaRoutes( - IOptions globalSettings, - IHostingEnvironment hostingEnvironment, - IRuntimeState runtimeState, - UmbracoApiControllerTypeCollection apiControllers) + /// + public void CreateRoutes(IEndpointRouteBuilder endpoints) + { + switch (_runtimeState.Level) { - _globalSettings = globalSettings.Value; - _hostingEnvironment = hostingEnvironment; - _runtimeState = runtimeState; - _apiControllers = apiControllers; - _umbracoPathSegment = _globalSettings.GetUmbracoMvcArea(_hostingEnvironment); + case RuntimeLevel.Install: + case RuntimeLevel.Upgrade: + case RuntimeLevel.Run: + + MapMinimalBackOffice(endpoints); + AutoRouteBackOfficeApiControllers(endpoints); + break; + case RuntimeLevel.BootFailed: + case RuntimeLevel.Unknown: + case RuntimeLevel.Boot: + break; } + } - /// - public void CreateRoutes(IEndpointRouteBuilder endpoints) + /// + /// Map the minimal routes required to load the back office login and auth + /// + private void MapMinimalBackOffice(IEndpointRouteBuilder endpoints) + { + endpoints.MapUmbracoRoute( + _umbracoPathSegment, + Constants.Web.Mvc.BackOfficeArea, + string.Empty, + "Default", + false, + // Limit the action/id to only allow characters - this is so this route doesn't hog all other + // routes like: /umbraco/channels/word.aspx, etc... + // (Not that we have to worry about too many of those these days, there still might be a need for these constraints). + new { action = @"[a-zA-Z]*", id = @"[a-zA-Z]*" }); + + endpoints.MapUmbracoApiRoute(_umbracoPathSegment, Constants.Web.Mvc.BackOfficeApiArea, true, string.Empty); + } + + /// + /// Auto-routes all back office api controllers + /// + private void AutoRouteBackOfficeApiControllers(IEndpointRouteBuilder endpoints) + { + // TODO: We could investigate dynamically routing plugin controllers so we don't have to eagerly type scan for them, + // it would probably work well, see https://www.strathweb.com/2019/08/dynamic-controller-routing-in-asp-net-core-3-0/ + // will probably be what we use for front-end routing too. BTW the orig article about migrating from IRouter to endpoint + // routing for things like a CMS is here https://github.com/dotnet/aspnetcore/issues/4221 + + foreach (Type controller in _apiControllers) { - switch (_runtimeState.Level) + PluginControllerMetadata meta = PluginController.GetMetadata(controller); + + // exclude front-end api controllers + if (!meta.IsBackOffice) { - case RuntimeLevel.Install: - case RuntimeLevel.Upgrade: - case RuntimeLevel.Run: - - MapMinimalBackOffice(endpoints); - AutoRouteBackOfficeApiControllers(endpoints); - break; - case RuntimeLevel.BootFailed: - case RuntimeLevel.Unknown: - case RuntimeLevel.Boot: - break; + continue; } - } - /// - /// Map the minimal routes required to load the back office login and auth - /// - private void MapMinimalBackOffice(IEndpointRouteBuilder endpoints) - { - endpoints.MapUmbracoRoute( + endpoints.MapUmbracoApiRoute( + meta.ControllerType, _umbracoPathSegment, - Constants.Web.Mvc.BackOfficeArea, - string.Empty, - "Default", - includeControllerNameInRoute: false, - constraints: - - // Limit the action/id to only allow characters - this is so this route doesn't hog all other - // routes like: /umbraco/channels/word.aspx, etc... - // (Not that we have to worry about too many of those these days, there still might be a need for these constraints). - new - { - action = @"[a-zA-Z]*", - id = @"[a-zA-Z]*" - }); - - endpoints.MapUmbracoApiRoute(_umbracoPathSegment, Constants.Web.Mvc.BackOfficeApiArea, true, defaultAction: string.Empty); - } - - /// - /// Auto-routes all back office api controllers - /// - private void AutoRouteBackOfficeApiControllers(IEndpointRouteBuilder endpoints) - { - // TODO: We could investigate dynamically routing plugin controllers so we don't have to eagerly type scan for them, - // it would probably work well, see https://www.strathweb.com/2019/08/dynamic-controller-routing-in-asp-net-core-3-0/ - // will probably be what we use for front-end routing too. BTW the orig article about migrating from IRouter to endpoint - // routing for things like a CMS is here https://github.com/dotnet/aspnetcore/issues/4221 - - foreach (Type controller in _apiControllers) - { - PluginControllerMetadata meta = PluginController.GetMetadata(controller); - - // exclude front-end api controllers - if (!meta.IsBackOffice) - { - continue; - } - - endpoints.MapUmbracoApiRoute( - meta.ControllerType, - _umbracoPathSegment, - meta.AreaName, - meta.IsBackOffice, - defaultAction: string.Empty); // no default action (this is what we had before) - } + meta.AreaName, + meta.IsBackOffice, + string.Empty); // no default action (this is what we had before) } } } diff --git a/src/Umbraco.Web.BackOffice/Routing/PreviewRoutes.cs b/src/Umbraco.Web.BackOffice/Routing/PreviewRoutes.cs index bda08d0d87..d91a67f9f0 100644 --- a/src/Umbraco.Web.BackOffice/Routing/PreviewRoutes.cs +++ b/src/Umbraco.Web.BackOffice/Routing/PreviewRoutes.cs @@ -9,51 +9,47 @@ using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.BackOffice.SignalR; using Umbraco.Cms.Web.Common.Routing; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Routing +namespace Umbraco.Cms.Web.BackOffice.Routing; + +/// +/// Creates routes for the preview hub +/// +public sealed class PreviewRoutes : IAreaRoutes { - /// - /// Creates routes for the preview hub - /// - public sealed class PreviewRoutes : IAreaRoutes + private readonly IRuntimeState _runtimeState; + private readonly string _umbracoPathSegment; + + public PreviewRoutes( + IOptions globalSettings, + IHostingEnvironment hostingEnvironment, + IRuntimeState runtimeState) { - private readonly IRuntimeState _runtimeState; - private readonly string _umbracoPathSegment; + _runtimeState = runtimeState; + _umbracoPathSegment = globalSettings.Value.GetUmbracoMvcArea(hostingEnvironment); + } - public PreviewRoutes( - IOptions globalSettings, - IHostingEnvironment hostingEnvironment, - IRuntimeState runtimeState) + public void CreateRoutes(IEndpointRouteBuilder endpoints) + { + switch (_runtimeState.Level) { - _runtimeState = runtimeState; - _umbracoPathSegment = globalSettings.Value.GetUmbracoMvcArea(hostingEnvironment); - } - - public void CreateRoutes(IEndpointRouteBuilder endpoints) - { - switch (_runtimeState.Level) - { - case RuntimeLevel.Install: - case RuntimeLevel.Upgrade: - case RuntimeLevel.Run: - endpoints.MapHub(GetPreviewHubRoute()); - endpoints.MapUmbracoRoute(_umbracoPathSegment, Constants.Web.Mvc.BackOfficeArea, null); - break; - case RuntimeLevel.BootFailed: - case RuntimeLevel.Unknown: - case RuntimeLevel.Boot: - break; - } - } - - /// - /// Returns the path to the signalR hub used for preview - /// - /// Path to signalR hub - public string GetPreviewHubRoute() - { - return $"/{_umbracoPathSegment}/{nameof(PreviewHub)}"; + case RuntimeLevel.Install: + case RuntimeLevel.Upgrade: + case RuntimeLevel.Run: + endpoints.MapHub(GetPreviewHubRoute()); + endpoints.MapUmbracoRoute(_umbracoPathSegment, Constants.Web.Mvc.BackOfficeArea, + null); + break; + case RuntimeLevel.BootFailed: + case RuntimeLevel.Unknown: + case RuntimeLevel.Boot: + break; } } + + /// + /// Returns the path to the signalR hub used for preview + /// + /// Path to signalR hub + public string GetPreviewHubRoute() => $"/{_umbracoPathSegment}/{nameof(PreviewHub)}"; } diff --git a/src/Umbraco.Web.BackOffice/Security/AutoLinkSignInResult.cs b/src/Umbraco.Web.BackOffice/Security/AutoLinkSignInResult.cs index 3901e96fbd..86a8a71c76 100644 --- a/src/Umbraco.Web.BackOffice/Security/AutoLinkSignInResult.cs +++ b/src/Umbraco.Web.BackOffice/Security/AutoLinkSignInResult.cs @@ -1,48 +1,30 @@ -using System; -using System.Collections.Generic; using Microsoft.AspNetCore.Identity; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Result returned from signing in when auto-linking takes place +/// +public class AutoLinkSignInResult : SignInResult { - /// - /// Result returned from signing in when auto-linking takes place - /// - public class AutoLinkSignInResult : SignInResult + public AutoLinkSignInResult(IReadOnlyCollection errors) => + Errors = errors ?? throw new ArgumentNullException(nameof(errors)); + + public AutoLinkSignInResult() { - public static AutoLinkSignInResult FailedNotLinked { get; } = new AutoLinkSignInResult() - { - Succeeded = false - }; - - public static AutoLinkSignInResult FailedNoEmail { get; } = new AutoLinkSignInResult() - { - Succeeded = false - }; - - public static AutoLinkSignInResult FailedException(string error) => new AutoLinkSignInResult(new[] { error }) - { - Succeeded = false - }; - - public static AutoLinkSignInResult FailedCreatingUser(IReadOnlyCollection errors) => new AutoLinkSignInResult(errors) - { - Succeeded = false - }; - - public static AutoLinkSignInResult FailedLinkingUser(IReadOnlyCollection errors) => new AutoLinkSignInResult(errors) - { - Succeeded = false - }; - - public AutoLinkSignInResult(IReadOnlyCollection errors) - { - Errors = errors ?? throw new ArgumentNullException(nameof(errors)); - } - - public AutoLinkSignInResult() - { - } - - public IReadOnlyCollection Errors { get; } = Array.Empty(); } + + public static AutoLinkSignInResult FailedNotLinked { get; } = new() { Succeeded = false }; + + public static AutoLinkSignInResult FailedNoEmail { get; } = new() { Succeeded = false }; + + public IReadOnlyCollection Errors { get; } = Array.Empty(); + + public static AutoLinkSignInResult FailedException(string error) => new(new[] { error }) { Succeeded = false }; + + public static AutoLinkSignInResult FailedCreatingUser(IReadOnlyCollection errors) => + new(errors) { Succeeded = false }; + + public static AutoLinkSignInResult FailedLinkingUser(IReadOnlyCollection errors) => + new(errors) { Succeeded = false }; } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeAntiforgery.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeAntiforgery.cs index f70eff32c1..2587ae9a58 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeAntiforgery.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeAntiforgery.cs @@ -1,89 +1,84 @@ -using System; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Microsoft.Net.Http.Headers; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Antiforgery implementation for the Umbraco back office +/// +/// +/// This is a wrapper around the global/default .net service. Because this service is a +/// single/global +/// object and all of it is internal we don't have the flexibility to create our own segregated service so we have to +/// work around +/// that limitation by wrapping the default and doing a few tricks to have this segregated for the Back office only. +/// +public class BackOfficeAntiforgery : IBackOfficeAntiforgery { + private readonly IAntiforgery _internalAntiForgery; + private GlobalSettings _globalSettings; - /// - /// Antiforgery implementation for the Umbraco back office - /// - /// - /// This is a wrapper around the global/default .net service. Because this service is a single/global - /// object and all of it is internal we don't have the flexibility to create our own segregated service so we have to work around - /// that limitation by wrapping the default and doing a few tricks to have this segregated for the Back office only. - /// - public class BackOfficeAntiforgery : IBackOfficeAntiforgery + public BackOfficeAntiforgery(IOptionsMonitor globalSettings) { - private readonly IAntiforgery _internalAntiForgery; - private GlobalSettings _globalSettings; - - public BackOfficeAntiforgery(IOptionsMonitor globalSettings) + // NOTE: This is the only way to create a separate IAntiForgery service :( + // Everything in netcore is internal. I have logged an issue here https://github.com/dotnet/aspnetcore/issues/22217 + // but it will not be handled so we have to revert to this. + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAntiforgery(x => { - // NOTE: This is the only way to create a separate IAntiForgery service :( - // Everything in netcore is internal. I have logged an issue here https://github.com/dotnet/aspnetcore/issues/22217 - // but it will not be handled so we have to revert to this. - var services = new ServiceCollection(); - services.AddLogging(); - services.AddAntiforgery(x => + x.HeaderName = Constants.Web.AngularHeadername; + x.Cookie.Name = Constants.Web.CsrfValidationCookieName; + }); + ServiceProvider container = services.BuildServiceProvider(); + _internalAntiForgery = container.GetRequiredService(); + _globalSettings = globalSettings.CurrentValue; + globalSettings.OnChange(x => + { + _globalSettings = x; + }); + } + + /// + public async Task> ValidateRequestAsync(HttpContext httpContext) + { + try + { + await _internalAntiForgery.ValidateRequestAsync(httpContext); + return Attempt.Succeed(); + } + catch (Exception ex) + { + return Attempt.Fail(ex.Message); + } + } + + /// + public void GetAndStoreTokens(HttpContext httpContext) + { + AntiforgeryTokenSet set = _internalAntiForgery.GetAndStoreTokens(httpContext); + + if (set.RequestToken == null) + { + throw new InvalidOperationException("Could not resolve a request token."); + } + + // We need to set 2 cookies: + // The cookie value that angular will use to set a header value on each request - we need to manually set this here + // The validation cookie value generated by the anti-forgery helper that we validate the header token against - set above in GetAndStoreTokens + httpContext.Response.Cookies.Append( + Constants.Web.AngularCookieName, + set.RequestToken, + new CookieOptions { - x.HeaderName = Constants.Web.AngularHeadername; - x.Cookie.Name = Constants.Web.CsrfValidationCookieName; + Path = "/", + //must be js readable + HttpOnly = false, + Secure = _globalSettings.UseHttps }); - ServiceProvider container = services.BuildServiceProvider(); - _internalAntiForgery = container.GetRequiredService(); - _globalSettings = globalSettings.CurrentValue; - globalSettings.OnChange(x => { - _globalSettings = x; - }); - } - - /// - public async Task> ValidateRequestAsync(HttpContext httpContext) - { - try - { - await _internalAntiForgery.ValidateRequestAsync(httpContext); - return Attempt.Succeed(); - } - catch (Exception ex) - { - return Attempt.Fail(ex.Message); - } - } - - /// - public void GetAndStoreTokens(HttpContext httpContext) - { - AntiforgeryTokenSet set = _internalAntiForgery.GetAndStoreTokens(httpContext); - - if (set.RequestToken == null) - { - throw new InvalidOperationException("Could not resolve a request token."); - } - - // We need to set 2 cookies: - // The cookie value that angular will use to set a header value on each request - we need to manually set this here - // The validation cookie value generated by the anti-forgery helper that we validate the header token against - set above in GetAndStoreTokens - httpContext.Response.Cookies.Append( - Constants.Web.AngularCookieName, - set.RequestToken, - new CookieOptions - { - Path = "/", - //must be js readable - HttpOnly = false, - Secure = _globalSettings.UseHttps - }); - } - } } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs index 123622c9bb..24217d331b 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeAuthenticationBuilder.cs @@ -1,74 +1,74 @@ -using System; -using System.Diagnostics; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Custom used to associate external logins with umbraco external login options +/// +public class BackOfficeAuthenticationBuilder : AuthenticationBuilder { + private readonly Action _loginProviderOptions; + + public BackOfficeAuthenticationBuilder( + IServiceCollection services, + Action? loginProviderOptions = null) + : base(services) + => _loginProviderOptions = loginProviderOptions ?? (x => { }); + + public string? SchemeForBackOffice(string scheme) + => scheme?.EnsureStartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix); + /// - /// Custom used to associate external logins with umbraco external login options + /// Overridden to track the final authenticationScheme being registered for the external login /// - public class BackOfficeAuthenticationBuilder : AuthenticationBuilder + /// + /// + /// + /// + /// + /// + public override AuthenticationBuilder AddRemoteScheme(string authenticationScheme, + string? displayName, Action? configureOptions) { - private readonly Action _loginProviderOptions; - - public BackOfficeAuthenticationBuilder( - IServiceCollection services, - Action? loginProviderOptions = null) - : base(services) - => _loginProviderOptions = loginProviderOptions ?? (x => { }); - - public string? SchemeForBackOffice(string scheme) - => scheme?.EnsureStartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix); - - /// - /// Overridden to track the final authenticationScheme being registered for the external login - /// - /// - /// - /// - /// - /// - /// - public override AuthenticationBuilder AddRemoteScheme(string authenticationScheme, string? displayName, Action? configureOptions) + // Validate that the prefix is set + if (!authenticationScheme.StartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix)) { - // Validate that the prefix is set - if (!authenticationScheme.StartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix)) - { - throw new InvalidOperationException($"The {nameof(authenticationScheme)} is not prefixed with {Constants.Security.BackOfficeExternalAuthenticationTypePrefix}. The scheme must be created with a call to the method {nameof(SchemeForBackOffice)}"); - } - - // add our login provider to the container along with a custom options configuration - Services.Configure(authenticationScheme, _loginProviderOptions); - base.Services.AddSingleton(services => - { - return new BackOfficeExternalLoginProvider( - authenticationScheme, - services.GetRequiredService>()); - }); - Services.TryAddEnumerable(ServiceDescriptor.Singleton, EnsureBackOfficeScheme>()); - - return base.AddRemoteScheme(authenticationScheme, displayName, configureOptions); + throw new InvalidOperationException( + $"The {nameof(authenticationScheme)} is not prefixed with {Constants.Security.BackOfficeExternalAuthenticationTypePrefix}. The scheme must be created with a call to the method {nameof(SchemeForBackOffice)}"); } - // TODO: We could override and throw NotImplementedException for other methods? - - // Ensures that the sign in scheme is always the Umbraco back office external type - internal class EnsureBackOfficeScheme : IPostConfigureOptions where TOptions : RemoteAuthenticationOptions + // add our login provider to the container along with a custom options configuration + Services.Configure(authenticationScheme, _loginProviderOptions); + base.Services.AddSingleton(services => { - public void PostConfigure(string name, TOptions options) + return new BackOfficeExternalLoginProvider( + authenticationScheme, + services.GetRequiredService>()); + }); + Services.TryAddEnumerable(ServiceDescriptor + .Singleton, EnsureBackOfficeScheme>()); + + return base.AddRemoteScheme(authenticationScheme, displayName, configureOptions); + } + + // TODO: We could override and throw NotImplementedException for other methods? + + // Ensures that the sign in scheme is always the Umbraco back office external type + internal class EnsureBackOfficeScheme : IPostConfigureOptions + where TOptions : RemoteAuthenticationOptions + { + public void PostConfigure(string name, TOptions options) + { + // ensure logic only applies to backoffice authentication schemes + if (name.StartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix)) { - // ensure logic only applies to backoffice authentication schemes - if (name.StartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix)) - { - options.SignInScheme = Constants.Security.BackOfficeExternalAuthenticationType; - } + options.SignInScheme = Constants.Security.BackOfficeExternalAuthenticationType; } } } - } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeCookieManager.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeCookieManager.cs index 1479e3b2be..a85cec1bf8 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeCookieManager.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeCookieManager.cs @@ -1,127 +1,123 @@ -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; using Umbraco.Extensions; +using ICookieManager = Microsoft.AspNetCore.Authentication.Cookies.ICookieManager; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// A custom cookie manager that is used to read the cookie from the request. +/// +/// +/// Umbraco's back office cookie needs to be read on two paths: /umbraco and /install, therefore we cannot just set the +/// cookie path to be /umbraco, +/// instead we'll specify our own cookie manager and return null if the request isn't for an acceptable path. +/// +public class BackOfficeCookieManager : ChunkingCookieManager, ICookieManager { + private readonly IBasicAuthService _basicAuthService; + private readonly string[]? _explicitPaths; + private readonly IRuntimeState _runtime; + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly UmbracoRequestPaths _umbracoRequestPaths; + /// - /// A custom cookie manager that is used to read the cookie from the request. + /// Initializes a new instance of the class. /// - /// - /// Umbraco's back office cookie needs to be read on two paths: /umbraco and /install, therefore we cannot just set the cookie path to be /umbraco, - /// instead we'll specify our own cookie manager and return null if the request isn't for an acceptable path. - /// - public class BackOfficeCookieManager : ChunkingCookieManager, Microsoft.AspNetCore.Authentication.Cookies.ICookieManager + public BackOfficeCookieManager( + IUmbracoContextAccessor umbracoContextAccessor, + IRuntimeState runtime, + UmbracoRequestPaths umbracoRequestPaths, + IBasicAuthService basicAuthService) + : this(umbracoContextAccessor, runtime, null, umbracoRequestPaths, basicAuthService) { - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly IRuntimeState _runtime; - private readonly string[]? _explicitPaths; - private readonly UmbracoRequestPaths _umbracoRequestPaths; - private readonly IBasicAuthService _basicAuthService; + } - /// - /// Initializes a new instance of the class. - /// - public BackOfficeCookieManager( - IUmbracoContextAccessor umbracoContextAccessor, - IRuntimeState runtime, - UmbracoRequestPaths umbracoRequestPaths, - IBasicAuthService basicAuthService) - : this(umbracoContextAccessor, runtime, null, umbracoRequestPaths, basicAuthService) + /// + /// Initializes a new instance of the class. + /// + public BackOfficeCookieManager( + IUmbracoContextAccessor umbracoContextAccessor, + IRuntimeState runtime, + IEnumerable? explicitPaths, + UmbracoRequestPaths umbracoRequestPaths, + IBasicAuthService basicAuthService) + { + _umbracoContextAccessor = umbracoContextAccessor; + _runtime = runtime; + _explicitPaths = explicitPaths?.ToArray(); + _umbracoRequestPaths = umbracoRequestPaths; + _basicAuthService = basicAuthService; + } + + /// + /// Explicitly implement this so that we filter the request + /// + /// + string? ICookieManager.GetRequestCookie(HttpContext context, string key) + { + PathString absPath = context.Request.Path; + if (!_umbracoContextAccessor.TryGetUmbracoContext(out _) || _umbracoRequestPaths.IsClientSideRequest(absPath)) { + return null; } - /// - /// Initializes a new instance of the class. - /// - public BackOfficeCookieManager( - IUmbracoContextAccessor umbracoContextAccessor, - IRuntimeState runtime, - IEnumerable? explicitPaths, - UmbracoRequestPaths umbracoRequestPaths, - IBasicAuthService basicAuthService) + return ShouldAuthenticateRequest(absPath) == false + + // Don't auth request, don't return a cookie + ? null + + // Return the default implementation + : GetRequestCookie(context, key); + } + + /// + /// Determines if we should authenticate the request + /// + /// true if the request should be authenticated + /// + /// We auth the request when: + /// * it is a back office request + /// * it is an installer request + /// * it is a preview request + /// + public bool ShouldAuthenticateRequest(string absPath) + { + // Do not authenticate the request if we are not running (don't have a db, are not configured) - since we will never need + // to know a current user in this scenario - we treat it as a new install. Without this we can have some issues + // when people have older invalid cookies on the same domain since our user managers might attempt to lookup a user + // and we don't even have a db. + // was: app.IsConfigured == false (equiv to !Run) && dbContext.IsDbConfigured == false (equiv to Install) + // so, we handle .Install here and NOT .Upgrade + if (_runtime.Level == RuntimeLevel.Install) { - _umbracoContextAccessor = umbracoContextAccessor; - _runtime = runtime; - _explicitPaths = explicitPaths?.ToArray(); - _umbracoRequestPaths = umbracoRequestPaths; - _basicAuthService = basicAuthService; - } - - /// - /// Determines if we should authenticate the request - /// - /// true if the request should be authenticated - /// - /// We auth the request when: - /// * it is a back office request - /// * it is an installer request - /// * it is a preview request - /// - public bool ShouldAuthenticateRequest(string absPath) - { - // Do not authenticate the request if we are not running (don't have a db, are not configured) - since we will never need - // to know a current user in this scenario - we treat it as a new install. Without this we can have some issues - // when people have older invalid cookies on the same domain since our user managers might attempt to lookup a user - // and we don't even have a db. - // was: app.IsConfigured == false (equiv to !Run) && dbContext.IsDbConfigured == false (equiv to Install) - // so, we handle .Install here and NOT .Upgrade - if (_runtime.Level == RuntimeLevel.Install) - { - return false; - } - - // check the explicit paths - if (_explicitPaths != null) - { - return _explicitPaths.Any(x => x.InvariantEquals(absPath)); - } - - if (// check back office - _umbracoRequestPaths.IsBackOfficeRequest(absPath) - - // check installer - || _umbracoRequestPaths.IsInstallerRequest(absPath)) - { - return true; - } - - if (_basicAuthService.IsBasicAuthEnabled()) - { - return true; - } - return false; } - /// - /// Explicitly implement this so that we filter the request - /// - /// - string? Microsoft.AspNetCore.Authentication.Cookies.ICookieManager.GetRequestCookie(HttpContext context, string key) + // check the explicit paths + if (_explicitPaths != null) { - var absPath = context.Request.Path; - if (!_umbracoContextAccessor.TryGetUmbracoContext(out _) || _umbracoRequestPaths.IsClientSideRequest(absPath)) - { - return null; - } - - return ShouldAuthenticateRequest(absPath) == false - - // Don't auth request, don't return a cookie - ? null - - // Return the default implementation - : GetRequestCookie(context, key); + return _explicitPaths.Any(x => x.InvariantEquals(absPath)); } + if ( // check back office + _umbracoRequestPaths.IsBackOfficeRequest(absPath) + + // check installer + || _umbracoRequestPaths.IsInstallerRequest(absPath)) + { + return true; + } + + if (_basicAuthService.IsBasicAuthEnabled()) + { + return true; + } + + return false; } } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternaLoginProviderScheme.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternaLoginProviderScheme.cs index 86dcac19ed..322d81c550 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternaLoginProviderScheme.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternaLoginProviderScheme.cs @@ -1,20 +1,17 @@ -using System; using Microsoft.AspNetCore.Authentication; -namespace Umbraco.Cms.Web.BackOffice.Security -{ - public class BackOfficeExternaLoginProviderScheme - { - public BackOfficeExternaLoginProviderScheme( - BackOfficeExternalLoginProvider externalLoginProvider, - AuthenticationScheme? authenticationScheme) - { - ExternalLoginProvider = externalLoginProvider ?? throw new ArgumentNullException(nameof(externalLoginProvider)); - AuthenticationScheme = authenticationScheme ?? throw new ArgumentNullException(nameof(authenticationScheme)); - } +namespace Umbraco.Cms.Web.BackOffice.Security; - public BackOfficeExternalLoginProvider ExternalLoginProvider { get; } - public AuthenticationScheme AuthenticationScheme { get; } +public class BackOfficeExternaLoginProviderScheme +{ + public BackOfficeExternaLoginProviderScheme( + BackOfficeExternalLoginProvider externalLoginProvider, + AuthenticationScheme? authenticationScheme) + { + ExternalLoginProvider = externalLoginProvider ?? throw new ArgumentNullException(nameof(externalLoginProvider)); + AuthenticationScheme = authenticationScheme ?? throw new ArgumentNullException(nameof(authenticationScheme)); } + public BackOfficeExternalLoginProvider ExternalLoginProvider { get; } + public AuthenticationScheme AuthenticationScheme { get; } } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProvider.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProvider.cs index e8223bd2b2..eeb68fd19a 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProvider.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProvider.cs @@ -1,36 +1,35 @@ -using System; using Microsoft.Extensions.Options; -namespace Umbraco.Cms.Web.BackOffice.Security -{ - /// - /// An external login (OAuth) provider for the back office - /// - public class BackOfficeExternalLoginProvider : IEquatable - { - public BackOfficeExternalLoginProvider( - string authenticationType, - IOptionsMonitor properties) - { - if (properties is null) - { - throw new ArgumentNullException(nameof(properties)); - } +namespace Umbraco.Cms.Web.BackOffice.Security; - AuthenticationType = authenticationType ?? throw new ArgumentNullException(nameof(authenticationType)); - Options = properties.Get(authenticationType); +/// +/// An external login (OAuth) provider for the back office +/// +public class BackOfficeExternalLoginProvider : IEquatable +{ + public BackOfficeExternalLoginProvider( + string authenticationType, + IOptionsMonitor properties) + { + if (properties is null) + { + throw new ArgumentNullException(nameof(properties)); } - /// - /// The authentication "Scheme" - /// - public string AuthenticationType { get; } - - public BackOfficeExternalLoginProviderOptions Options { get; } - - public override bool Equals(object? obj) => Equals(obj as BackOfficeExternalLoginProvider); - public bool Equals(BackOfficeExternalLoginProvider? other) => other != null && AuthenticationType == other.AuthenticationType; - public override int GetHashCode() => HashCode.Combine(AuthenticationType); + AuthenticationType = authenticationType ?? throw new ArgumentNullException(nameof(authenticationType)); + Options = properties.Get(authenticationType); } + /// + /// The authentication "Scheme" + /// + public string AuthenticationType { get; } + + public BackOfficeExternalLoginProviderOptions Options { get; } + + public bool Equals(BackOfficeExternalLoginProvider? other) => + other != null && AuthenticationType == other.AuthenticationType; + + public override bool Equals(object? obj) => Equals(obj as BackOfficeExternalLoginProvider); + public override int GetHashCode() => HashCode.Combine(AuthenticationType); } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviderOptions.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviderOptions.cs index 3d426c1aae..6fd58e7f95 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviderOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviderOptions.cs @@ -1,60 +1,63 @@ -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Options used to configure back office external login providers +/// +public class BackOfficeExternalLoginProviderOptions { - /// - /// Options used to configure back office external login providers - /// - public class BackOfficeExternalLoginProviderOptions + public BackOfficeExternalLoginProviderOptions( + string buttonStyle, + string icon, + ExternalSignInAutoLinkOptions? autoLinkOptions = null, + bool denyLocalLogin = false, + bool autoRedirectLoginToExternalProvider = false, + string? customBackOfficeView = null) { - public BackOfficeExternalLoginProviderOptions( - string buttonStyle, - string icon, - ExternalSignInAutoLinkOptions? autoLinkOptions = null, - bool denyLocalLogin = false, - bool autoRedirectLoginToExternalProvider = false, - string? customBackOfficeView = null) - { - ButtonStyle = buttonStyle; - Icon = icon; - AutoLinkOptions = autoLinkOptions ?? new ExternalSignInAutoLinkOptions(); - DenyLocalLogin = denyLocalLogin; - AutoRedirectLoginToExternalProvider = autoRedirectLoginToExternalProvider; - CustomBackOfficeView = customBackOfficeView; - } - - public BackOfficeExternalLoginProviderOptions() - { - } - - public string ButtonStyle { get; set; } = "btn-openid"; - - public string Icon { get; set; } = "fa fa-user"; - - /// - /// Options used to control how users can be auto-linked/created/updated based on the external login provider - /// - public ExternalSignInAutoLinkOptions AutoLinkOptions { get; set; } = new ExternalSignInAutoLinkOptions(); - - /// - /// When set to true will disable all local user login functionality - /// - public bool DenyLocalLogin { get; set; } - - /// - /// When specified this will automatically redirect to the OAuth login provider instead of prompting the user to click on the OAuth button first. - /// - /// - /// This is generally used in conjunction with . If more than one OAuth provider specifies this, the last registered - /// provider's redirect settings will win. - /// - public bool AutoRedirectLoginToExternalProvider { get; set; } - - /// - /// A virtual path to a custom angular view that is used to replace the entire UI that renders the external login button that the user interacts with - /// - /// - /// If this view is specified it is 100% up to the user to render the html responsible for rendering the link/un-link buttons along with showing any errors - /// that occur. This overrides what Umbraco normally does by default. - /// - public string? CustomBackOfficeView { get; set; } + ButtonStyle = buttonStyle; + Icon = icon; + AutoLinkOptions = autoLinkOptions ?? new ExternalSignInAutoLinkOptions(); + DenyLocalLogin = denyLocalLogin; + AutoRedirectLoginToExternalProvider = autoRedirectLoginToExternalProvider; + CustomBackOfficeView = customBackOfficeView; } + + public BackOfficeExternalLoginProviderOptions() + { + } + + public string ButtonStyle { get; set; } = "btn-openid"; + + public string Icon { get; set; } = "fa fa-user"; + + /// + /// Options used to control how users can be auto-linked/created/updated based on the external login provider + /// + public ExternalSignInAutoLinkOptions AutoLinkOptions { get; set; } = new(); + + /// + /// When set to true will disable all local user login functionality + /// + public bool DenyLocalLogin { get; set; } + + /// + /// When specified this will automatically redirect to the OAuth login provider instead of prompting the user to click + /// on the OAuth button first. + /// + /// + /// This is generally used in conjunction with . If more than one OAuth provider specifies + /// this, the last registered + /// provider's redirect settings will win. + /// + public bool AutoRedirectLoginToExternalProvider { get; set; } + + /// + /// A virtual path to a custom angular view that is used to replace the entire UI that renders the external login + /// button that the user interacts with + /// + /// + /// If this view is specified it is 100% up to the user to render the html responsible for rendering the link/un-link + /// buttons along with showing any errors + /// that occur. This overrides what Umbraco normally does by default. + /// + public string? CustomBackOfficeView { get; set; } } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs index 3058a10ea8..277fd06c6b 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs @@ -1,73 +1,69 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProviders { + private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; + private readonly Dictionary _externalLogins; - /// - public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProviders + public BackOfficeExternalLoginProviders( + IEnumerable externalLogins, + IAuthenticationSchemeProvider authenticationSchemeProvider) { - private readonly Dictionary _externalLogins; - private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; - - public BackOfficeExternalLoginProviders( - IEnumerable externalLogins, - IAuthenticationSchemeProvider authenticationSchemeProvider) - { - _externalLogins = externalLogins.ToDictionary(x => x.AuthenticationType); - _authenticationSchemeProvider = authenticationSchemeProvider; - } - - /// - public async Task GetAsync(string authenticationType) - { - if (!_externalLogins.TryGetValue(authenticationType, out BackOfficeExternalLoginProvider? provider)) - { - return null; - } - - // get the associated scheme - AuthenticationScheme? associatedScheme = await _authenticationSchemeProvider.GetSchemeAsync(provider.AuthenticationType); - - if (associatedScheme == null) - { - throw new InvalidOperationException("No authentication scheme registered for " + provider.AuthenticationType); - } - - return new BackOfficeExternaLoginProviderScheme(provider, associatedScheme); - } - - /// - public string? GetAutoLoginProvider() - { - var found = _externalLogins.Values.Where(x => x.Options.AutoRedirectLoginToExternalProvider).ToList(); - return found.Count > 0 ? found[0].AuthenticationType : null; - } - - /// - public async Task> GetBackOfficeProvidersAsync() - { - var providersWithSchemes = new List(); - foreach (BackOfficeExternalLoginProvider login in _externalLogins.Values) - { - // get the associated scheme - AuthenticationScheme? associatedScheme = await _authenticationSchemeProvider.GetSchemeAsync(login.AuthenticationType); - - providersWithSchemes.Add(new BackOfficeExternaLoginProviderScheme(login, associatedScheme)); - } - - return providersWithSchemes; - } - - /// - public bool HasDenyLocalLogin() - { - var found = _externalLogins.Values.Where(x => x.Options.DenyLocalLogin).ToList(); - return found.Count > 0; - } + _externalLogins = externalLogins.ToDictionary(x => x.AuthenticationType); + _authenticationSchemeProvider = authenticationSchemeProvider; } + /// + public async Task GetAsync(string authenticationType) + { + if (!_externalLogins.TryGetValue(authenticationType, out BackOfficeExternalLoginProvider? provider)) + { + return null; + } + + // get the associated scheme + AuthenticationScheme? associatedScheme = + await _authenticationSchemeProvider.GetSchemeAsync(provider.AuthenticationType); + + if (associatedScheme == null) + { + throw new InvalidOperationException( + "No authentication scheme registered for " + provider.AuthenticationType); + } + + return new BackOfficeExternaLoginProviderScheme(provider, associatedScheme); + } + + /// + public string? GetAutoLoginProvider() + { + var found = _externalLogins.Values.Where(x => x.Options.AutoRedirectLoginToExternalProvider).ToList(); + return found.Count > 0 ? found[0].AuthenticationType : null; + } + + /// + public async Task> GetBackOfficeProvidersAsync() + { + var providersWithSchemes = new List(); + foreach (BackOfficeExternalLoginProvider login in _externalLogins.Values) + { + // get the associated scheme + AuthenticationScheme? associatedScheme = + await _authenticationSchemeProvider.GetSchemeAsync(login.AuthenticationType); + + providersWithSchemes.Add(new BackOfficeExternaLoginProviderScheme(login, associatedScheme)); + } + + return providersWithSchemes; + } + + /// + public bool HasDenyLocalLogin() + { + var found = _externalLogins.Values.Where(x => x.Options.DenyLocalLogin).ToList(); + return found.Count > 0; + } } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginsBuilder.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginsBuilder.cs index 6d8a4c59a7..4a15be1d54 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginsBuilder.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginsBuilder.cs @@ -1,33 +1,27 @@ -using System; using Microsoft.Extensions.DependencyInjection; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Used to add back office login providers +/// +public class BackOfficeExternalLoginsBuilder { + private readonly IServiceCollection _services; + + public BackOfficeExternalLoginsBuilder(IServiceCollection services) => _services = services; + /// - /// Used to add back office login providers + /// Add a back office login provider with options /// - public class BackOfficeExternalLoginsBuilder + /// + /// + /// + public BackOfficeExternalLoginsBuilder AddBackOfficeLogin( + Action build, + Action? loginProviderOptions = null) { - public BackOfficeExternalLoginsBuilder(IServiceCollection services) - { - _services = services; - } - - private readonly IServiceCollection _services; - - /// - /// Add a back office login provider with options - /// - /// - /// - /// - public BackOfficeExternalLoginsBuilder AddBackOfficeLogin( - Action build, - Action? loginProviderOptions = null) - { - build(new BackOfficeAuthenticationBuilder(_services, loginProviderOptions)); - return this; - } + build(new BackOfficeAuthenticationBuilder(_services, loginProviderOptions)); + return this; } - } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeSecureDataFormat.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeSecureDataFormat.cs index 6e5155d5d3..a30a13722c 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeSecureDataFormat.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeSecureDataFormat.cs @@ -1,74 +1,77 @@ -using System; using System.Security.Claims; -using Umbraco.Extensions; using Microsoft.AspNetCore.Authentication; +using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Custom secure format that ensures the Identity in the ticket is verified +/// +internal class BackOfficeSecureDataFormat : ISecureDataFormat { + private readonly TimeSpan _loginTimeout; + private readonly ISecureDataFormat _ticketDataFormat; + + public BackOfficeSecureDataFormat(TimeSpan loginTimeout, ISecureDataFormat ticketDataFormat) + { + _loginTimeout = loginTimeout; + _ticketDataFormat = ticketDataFormat ?? throw new ArgumentNullException(nameof(ticketDataFormat)); + } + + public string Protect(AuthenticationTicket data, string? purpose) + { + // create a new ticket based on the passed in tickets details, however, we'll adjust the expires utc based on the specified timeout mins + var ticket = new AuthenticationTicket( + data.Principal, + new AuthenticationProperties(data.Properties.Items) + { + IssuedUtc = data.Properties.IssuedUtc, + ExpiresUtc = data.Properties.ExpiresUtc ?? DateTimeOffset.UtcNow.Add(_loginTimeout), + AllowRefresh = data.Properties.AllowRefresh, + IsPersistent = data.Properties.IsPersistent, + RedirectUri = data.Properties.RedirectUri + }, + data.AuthenticationScheme); + + return _ticketDataFormat.Protect(ticket); + } + + public string Protect(AuthenticationTicket data) => Protect(data, string.Empty); + + + public AuthenticationTicket? Unprotect(string? protectedText) => Unprotect(protectedText, string.Empty); /// - /// Custom secure format that ensures the Identity in the ticket is verified + /// Un-protects the cookie /// - internal class BackOfficeSecureDataFormat : ISecureDataFormat + /// + /// + /// + public AuthenticationTicket? Unprotect(string? protectedText, string? purpose) { - private readonly TimeSpan _loginTimeout; - private readonly ISecureDataFormat _ticketDataFormat; - - public BackOfficeSecureDataFormat(TimeSpan loginTimeout, ISecureDataFormat ticketDataFormat) + AuthenticationTicket? decrypt; + try { - _loginTimeout = loginTimeout; - _ticketDataFormat = ticketDataFormat ?? throw new ArgumentNullException(nameof(ticketDataFormat)); - } - - public string Protect(AuthenticationTicket data, string? purpose) - { - // create a new ticket based on the passed in tickets details, however, we'll adjust the expires utc based on the specified timeout mins - var ticket = new AuthenticationTicket(data.Principal, - new AuthenticationProperties(data.Properties.Items) - { - IssuedUtc = data.Properties.IssuedUtc, - ExpiresUtc = data.Properties.ExpiresUtc ?? DateTimeOffset.UtcNow.Add(_loginTimeout), - AllowRefresh = data.Properties.AllowRefresh, - IsPersistent = data.Properties.IsPersistent, - RedirectUri = data.Properties.RedirectUri - }, data.AuthenticationScheme); - - return _ticketDataFormat.Protect(ticket); - } - - public string Protect(AuthenticationTicket data) => Protect(data, string.Empty); - - - public AuthenticationTicket? Unprotect(string? protectedText) => Unprotect(protectedText, string.Empty); - - /// - /// Un-protects the cookie - /// - /// - /// - public AuthenticationTicket? Unprotect(string? protectedText, string? purpose) - { - AuthenticationTicket? decrypt; - try - { - decrypt = _ticketDataFormat.Unprotect(protectedText); - if (decrypt == null) return null; - } - catch (Exception) + decrypt = _ticketDataFormat.Unprotect(protectedText); + if (decrypt == null) { return null; } - - var identity = (ClaimsIdentity?)decrypt.Principal.Identity; - if (identity is null || !identity.VerifyBackOfficeIdentity(out ClaimsIdentity? verifiedIdentity)) - { - return null; - } - - //return the ticket with a UmbracoBackOfficeIdentity - var ticket = new AuthenticationTicket(new ClaimsPrincipal(verifiedIdentity), decrypt.Properties, decrypt.AuthenticationScheme); - - return ticket; } + catch (Exception) + { + return null; + } + + var identity = (ClaimsIdentity?)decrypt.Principal.Identity; + if (identity is null || !identity.VerifyBackOfficeIdentity(out ClaimsIdentity? verifiedIdentity)) + { + return null; + } + + //return the ticket with a UmbracoBackOfficeIdentity + var ticket = new AuthenticationTicket(new ClaimsPrincipal(verifiedIdentity), decrypt.Properties, decrypt.AuthenticationScheme); + + return ticket; } } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeSecurityStampValidator.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeSecurityStampValidator.cs index e8eaba75a9..577dc8ce08 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeSecurityStampValidator.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeSecurityStampValidator.cs @@ -4,19 +4,17 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Security; -namespace Umbraco.Cms.Web.BackOffice.Security -{ - /// - /// A security stamp validator for the back office - /// - public class BackOfficeSecurityStampValidator : SecurityStampValidator - { - public BackOfficeSecurityStampValidator( - IOptions options, - BackOfficeSignInManager signInManager, ISystemClock clock, ILoggerFactory logger) - : base(options, signInManager, clock, logger) - { - } +namespace Umbraco.Cms.Web.BackOffice.Security; +/// +/// A security stamp validator for the back office +/// +public class BackOfficeSecurityStampValidator : SecurityStampValidator +{ + public BackOfficeSecurityStampValidator( + IOptions options, + BackOfficeSignInManager signInManager, ISystemClock clock, ILoggerFactory logger) + : base(options, signInManager, clock, logger) + { } } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeSecurityStampValidatorOptions.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeSecurityStampValidatorOptions.cs index bf9a17b71b..3dd10c08ee 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeSecurityStampValidatorOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeSecurityStampValidatorOptions.cs @@ -1,13 +1,10 @@ -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Custom for the back office +/// +public class BackOfficeSecurityStampValidatorOptions : SecurityStampValidatorOptions { - /// - /// Custom for the back office - /// - public class BackOfficeSecurityStampValidatorOptions : SecurityStampValidatorOptions - { - } - - } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeSessionIdValidator.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeSessionIdValidator.cs index 1964b7274c..15413db2a7 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeSessionIdValidator.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeSessionIdValidator.cs @@ -1,145 +1,155 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; using System.Security.Claims; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Security; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Used to validate a cookie against a user's session id +/// +/// +/// +/// This uses another cookie to track the last checked time which is done for a few reasons: +/// * We can't use the user's auth ticket to do this because we'd be re-issuing the auth ticket all of the time and +/// it would never expire +/// plus the auth ticket size is much larger than this small value +/// * This will execute quite often (every minute per user) and in some cases there might be several requests that +/// end up re-issuing the cookie so the cookie value should be small +/// * We want to avoid the user lookup if it's not required so that will only happen when the time diff is great +/// enough in the cookie +/// +/// +/// This is a scoped/request based object. +/// +/// +public class BackOfficeSessionIdValidator { + public const string CookieName = "UMB_UCONTEXT_C"; + private readonly GlobalSettings _globalSettings; + private readonly ISystemClock _systemClock; + private readonly IBackOfficeUserManager _userManager; + /// - /// Used to validate a cookie against a user's session id + /// Initializes a new instance of the class. /// - /// - /// - /// This uses another cookie to track the last checked time which is done for a few reasons: - /// * We can't use the user's auth ticket to do this because we'd be re-issuing the auth ticket all of the time and it would never expire - /// plus the auth ticket size is much larger than this small value - /// * This will execute quite often (every minute per user) and in some cases there might be several requests that end up re-issuing the cookie so the cookie value should be small - /// * We want to avoid the user lookup if it's not required so that will only happen when the time diff is great enough in the cookie - /// - /// - /// This is a scoped/request based object. - /// - /// - public class BackOfficeSessionIdValidator + public BackOfficeSessionIdValidator(ISystemClock systemClock, IOptionsSnapshot globalSettings, IBackOfficeUserManager userManager) { - public const string CookieName = "UMB_UCONTEXT_C"; - private readonly ISystemClock _systemClock; - private readonly GlobalSettings _globalSettings; - private readonly IBackOfficeUserManager _userManager; + _systemClock = systemClock; + _globalSettings = globalSettings.Value; + _userManager = userManager; + } - /// - /// Initializes a new instance of the class. - /// - public BackOfficeSessionIdValidator(ISystemClock systemClock, IOptionsSnapshot globalSettings, IBackOfficeUserManager userManager) + public async Task ValidateSessionAsync(TimeSpan validateInterval, CookieValidatePrincipalContext context) + { + if (!context.Request.IsBackOfficeRequest()) { - _systemClock = systemClock; - _globalSettings = globalSettings.Value; - _userManager = userManager; + return; } - public async Task ValidateSessionAsync(TimeSpan validateInterval, CookieValidatePrincipalContext context) + var valid = await ValidateSessionAsync(validateInterval, context.HttpContext, context.Options.CookieManager, _systemClock, context.Properties.IssuedUtc, context.Principal?.Identity as ClaimsIdentity); + + if (valid == false) { - if (!context.Request.IsBackOfficeRequest()) - { - return; - } + context.RejectPrincipal(); + await context.HttpContext.SignOutAsync(Constants.Security.BackOfficeAuthenticationType); + } + } - var valid = await ValidateSessionAsync(validateInterval, context.HttpContext, context.Options.CookieManager, _systemClock, context.Properties.IssuedUtc, context.Principal?.Identity as ClaimsIdentity); + private async Task ValidateSessionAsync( + TimeSpan validateInterval, + HttpContext httpContext, + ICookieManager cookieManager, + ISystemClock systemClock, + DateTimeOffset? authTicketIssueDate, + ClaimsIdentity? currentIdentity) + { + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } - if (valid == false) + if (cookieManager == null) + { + throw new ArgumentNullException(nameof(cookieManager)); + } + + if (systemClock == null) + { + throw new ArgumentNullException(nameof(systemClock)); + } + + if (currentIdentity == null) + { + return false; + } + + DateTimeOffset? issuedUtc = null; + DateTimeOffset currentUtc = systemClock.UtcNow; + + // read the last checked time from a custom cookie + var lastCheckedCookie = cookieManager.GetRequestCookie(httpContext, CookieName); + + if (lastCheckedCookie.IsNullOrWhiteSpace() == false) + { + if (DateTimeOffset.TryParse(lastCheckedCookie, out DateTimeOffset parsed)) { - context.RejectPrincipal(); - await context.HttpContext.SignOutAsync(Constants.Security.BackOfficeAuthenticationType); + issuedUtc = parsed; } } - private async Task ValidateSessionAsync( - TimeSpan validateInterval, - HttpContext httpContext, - Microsoft.AspNetCore.Authentication.Cookies.ICookieManager cookieManager, - ISystemClock systemClock, - DateTimeOffset? authTicketIssueDate, - ClaimsIdentity? currentIdentity) + // no cookie, use the issue time of the auth ticket + if (issuedUtc.HasValue == false) { - if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); - if (cookieManager == null) throw new ArgumentNullException(nameof(cookieManager)); - if (systemClock == null) throw new ArgumentNullException(nameof(systemClock)); + issuedUtc = authTicketIssueDate; + } - if (currentIdentity == null) - { - return false; - } - - DateTimeOffset? issuedUtc = null; - var currentUtc = systemClock.UtcNow; - - // read the last checked time from a custom cookie - var lastCheckedCookie = cookieManager.GetRequestCookie(httpContext, CookieName); - - if (lastCheckedCookie.IsNullOrWhiteSpace() == false) - { - if (DateTimeOffset.TryParse(lastCheckedCookie, out var parsed)) - { - issuedUtc = parsed; - } - } - - // no cookie, use the issue time of the auth ticket - if (issuedUtc.HasValue == false) - { - issuedUtc = authTicketIssueDate; - } - - // Only validate if enough time has elapsed - var validate = issuedUtc.HasValue == false; - if (issuedUtc.HasValue) - { - var timeElapsed = currentUtc.Subtract(issuedUtc.Value); - validate = timeElapsed > validateInterval; - } - - if (validate == false) - { - return true; - } - - var userId = currentIdentity.GetUserId(); - var user = await _userManager.FindByIdAsync(userId); - if (user == null) - { - return false; - } - - var sessionId = currentIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); - if (await _userManager.ValidateSessionIdAsync(userId, sessionId) == false) - { - return false; - } - - // we will re-issue the cookie last checked cookie - cookieManager.AppendResponseCookie( - httpContext, - CookieName, - DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"), - new CookieOptions - { - HttpOnly = true, - Secure = _globalSettings.UseHttps || httpContext.Request.IsHttps, - Path = "/" - }); + // Only validate if enough time has elapsed + var validate = issuedUtc.HasValue == false; + if (issuedUtc.HasValue) + { + TimeSpan timeElapsed = currentUtc.Subtract(issuedUtc.Value); + validate = timeElapsed > validateInterval; + } + if (validate == false) + { return true; } + var userId = currentIdentity.GetUserId(); + BackOfficeIdentityUser? user = await _userManager.FindByIdAsync(userId); + if (user == null) + { + return false; + } + + var sessionId = currentIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + if (await _userManager.ValidateSessionIdAsync(userId, sessionId) == false) + { + return false; + } + + // we will re-issue the cookie last checked cookie + cookieManager.AppendResponseCookie( + httpContext, + CookieName, + DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"), + new CookieOptions + { + HttpOnly = true, + Secure = _globalSettings.UseHttps || httpContext.Request.IsHttps, + Path = "/" + }); + + return true; } } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs index ca22375cfd..74f5fb5eb8 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs @@ -1,14 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Security.Claims; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Notifications; @@ -17,322 +14,319 @@ using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// The sign in manager for back office users +/// +public class BackOfficeSignInManager : UmbracoSignInManager, IBackOfficeSignInManager { - using Constants = Core.Constants; + private readonly IEventAggregator _eventAggregator; + private readonly IBackOfficeExternalLoginProviders _externalLogins; + private readonly GlobalSettings _globalSettings; + private readonly BackOfficeUserManager _userManager; + + public BackOfficeSignInManager( + BackOfficeUserManager userManager, + IHttpContextAccessor contextAccessor, + IBackOfficeExternalLoginProviders externalLogins, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + IOptions globalSettings, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IEventAggregator eventAggregator) + : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + { + _userManager = userManager; + _externalLogins = externalLogins; + _eventAggregator = eventAggregator; + _globalSettings = globalSettings.Value; + } + + [Obsolete("Use ctor with all params")] + public BackOfficeSignInManager( + BackOfficeUserManager userManager, + IHttpContextAccessor contextAccessor, + IBackOfficeExternalLoginProviders externalLogins, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + IOptions globalSettings, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation) + : this(userManager, contextAccessor, externalLogins, claimsFactory, optionsAccessor, globalSettings, logger, schemes, confirmation, StaticServiceProvider.Instance.GetRequiredService()) + { + } + + protected override string AuthenticationType => Constants.Security.BackOfficeAuthenticationType; + + protected override string ExternalAuthenticationType => Constants.Security.BackOfficeExternalAuthenticationType; + + protected override string TwoFactorAuthenticationType => Constants.Security.BackOfficeTwoFactorAuthenticationType; + + protected override string TwoFactorRememberMeAuthenticationType => + Constants.Security.BackOfficeTwoFactorRememberMeAuthenticationType; /// - /// The sign in manager for back office users + /// Custom ExternalLoginSignInAsync overload for handling external sign in with auto-linking /// - public class BackOfficeSignInManager : UmbracoSignInManager, IBackOfficeSignInManager + /// + /// + /// + /// + public async Task ExternalLoginSignInAsync(ExternalLoginInfo loginInfo, bool isPersistent, bool bypassTwoFactor = false) { - private readonly BackOfficeUserManager _userManager; - private readonly IBackOfficeExternalLoginProviders _externalLogins; - private readonly IEventAggregator _eventAggregator; - private readonly GlobalSettings _globalSettings; + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // to be able to deal with auto-linking and reduce duplicate lookups - protected override string AuthenticationType => Constants.Security.BackOfficeAuthenticationType; - - protected override string ExternalAuthenticationType => Constants.Security.BackOfficeExternalAuthenticationType; - - protected override string TwoFactorAuthenticationType => Constants.Security.BackOfficeTwoFactorAuthenticationType; - - protected override string TwoFactorRememberMeAuthenticationType => Constants.Security.BackOfficeTwoFactorRememberMeAuthenticationType; - - public BackOfficeSignInManager( - BackOfficeUserManager userManager, - IHttpContextAccessor contextAccessor, - IBackOfficeExternalLoginProviders externalLogins, - IUserClaimsPrincipalFactory claimsFactory, - IOptions optionsAccessor, - IOptions globalSettings, - ILogger> logger, - IAuthenticationSchemeProvider schemes, - IUserConfirmation confirmation, - IEventAggregator eventAggregator) - : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + ExternalSignInAutoLinkOptions? autoLinkOptions = (await _externalLogins.GetAsync(loginInfo.LoginProvider)) + ?.ExternalLoginProvider?.Options?.AutoLinkOptions; + BackOfficeIdentityUser? user = + await UserManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); + if (user == null) { - _userManager = userManager; - _externalLogins = externalLogins; - _eventAggregator = eventAggregator; - _globalSettings = globalSettings.Value; + // user doesn't exist so see if we can auto link + return await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions); } - [Obsolete("Use ctor with all params")] - public BackOfficeSignInManager( - BackOfficeUserManager userManager, - IHttpContextAccessor contextAccessor, - IBackOfficeExternalLoginProviders externalLogins, - IUserClaimsPrincipalFactory claimsFactory, - IOptions optionsAccessor, - IOptions globalSettings, - ILogger> logger, - IAuthenticationSchemeProvider schemes, - IUserConfirmation confirmation) - : this(userManager, contextAccessor, externalLogins, claimsFactory, optionsAccessor, globalSettings, logger, schemes, confirmation, StaticServiceProvider.Instance.GetRequiredService()) + if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null) { - - } - - /// - /// Custom ExternalLoginSignInAsync overload for handling external sign in with auto-linking - /// - /// - /// - /// - /// - /// - public async Task ExternalLoginSignInAsync(ExternalLoginInfo loginInfo, bool isPersistent, bool bypassTwoFactor = false) - { - // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs - // to be able to deal with auto-linking and reduce duplicate lookups - - var autoLinkOptions = (await _externalLogins.GetAsync(loginInfo.LoginProvider))?.ExternalLoginProvider?.Options?.AutoLinkOptions; - var user = await UserManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); - if (user == null) + var shouldSignIn = autoLinkOptions.OnExternalLogin(user, loginInfo); + if (shouldSignIn == false) { - // user doesn't exist so see if we can auto link - return await AutoLinkAndSignInExternalAccount(loginInfo, autoLinkOptions); - } - - if (autoLinkOptions != null && autoLinkOptions.OnExternalLogin != null) - { - var shouldSignIn = autoLinkOptions.OnExternalLogin(user, loginInfo); - if (shouldSignIn == false) - { - LogFailedExternalLogin(loginInfo, user); - return ExternalLoginSignInResult.NotAllowed; - } - } - - var error = await PreSignInCheck(user); - if (error != null) - { - return error; - } - return await SignInOrTwoFactorAsync(user, isPersistent, loginInfo.LoginProvider, bypassTwoFactor); - } - - /// - /// Configures the redirect URL and user identifier for the specified external login . - /// - /// The provider to configure. - /// The external login URL users should be redirected to during the login flow. - /// The current user's identifier, which will be used to provide CSRF protection. - /// A configured . - public override AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string? redirectUrl, string? userId = null) - { - // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs - // to be able to use our own XsrfKey/LoginProviderKey because the default is private :/ - - var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; - properties.Items[UmbracoSignInMgrLoginProviderKey] = provider; - if (userId != null) - { - properties.Items[UmbracoSignInMgrXsrfKey] = userId; - } - return properties; - } - - public override Task> GetExternalAuthenticationSchemesAsync() - { - // TODO: We can filter these so that they only include the back office ones. - // That can be done by either checking the scheme (maybe) or comparing it to what we have registered in the collection of BackOfficeExternalLoginProvider - return base.GetExternalAuthenticationSchemesAsync(); - } - - /// - /// Overridden to deal with events/notificiations - /// - /// - /// - /// - /// - protected override async Task HandleSignIn(BackOfficeIdentityUser? user, string? username, SignInResult result) - { - result = await base.HandleSignIn(user, username, result); - - if (result.Succeeded) - { - if (user != null) - { - _userManager.NotifyLoginSuccess(Context.User, user.Id); - } - } - else if (result.IsLockedOut) - { - _userManager.NotifyAccountLocked(Context.User, user?.Id); - } - else if (result.RequiresTwoFactor) - { - _userManager.NotifyLoginRequiresVerification(Context.User, user?.Id); - } - else if (!result.Succeeded || result.IsNotAllowed) - { - } - else - { - throw new ArgumentOutOfRangeException(); - } - - return result; - } - - /// - /// Used for auto linking/creating user accounts for external logins - /// - /// - /// - /// - private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, ExternalSignInAutoLinkOptions? autoLinkOptions) - { - // If there are no autolink options then the attempt is failed (user does not exist) - if (autoLinkOptions == null || !autoLinkOptions.AutoLinkExternalAccount) - { - return SignInResult.Failed; - } - - var email = loginInfo.Principal.FindFirstValue(ClaimTypes.Email); - - //we are allowing auto-linking/creating of local accounts - if (email.IsNullOrWhiteSpace()) - { - return AutoLinkSignInResult.FailedNoEmail; - } - else - { - //Now we need to perform the auto-link, so first we need to lookup/create a user with the email address - var autoLinkUser = await UserManager.FindByEmailAsync(email); - if (autoLinkUser != null) - { - try - { - //call the callback if one is assigned - autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); - } - catch (Exception ex) - { - Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); - return AutoLinkSignInResult.FailedException(ex.Message); - } - - var shouldLinkUser = autoLinkOptions.OnExternalLogin == null || autoLinkOptions.OnExternalLogin(autoLinkUser, loginInfo); - if (shouldLinkUser) - { - return await LinkUser(autoLinkUser, loginInfo); - } - else - { - LogFailedExternalLogin(loginInfo, autoLinkUser); - return ExternalLoginSignInResult.NotAllowed; - } - } - else - { - var name = loginInfo.Principal?.Identity?.Name; - if (name.IsNullOrWhiteSpace()) throw new InvalidOperationException("The Name value cannot be null"); - - autoLinkUser = BackOfficeIdentityUser.CreateNew(_globalSettings, email, email, autoLinkOptions.GetUserAutoLinkCulture(_globalSettings), name); - - foreach (var userGroup in autoLinkOptions.DefaultUserGroups) - { - autoLinkUser.AddRole(userGroup); - } - - //call the callback if one is assigned - try - { - autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); - } - catch (Exception ex) - { - Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); - return AutoLinkSignInResult.FailedException(ex.Message); - } - - var userCreationResult = await _userManager.CreateAsync(autoLinkUser); - - if (!userCreationResult.Succeeded) - { - return AutoLinkSignInResult.FailedCreatingUser(userCreationResult.Errors.Select(x => x.Description).ToList()); - } - else - { - var shouldLinkUser = autoLinkOptions.OnExternalLogin == null || autoLinkOptions.OnExternalLogin(autoLinkUser, loginInfo); - if (shouldLinkUser) - { - return await LinkUser(autoLinkUser, loginInfo); - } - else - { - LogFailedExternalLogin(loginInfo, autoLinkUser); - return ExternalLoginSignInResult.NotAllowed; - } - } - } + LogFailedExternalLogin(loginInfo, user); + return ExternalLoginSignInResult.NotAllowed; } } - private async Task LinkUser(BackOfficeIdentityUser autoLinkUser, ExternalLoginInfo loginInfo) + SignInResult? error = await PreSignInCheck(user); + if (error != null) { - var existingLogins = await _userManager.GetLoginsAsync(autoLinkUser); - var exists = existingLogins.FirstOrDefault(x => x.LoginProvider == loginInfo.LoginProvider && x.ProviderKey == loginInfo.ProviderKey); - - // if it already exists (perhaps it was added in the AutoLink callbak) then we just continue - if (exists != null) - { - //sign in - return await SignInOrTwoFactorAsync(autoLinkUser, isPersistent: false, loginInfo.LoginProvider); - } - - var linkResult = await _userManager.AddLoginAsync(autoLinkUser, loginInfo); - if (linkResult.Succeeded) - { - //we're good! sign in - return await SignInOrTwoFactorAsync(autoLinkUser, isPersistent: false, loginInfo.LoginProvider); - } - - //If this fails, we should really delete the user since it will be in an inconsistent state! - var deleteResult = await _userManager.DeleteAsync(autoLinkUser); - if (deleteResult.Succeeded) - { - var errors = linkResult.Errors.Select(x => x.Description).ToList(); - return AutoLinkSignInResult.FailedLinkingUser(errors); - } - else - { - //DOH! ... this isn't good, combine all errors to be shown - var errors = linkResult.Errors.Concat(deleteResult.Errors).Select(x => x.Description).ToList(); - return AutoLinkSignInResult.FailedLinkingUser(errors); - } + return error; } - protected override async Task SignInOrTwoFactorAsync(BackOfficeIdentityUser 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(BackOfficeIdentityUser user) => Notify(user, - (currentUser) => new UserTwoFactorRequestedNotification(currentUser.Key) - ); - - private T Notify(BackOfficeIdentityUser currentUser, Func createNotification) where T : INotification - { - - var notification = createNotification(currentUser); - _eventAggregator.Publish(notification); - return notification; - } - - private void LogFailedExternalLogin(ExternalLoginInfo loginInfo, BackOfficeIdentityUser 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); + return await SignInOrTwoFactorAsync(user, isPersistent, loginInfo.LoginProvider, bypassTwoFactor); } + + /// + /// Configures the redirect URL and user identifier for the specified external login . + /// + /// The provider to configure. + /// The external login URL users should be redirected to during the login flow. + /// The current user's identifier, which will be used to provide CSRF protection. + /// A configured . + public override AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string? redirectUrl, string? userId = null) + { + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs + // to be able to use our own XsrfKey/LoginProviderKey because the default is private :/ + + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + properties.Items[UmbracoSignInMgrLoginProviderKey] = provider; + if (userId != null) + { + properties.Items[UmbracoSignInMgrXsrfKey] = userId; + } + + return properties; + } + + public override Task> GetExternalAuthenticationSchemesAsync() => + // TODO: We can filter these so that they only include the back office ones. + // That can be done by either checking the scheme (maybe) or comparing it to what we have registered in the collection of BackOfficeExternalLoginProvider + base.GetExternalAuthenticationSchemesAsync(); + + /// + /// Overridden to deal with events/notificiations + /// + /// + /// + /// + /// + protected override async Task HandleSignIn(BackOfficeIdentityUser? user, string? username, SignInResult result) + { + result = await base.HandleSignIn(user, username, result); + + if (result.Succeeded) + { + if (user != null) + { + _userManager.NotifyLoginSuccess(Context.User, user.Id); + } + } + else if (result.IsLockedOut) + { + _userManager.NotifyAccountLocked(Context.User, user?.Id); + } + else if (result.RequiresTwoFactor) + { + _userManager.NotifyLoginRequiresVerification(Context.User, user?.Id); + } + else if (!result.Succeeded || result.IsNotAllowed) + { + } + else + { + throw new ArgumentOutOfRangeException(); + } + + return result; + } + + /// + /// Used for auto linking/creating user accounts for external logins + /// + /// + /// + /// + private async Task AutoLinkAndSignInExternalAccount(ExternalLoginInfo loginInfo, ExternalSignInAutoLinkOptions? autoLinkOptions) + { + // If there are no autolink options then the attempt is failed (user does not exist) + if (autoLinkOptions == null || !autoLinkOptions.AutoLinkExternalAccount) + { + return SignInResult.Failed; + } + + var email = loginInfo.Principal.FindFirstValue(ClaimTypes.Email); + + //we are allowing auto-linking/creating of local accounts + if (email.IsNullOrWhiteSpace()) + { + return AutoLinkSignInResult.FailedNoEmail; + } + + //Now we need to perform the auto-link, so first we need to lookup/create a user with the email address + BackOfficeIdentityUser? autoLinkUser = await UserManager.FindByEmailAsync(email); + if (autoLinkUser != null) + { + try + { + //call the callback if one is assigned + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); + return AutoLinkSignInResult.FailedException(ex.Message); + } + + var shouldLinkUser = autoLinkOptions.OnExternalLogin == null || + autoLinkOptions.OnExternalLogin(autoLinkUser, loginInfo); + if (shouldLinkUser) + { + return await LinkUser(autoLinkUser, loginInfo); + } + + LogFailedExternalLogin(loginInfo, autoLinkUser); + return ExternalLoginSignInResult.NotAllowed; + } + + var name = loginInfo.Principal?.Identity?.Name; + if (name.IsNullOrWhiteSpace()) + { + throw new InvalidOperationException("The Name value cannot be null"); + } + + autoLinkUser = BackOfficeIdentityUser.CreateNew(_globalSettings, email, email, autoLinkOptions.GetUserAutoLinkCulture(_globalSettings), name); + + foreach (var userGroup in autoLinkOptions.DefaultUserGroups) + { + autoLinkUser.AddRole(userGroup); + } + + //call the callback if one is assigned + try + { + autoLinkOptions.OnAutoLinking?.Invoke(autoLinkUser, loginInfo); + } + catch (Exception ex) + { + Logger.LogError(ex, "Could not link login provider {LoginProvider}.", loginInfo.LoginProvider); + return AutoLinkSignInResult.FailedException(ex.Message); + } + + IdentityResult? userCreationResult = await _userManager.CreateAsync(autoLinkUser); + + if (!userCreationResult.Succeeded) + { + return AutoLinkSignInResult.FailedCreatingUser( + userCreationResult.Errors.Select(x => x.Description).ToList()); + } + + { + var shouldLinkUser = autoLinkOptions.OnExternalLogin == null || + autoLinkOptions.OnExternalLogin(autoLinkUser, loginInfo); + if (shouldLinkUser) + { + return await LinkUser(autoLinkUser, loginInfo); + } + + LogFailedExternalLogin(loginInfo, autoLinkUser); + return ExternalLoginSignInResult.NotAllowed; + } + } + + private async Task LinkUser(BackOfficeIdentityUser autoLinkUser, ExternalLoginInfo loginInfo) + { + IList? existingLogins = await _userManager.GetLoginsAsync(autoLinkUser); + UserLoginInfo? exists = existingLogins.FirstOrDefault(x => + x.LoginProvider == loginInfo.LoginProvider && x.ProviderKey == loginInfo.ProviderKey); + + // if it already exists (perhaps it was added in the AutoLink callbak) then we just continue + if (exists != null) + { + //sign in + return await SignInOrTwoFactorAsync(autoLinkUser, false, loginInfo.LoginProvider); + } + + IdentityResult? linkResult = await _userManager.AddLoginAsync(autoLinkUser, loginInfo); + if (linkResult.Succeeded) + { + //we're good! sign in + return await SignInOrTwoFactorAsync(autoLinkUser, false, loginInfo.LoginProvider); + } + + //If this fails, we should really delete the user since it will be in an inconsistent state! + IdentityResult? deleteResult = await _userManager.DeleteAsync(autoLinkUser); + if (deleteResult.Succeeded) + { + var errors = linkResult.Errors.Select(x => x.Description).ToList(); + return AutoLinkSignInResult.FailedLinkingUser(errors); + } + else + { + //DOH! ... this isn't good, combine all errors to be shown + var errors = linkResult.Errors.Concat(deleteResult.Errors).Select(x => x.Description).ToList(); + return AutoLinkSignInResult.FailedLinkingUser(errors); + } + } + + protected override async Task SignInOrTwoFactorAsync(BackOfficeIdentityUser user, bool isPersistent, string? loginProvider = null, bool bypassTwoFactor = false) + { + SignInResult result = await base.SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); + + if (result.RequiresTwoFactor) + { + NotifyRequiresTwoFactor(user); + } + + return result; + } + + protected void NotifyRequiresTwoFactor(BackOfficeIdentityUser user) => Notify(user, currentUser => new UserTwoFactorRequestedNotification(currentUser.Key)); + + private T Notify(BackOfficeIdentityUser currentUser, Func createNotification) + where T : INotification + { + T notification = createNotification(currentUser); + _eventAggregator.Publish(notification); + return notification; + } + + private void LogFailedExternalLogin(ExternalLoginInfo loginInfo, BackOfficeIdentityUser user) => + Logger.LogWarning( + "The AutoLinkOptions of the external authentication provider '{LoginProvider}' have refused the login based on the OnExternalLogin method. Affected user id: '{UserId}'", + loginInfo.LoginProvider, + user.Id); } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeUserManagerAuditer.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeUserManagerAuditer.cs index d69456efa0..242b246c0f 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeUserManagerAuditer.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeUserManagerAuditer.cs @@ -1,4 +1,3 @@ -using System; using System.Globalization; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models.Membership; @@ -7,96 +6,106 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Binds to notifications to write audit logs for the +/// +internal class BackOfficeUserManagerAuditer : + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler { - /// - /// Binds to notifications to write audit logs for the - /// - internal class BackOfficeUserManagerAuditer : - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler + private readonly IAuditService _auditService; + private readonly IUserService _userService; + + public BackOfficeUserManagerAuditer(IAuditService auditService, IUserService userService) { - private readonly IAuditService _auditService; - private readonly IUserService _userService; + _auditService = auditService; + _userService = userService; + } - public BackOfficeUserManagerAuditer(IAuditService auditService, IUserService userService) + public void Handle(UserForgotPasswordChangedNotification notification) => + WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, + "umbraco/user/password/forgot/change", "password forgot/change"); + + public void Handle(UserForgotPasswordRequestedNotification notification) => + WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, + "umbraco/user/password/forgot/request", "password forgot/request"); + + public void Handle(UserLoginFailedNotification notification) => + WriteAudit(notification.PerformingUserId, "0", notification.IpAddress, "umbraco/user/sign-in/failed", + "login failed", ""); + + public void Handle(UserLoginSuccessNotification notification) + => WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, + "umbraco/user/sign-in/login", "login success"); + + public void Handle(UserLogoutSuccessNotification notification) + => WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, + "umbraco/user/sign-in/logout", "logout success"); + + public void Handle(UserPasswordChangedNotification notification) => + WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, + "umbraco/user/password/change", "password change"); + + public void Handle(UserPasswordResetNotification notification) => + WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, + "umbraco/user/password/reset", "password reset"); + + private static string FormatEmail(IMembershipUser user) => + user == null ? string.Empty : user.Email.IsNullOrWhiteSpace() ? "" : $"<{user.Email}>"; + + private void WriteAudit(string performingId, string? affectedId, string ipAddress, string eventType, + string eventDetails, string? affectedDetails = null) + { + IUser? performingUser = null; + if (int.TryParse(performingId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var asInt)) { - _auditService = auditService; - _userService = userService; + performingUser = _userService.GetUserById(asInt); } - public void Handle(UserLoginSuccessNotification notification) - => WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, "umbraco/user/sign-in/login", "login success"); + var performingDetails = performingUser == null + ? $"User UNKNOWN:{performingId}" + : $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}"; - public void Handle(UserLogoutSuccessNotification notification) - => WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, "umbraco/user/sign-in/logout", "logout success"); - - public void Handle(UserLoginFailedNotification notification) => - WriteAudit(notification.PerformingUserId, "0", notification.IpAddress, "umbraco/user/sign-in/failed", "login failed", ""); - - public void Handle(UserForgotPasswordRequestedNotification notification) => - WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, "umbraco/user/password/forgot/request", "password forgot/request"); - - public void Handle(UserForgotPasswordChangedNotification notification) => - WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, "umbraco/user/password/forgot/change", "password forgot/change"); - - public void Handle(UserPasswordChangedNotification notification) => - WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, "umbraco/user/password/change", "password change"); - - public void Handle(UserPasswordResetNotification notification) => - WriteAudit(notification.PerformingUserId, notification.AffectedUserId, notification.IpAddress, "umbraco/user/password/reset", "password reset"); - - private static string FormatEmail(IMembershipUser user) => user == null ? string.Empty : user.Email.IsNullOrWhiteSpace() ? "" : $"<{user.Email}>"; - - private void WriteAudit(string performingId, string? affectedId, string ipAddress, string eventType, string eventDetails, string? affectedDetails = null) + if (!int.TryParse(performingId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var performingIdAsInt)) { - IUser? performingUser = null; - if (int.TryParse(performingId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int asInt)) - { - performingUser = _userService.GetUserById(asInt); - } - - var performingDetails = performingUser == null - ? $"User UNKNOWN:{performingId}" - : $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}"; - - if (!int.TryParse(performingId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int performingIdAsInt)) - { - performingIdAsInt = 0; - } - - if (!int.TryParse(affectedId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int affectedIdAsInt)) - { - affectedIdAsInt = 0; - } - - WriteAudit(performingIdAsInt, performingDetails, affectedIdAsInt, ipAddress, eventType, eventDetails, affectedDetails); + performingIdAsInt = 0; } - private void WriteAudit(int performingId, string performingDetails, int affectedId, string ipAddress, string eventType, string eventDetails, string? affectedDetails = null) + if (!int.TryParse(affectedId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var affectedIdAsInt)) { - if (affectedDetails == null) - { - IUser? affectedUser = _userService.GetUserById(affectedId); - affectedDetails = affectedUser == null - ? $"User UNKNOWN:{affectedId}" - : $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}"; - } - - _auditService.Write( - performingId, - performingDetails, - ipAddress, - DateTime.UtcNow, - affectedId, - affectedDetails, - eventType, - eventDetails); + affectedIdAsInt = 0; } + + WriteAudit(performingIdAsInt, performingDetails, affectedIdAsInt, ipAddress, eventType, eventDetails, + affectedDetails); + } + + private void WriteAudit(int performingId, string performingDetails, int affectedId, string ipAddress, + string eventType, string eventDetails, string? affectedDetails = null) + { + if (affectedDetails == null) + { + IUser? affectedUser = _userService.GetUserById(affectedId); + affectedDetails = affectedUser == null + ? $"User UNKNOWN:{affectedId}" + : $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}"; + } + + _auditService.Write( + performingId, + performingDetails, + ipAddress, + DateTime.UtcNow, + affectedId, + affectedDetails, + eventType, + eventDetails); } } diff --git a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeCookieOptions.cs b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeCookieOptions.cs index c49c73b856..54d37e5431 100644 --- a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeCookieOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeCookieOptions.cs @@ -1,11 +1,9 @@ -using System; -using System.Diagnostics; using System.Security.Claims; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; @@ -18,300 +16,315 @@ using Umbraco.Cms.Core.Web; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Used to configure for the back office authentication type +/// +public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions { + private readonly IBasicAuthService _basicAuthService; + private readonly IDataProtectionProvider _dataProtection; + private readonly GlobalSettings _globalSettings; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IIpResolver _ipResolver; + private readonly IRuntimeState _runtimeState; + private readonly SecuritySettings _securitySettings; + private readonly IServiceProvider _serviceProvider; + private readonly ISystemClock _systemClock; + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly UmbracoRequestPaths _umbracoRequestPaths; + private readonly IUserService _userService; + /// - /// Used to configure for the back office authentication type + /// Initializes a new instance of the class. /// - public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions + /// The + /// The + /// The options + /// The options + /// The + /// The + /// The + /// The + /// The + /// The + /// The + /// The + public ConfigureBackOfficeCookieOptions( + IServiceProvider serviceProvider, + IUmbracoContextAccessor umbracoContextAccessor, + IOptions securitySettings, + IOptions globalSettings, + IHostingEnvironment hostingEnvironment, + IRuntimeState runtimeState, + IDataProtectionProvider dataProtection, + IUserService userService, + IIpResolver ipResolver, + ISystemClock systemClock, + UmbracoRequestPaths umbracoRequestPaths, + IBasicAuthService basicAuthService) { - private readonly IServiceProvider _serviceProvider; - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly SecuritySettings _securitySettings; - private readonly GlobalSettings _globalSettings; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly IRuntimeState _runtimeState; - private readonly IDataProtectionProvider _dataProtection; - private readonly IUserService _userService; - private readonly IIpResolver _ipResolver; - private readonly ISystemClock _systemClock; - private readonly UmbracoRequestPaths _umbracoRequestPaths; - private readonly IBasicAuthService _basicAuthService; + _serviceProvider = serviceProvider; + _umbracoContextAccessor = umbracoContextAccessor; + _securitySettings = securitySettings.Value; + _globalSettings = globalSettings.Value; + _hostingEnvironment = hostingEnvironment; + _runtimeState = runtimeState; + _dataProtection = dataProtection; + _userService = userService; + _ipResolver = ipResolver; + _systemClock = systemClock; + _umbracoRequestPaths = umbracoRequestPaths; + _basicAuthService = basicAuthService; + } - /// - /// Initializes a new instance of the class. - /// - /// The - /// The - /// The options - /// The options - /// The - /// The - /// The - /// The - /// The - /// The - public ConfigureBackOfficeCookieOptions( - IServiceProvider serviceProvider, - IUmbracoContextAccessor umbracoContextAccessor, - IOptions securitySettings, - IOptions globalSettings, - IHostingEnvironment hostingEnvironment, - IRuntimeState runtimeState, - IDataProtectionProvider dataProtection, - IUserService userService, - IIpResolver ipResolver, - ISystemClock systemClock, - UmbracoRequestPaths umbracoRequestPaths, - IBasicAuthService basicAuthService) + /// + public void Configure(string name, CookieAuthenticationOptions options) + { + if (name != Constants.Security.BackOfficeAuthenticationType) { - _serviceProvider = serviceProvider; - _umbracoContextAccessor = umbracoContextAccessor; - _securitySettings = securitySettings.Value; - _globalSettings = globalSettings.Value; - _hostingEnvironment = hostingEnvironment; - _runtimeState = runtimeState; - _dataProtection = dataProtection; - _userService = userService; - _ipResolver = ipResolver; - _systemClock = systemClock; - _umbracoRequestPaths = umbracoRequestPaths; - _basicAuthService = basicAuthService; + return; } - /// - public void Configure(string name, CookieAuthenticationOptions options) + Configure(options); + } + + /// + public void Configure(CookieAuthenticationOptions options) + { + options.SlidingExpiration = false; + options.ExpireTimeSpan = _globalSettings.TimeOut; + options.Cookie.Domain = _securitySettings.AuthCookieDomain; + options.Cookie.Name = _securitySettings.AuthCookieName; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = + _globalSettings.UseHttps ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; + options.Cookie.Path = "/"; + + // For any redirections that may occur for the back office, they all go to the same path + var backOfficePath = _globalSettings.GetBackOfficePath(_hostingEnvironment); + options.AccessDeniedPath = backOfficePath; + options.LoginPath = backOfficePath; + options.LogoutPath = backOfficePath; + + options.DataProtectionProvider = _dataProtection; + + // NOTE: This is borrowed directly from aspnetcore source + // Note: the purpose for the data protector must remain fixed for interop to work. + IDataProtector dataProtector = options.DataProtectionProvider.CreateProtector( + "Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware", + Constants.Security.BackOfficeAuthenticationType, + "v2"); + var ticketDataFormat = new TicketDataFormat(dataProtector); + + options.TicketDataFormat = new BackOfficeSecureDataFormat(_globalSettings.TimeOut, ticketDataFormat); + + // Custom cookie manager so we can filter requests + options.CookieManager = new BackOfficeCookieManager( + _umbracoContextAccessor, + _runtimeState, + _umbracoRequestPaths, + _basicAuthService); + + options.Events = new CookieAuthenticationEvents { - if (name != Constants.Security.BackOfficeAuthenticationType) + // IMPORTANT! If you set any of OnRedirectToLogin, OnRedirectToAccessDenied, OnRedirectToLogout, OnRedirectToReturnUrl + // you need to be aware that this will bypass the default behavior of returning the correct status codes for ajax requests and + // not redirecting for non-ajax requests. This is because the default behavior is baked into this class here: + // https://github.com/dotnet/aspnetcore/blob/master/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs#L58 + // It would be possible to re-use the default behavior if any of these need to be set but that must be taken into account else + // our back office requests will not function correctly. For now we don't need to set/configure any of these callbacks because + // the defaults work fine with our setup. + OnValidatePrincipal = async ctx => { - return; - } + // We need to resolve the BackOfficeSecurityStampValidator per request as a requirement (even in aspnetcore they do this) + BackOfficeSecurityStampValidator securityStampValidator = + ctx.HttpContext.RequestServices.GetRequiredService(); - Configure(options); - } + // Same goes for the signinmanager + IBackOfficeSignInManager signInManager = + ctx.HttpContext.RequestServices.GetRequiredService(); - /// - public void Configure(CookieAuthenticationOptions options) - { - options.SlidingExpiration = false; - options.ExpireTimeSpan = _globalSettings.TimeOut; - options.Cookie.Domain = _securitySettings.AuthCookieDomain; - options.Cookie.Name = _securitySettings.AuthCookieName; - options.Cookie.HttpOnly = true; - options.Cookie.SecurePolicy = _globalSettings.UseHttps ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; - options.Cookie.Path = "/"; - - // For any redirections that may occur for the back office, they all go to the same path - var backOfficePath = _globalSettings.GetBackOfficePath(_hostingEnvironment); - options.AccessDeniedPath = backOfficePath; - options.LoginPath = backOfficePath; - options.LogoutPath = backOfficePath; - - options.DataProtectionProvider = _dataProtection; - - // NOTE: This is borrowed directly from aspnetcore source - // Note: the purpose for the data protector must remain fixed for interop to work. - IDataProtector dataProtector = options.DataProtectionProvider.CreateProtector("Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware", Constants.Security.BackOfficeAuthenticationType, "v2"); - var ticketDataFormat = new TicketDataFormat(dataProtector); - - options.TicketDataFormat = new BackOfficeSecureDataFormat(_globalSettings.TimeOut, ticketDataFormat); - - // Custom cookie manager so we can filter requests - options.CookieManager = new BackOfficeCookieManager( - _umbracoContextAccessor, - _runtimeState, - _umbracoRequestPaths, - _basicAuthService - ); - - options.Events = new CookieAuthenticationEvents - { - // IMPORTANT! If you set any of OnRedirectToLogin, OnRedirectToAccessDenied, OnRedirectToLogout, OnRedirectToReturnUrl - // you need to be aware that this will bypass the default behavior of returning the correct status codes for ajax requests and - // not redirecting for non-ajax requests. This is because the default behavior is baked into this class here: - // https://github.com/dotnet/aspnetcore/blob/master/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs#L58 - // It would be possible to re-use the default behavior if any of these need to be set but that must be taken into account else - // our back office requests will not function correctly. For now we don't need to set/configure any of these callbacks because - // the defaults work fine with our setup. - OnValidatePrincipal = async ctx => + ClaimsIdentity? backOfficeIdentity = ctx.Principal?.GetUmbracoIdentity(); + if (backOfficeIdentity == null) { - // We need to resolve the BackOfficeSecurityStampValidator per request as a requirement (even in aspnetcore they do this) - BackOfficeSecurityStampValidator securityStampValidator = ctx.HttpContext.RequestServices.GetRequiredService(); + ctx.RejectPrincipal(); + await signInManager.SignOutAsync(); + } - // Same goes for the signinmanager - IBackOfficeSignInManager signInManager = ctx.HttpContext.RequestServices.GetRequiredService(); + // ensure the thread culture is set + backOfficeIdentity?.EnsureCulture(); - ClaimsIdentity? backOfficeIdentity = ctx.Principal?.GetUmbracoIdentity(); - if (backOfficeIdentity == null) - { - ctx.RejectPrincipal(); - await signInManager.SignOutAsync(); - } + EnsureTicketRenewalIfKeepUserLoggedIn(ctx); - // ensure the thread culture is set - backOfficeIdentity?.EnsureCulture(); + // add or update a claim to track when the cookie expires, we use this to track time remaining + backOfficeIdentity?.AddOrUpdateClaim(new Claim( + Constants.Security.TicketExpiresClaimType, + ctx.Properties.ExpiresUtc!.Value.ToString("o"), + ClaimValueTypes.DateTime, + Constants.Security.BackOfficeAuthenticationType, + Constants.Security.BackOfficeAuthenticationType, + backOfficeIdentity)); - EnsureTicketRenewalIfKeepUserLoggedIn(ctx); + await securityStampValidator.ValidateAsync(ctx); - // add or update a claim to track when the cookie expires, we use this to track time remaining - backOfficeIdentity?.AddOrUpdateClaim(new Claim( - Constants.Security.TicketExpiresClaimType, - ctx.Properties.ExpiresUtc!.Value.ToString("o"), - ClaimValueTypes.DateTime, + // This might have been called from GetRemainingTimeoutSeconds, in this case we don't want to ensure valid session + // since that in it self will keep the session valid since we renew the lastVerified date. + // Similarly don't renew the token + if (IsRemainingSecondsRequest(ctx)) + { + return; + } + + // This relies on IssuedUtc, so call it before updating it. + await EnsureValidSessionId(ctx); + + // We have to manually specify Issued and Expires, + // because the SecurityStampValidator refreshes the principal every 30 minutes, + // When the principal is refreshed the Issued is update to time of refresh, however, the Expires remains unchanged + // When we then try and renew, the difference of issued and expires effectively becomes the new ExpireTimeSpan + // meaning we effectively lose 30 minutes of our ExpireTimeSpan for EVERY principal refresh if we don't + // https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs#L115 + ctx.Properties.IssuedUtc = _systemClock.UtcNow; + ctx.Properties.ExpiresUtc = _systemClock.UtcNow.Add(_globalSettings.TimeOut); + ctx.ShouldRenew = true; + }, + OnSigningIn = ctx => + { + // occurs when sign in is successful but before the ticket is written to the outbound cookie + ClaimsIdentity? backOfficeIdentity = ctx.Principal?.GetUmbracoIdentity(); + if (backOfficeIdentity != null) + { + // generate a session id and assign it + // create a session token - if we are configured and not in an upgrade state then use the db, otherwise just generate one + Guid session = _runtimeState.Level == RuntimeLevel.Run + ? _userService.CreateLoginSession( + backOfficeIdentity.GetId()!.Value, + _ipResolver.GetCurrentRequestIpAddress()) + : Guid.NewGuid(); + + // add our session claim + backOfficeIdentity.AddClaim(new Claim( + Constants.Security.SessionIdClaimType, + session.ToString(), + ClaimValueTypes.String, Constants.Security.BackOfficeAuthenticationType, Constants.Security.BackOfficeAuthenticationType, backOfficeIdentity)); - await securityStampValidator.ValidateAsync(ctx); - - // This might have been called from GetRemainingTimeoutSeconds, in this case we don't want to ensure valid session - // since that in it self will keep the session valid since we renew the lastVerified date. - // Similarly don't renew the token - if (IsRemainingSecondsRequest(ctx)) - { - return; - } - - // This relies on IssuedUtc, so call it before updating it. - await EnsureValidSessionId(ctx); - - // We have to manually specify Issued and Expires, - // because the SecurityStampValidator refreshes the principal every 30 minutes, - // When the principal is refreshed the Issued is update to time of refresh, however, the Expires remains unchanged - // When we then try and renew, the difference of issued and expires effectively becomes the new ExpireTimeSpan - // meaning we effectively lose 30 minutes of our ExpireTimeSpan for EVERY principal refresh if we don't - // https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs#L115 - ctx.Properties.IssuedUtc = _systemClock.UtcNow; - ctx.Properties.ExpiresUtc = _systemClock.UtcNow.Add(_globalSettings.TimeOut); - ctx.ShouldRenew = true; - }, - OnSigningIn = ctx => - { - // occurs when sign in is successful but before the ticket is written to the outbound cookie - ClaimsIdentity? backOfficeIdentity = ctx.Principal?.GetUmbracoIdentity(); - if (backOfficeIdentity != null) - { - // generate a session id and assign it - // create a session token - if we are configured and not in an upgrade state then use the db, otherwise just generate one - Guid session = _runtimeState.Level == RuntimeLevel.Run - ? _userService.CreateLoginSession(backOfficeIdentity.GetId()!.Value, _ipResolver.GetCurrentRequestIpAddress()) - : Guid.NewGuid(); - - // add our session claim - backOfficeIdentity.AddClaim(new Claim(Constants.Security.SessionIdClaimType, session.ToString(), ClaimValueTypes.String, Constants.Security.BackOfficeAuthenticationType, Constants.Security.BackOfficeAuthenticationType, backOfficeIdentity)); - - // since it is a cookie-based authentication add that claim - backOfficeIdentity.AddClaim(new Claim(ClaimTypes.CookiePath, "/", ClaimValueTypes.String, Constants.Security.BackOfficeAuthenticationType, Constants.Security.BackOfficeAuthenticationType, backOfficeIdentity)); - } - - return Task.CompletedTask; - }, - OnSignedIn = ctx => - { - // occurs when sign in is successful and after the ticket is written to the outbound cookie - - // When we are signed in with the cookie, assign the principal to the current HttpContext - ctx.HttpContext.SetPrincipalForRequest(ctx.Principal); - - return Task.CompletedTask; - }, - OnSigningOut = ctx => - { - // Clear the user's session on sign out - if (ctx.HttpContext?.User?.Identity != null) - { - var claimsIdentity = ctx.HttpContext.User.Identity as ClaimsIdentity; - var sessionId = claimsIdentity?.FindFirstValue(Constants.Security.SessionIdClaimType); - if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out Guid guidSession)) - { - _userService.ClearLoginSession(guidSession); - } - } - - // Remove all of our cookies - var cookies = new[] - { - BackOfficeSessionIdValidator.CookieName, - _securitySettings.AuthCookieName, - Constants.Web.PreviewCookieName, - Constants.Security.BackOfficeExternalCookieName, - Constants.Web.AngularCookieName, - Constants.Web.CsrfValidationCookieName, - }; - foreach (var cookie in cookies) - { - ctx.Options.CookieManager.DeleteCookie(ctx.HttpContext!, cookie, new CookieOptions - { - Path = "/" - }); - } - - return Task.CompletedTask; - }, - }; - } - - /// - /// Ensures that the user has a valid session id - /// - /// - /// So that we are not overloading the database this throttles it's check to every minute - /// - private async Task EnsureValidSessionId(CookieValidatePrincipalContext context) - { - if (_runtimeState.Level != RuntimeLevel.Run) - { - return; - } - - using IServiceScope scope = _serviceProvider.CreateScope(); - BackOfficeSessionIdValidator validator = scope.ServiceProvider.GetRequiredService(); - await validator.ValidateSessionAsync(TimeSpan.FromMinutes(1), context); - } - - /// - /// Ensures the ticket is renewed if the is set to true - /// and the current request is for the get user seconds endpoint - /// - /// The - private void EnsureTicketRenewalIfKeepUserLoggedIn(CookieValidatePrincipalContext context) - { - if (!_securitySettings.KeepUserLoggedIn) - { - return; - } - - DateTimeOffset currentUtc = _systemClock.UtcNow; - DateTimeOffset? issuedUtc = context.Properties.IssuedUtc; - DateTimeOffset? expiresUtc = context.Properties.ExpiresUtc; - - if (expiresUtc.HasValue && issuedUtc.HasValue) - { - TimeSpan timeElapsed = currentUtc.Subtract(issuedUtc.Value); - TimeSpan timeRemaining = expiresUtc.Value.Subtract(currentUtc); - - // if it's time to renew, then do it - if (timeRemaining < timeElapsed) - { - context.ShouldRenew = true; + // since it is a cookie-based authentication add that claim + backOfficeIdentity.AddClaim(new Claim( + ClaimTypes.CookiePath, + "/", + ClaimValueTypes.String, + Constants.Security.BackOfficeAuthenticationType, + Constants.Security.BackOfficeAuthenticationType, + backOfficeIdentity)); } + + return Task.CompletedTask; + }, + OnSignedIn = ctx => + { + // occurs when sign in is successful and after the ticket is written to the outbound cookie + + // When we are signed in with the cookie, assign the principal to the current HttpContext + ctx.HttpContext.SetPrincipalForRequest(ctx.Principal); + + return Task.CompletedTask; + }, + OnSigningOut = ctx => + { + // Clear the user's session on sign out + if (ctx.HttpContext?.User?.Identity != null) + { + var claimsIdentity = ctx.HttpContext.User.Identity as ClaimsIdentity; + var sessionId = claimsIdentity?.FindFirstValue(Constants.Security.SessionIdClaimType); + if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out Guid guidSession)) + { + _userService.ClearLoginSession(guidSession); + } + } + + // Remove all of our cookies + var cookies = new[] + { + BackOfficeSessionIdValidator.CookieName, _securitySettings.AuthCookieName, + Constants.Web.PreviewCookieName, Constants.Security.BackOfficeExternalCookieName, + Constants.Web.AngularCookieName, Constants.Web.CsrfValidationCookieName + }; + foreach (var cookie in cookies) + { + ctx.Options.CookieManager.DeleteCookie(ctx.HttpContext!, cookie, new CookieOptions { Path = "/" }); + } + + return Task.CompletedTask; } + }; + } + + /// + /// Ensures that the user has a valid session id + /// + /// + /// So that we are not overloading the database this throttles it's check to every minute + /// + private async Task EnsureValidSessionId(CookieValidatePrincipalContext context) + { + if (_runtimeState.Level != RuntimeLevel.Run) + { + return; } - private bool IsRemainingSecondsRequest(CookieValidatePrincipalContext context) - { - var routeValues = context.HttpContext.Request.RouteValues; - if (routeValues.TryGetValue("controller", out var controllerName) && - routeValues.TryGetValue("action", out var action)) - { - if (controllerName?.ToString() == ControllerExtensions.GetControllerName() - && action?.ToString() == nameof(AuthenticationController.GetRemainingTimeoutSeconds)) - { - return true; - } - } + using IServiceScope scope = _serviceProvider.CreateScope(); + BackOfficeSessionIdValidator validator = + scope.ServiceProvider.GetRequiredService(); + await validator.ValidateSessionAsync(TimeSpan.FromMinutes(1), context); + } - return false; + /// + /// Ensures the ticket is renewed if the is set to true + /// and the current request is for the get user seconds endpoint + /// + /// The + private void EnsureTicketRenewalIfKeepUserLoggedIn(CookieValidatePrincipalContext context) + { + if (!_securitySettings.KeepUserLoggedIn) + { + return; + } + + DateTimeOffset currentUtc = _systemClock.UtcNow; + DateTimeOffset? issuedUtc = context.Properties.IssuedUtc; + DateTimeOffset? expiresUtc = context.Properties.ExpiresUtc; + + if (expiresUtc.HasValue && issuedUtc.HasValue) + { + TimeSpan timeElapsed = currentUtc.Subtract(issuedUtc.Value); + TimeSpan timeRemaining = expiresUtc.Value.Subtract(currentUtc); + + // if it's time to renew, then do it + if (timeRemaining < timeElapsed) + { + context.ShouldRenew = true; + } } } + + private bool IsRemainingSecondsRequest(CookieValidatePrincipalContext context) + { + RouteValueDictionary routeValues = context.HttpContext.Request.RouteValues; + if (routeValues.TryGetValue("controller", out var controllerName) && + routeValues.TryGetValue("action", out var action)) + { + if (controllerName?.ToString() == ControllerExtensions.GetControllerName() + && action?.ToString() == nameof(AuthenticationController.GetRemainingTimeoutSeconds)) + { + return true; + } + } + + return false; + } } diff --git a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeIdentityOptions.cs b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeIdentityOptions.cs index 8ffad24d54..a480991648 100644 --- a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeIdentityOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeIdentityOptions.cs @@ -1,47 +1,41 @@ -using System; using System.Security.Claims; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Security; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Used to configure for the Umbraco Back office +/// +public sealed class ConfigureBackOfficeIdentityOptions : IConfigureOptions { - /// - /// Used to configure for the Umbraco Back office - /// - public sealed class ConfigureBackOfficeIdentityOptions : IConfigureOptions + private readonly UserPasswordConfigurationSettings _userPasswordConfiguration; + + public ConfigureBackOfficeIdentityOptions(IOptions userPasswordConfiguration) => + _userPasswordConfiguration = userPasswordConfiguration.Value; + + public void Configure(BackOfficeIdentityOptions options) { - private readonly UserPasswordConfigurationSettings _userPasswordConfiguration; + options.SignIn.RequireConfirmedAccount = true; // uses our custom IUserConfirmation + options.SignIn.RequireConfirmedEmail = false; // not implemented + options.SignIn.RequireConfirmedPhoneNumber = false; // not implemented - public ConfigureBackOfficeIdentityOptions(IOptions userPasswordConfiguration) - { - _userPasswordConfiguration = userPasswordConfiguration.Value; - } + options.User.RequireUniqueEmail = true; - public void Configure(BackOfficeIdentityOptions options) - { - options.SignIn.RequireConfirmedAccount = true; // uses our custom IUserConfirmation - options.SignIn.RequireConfirmedEmail = false; // not implemented - options.SignIn.RequireConfirmedPhoneNumber = false; // not implemented + options.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier; + options.ClaimsIdentity.UserNameClaimType = ClaimTypes.Name; + options.ClaimsIdentity.RoleClaimType = ClaimTypes.Role; + options.ClaimsIdentity.SecurityStampClaimType = Constants.Security.SecurityStampClaimType; - options.User.RequireUniqueEmail = true; + options.Lockout.AllowedForNewUsers = true; + // TODO: Implement this + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(30); - options.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier; - options.ClaimsIdentity.UserNameClaimType = ClaimTypes.Name; - options.ClaimsIdentity.RoleClaimType = ClaimTypes.Role; - options.ClaimsIdentity.SecurityStampClaimType = Constants.Security.SecurityStampClaimType; + options.Password.ConfigurePasswordOptions(_userPasswordConfiguration); - options.Lockout.AllowedForNewUsers = true; - // TODO: Implement this - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(30); - - options.Password.ConfigurePasswordOptions(_userPasswordConfiguration); - - options.Lockout.MaxFailedAccessAttempts = _userPasswordConfiguration.MaxFailedAccessAttemptsBeforeLockout; - } - - + options.Lockout.MaxFailedAccessAttempts = _userPasswordConfiguration.MaxFailedAccessAttemptsBeforeLockout; } } diff --git a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs index 110009e5ef..ec5f0f6696 100644 --- a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs @@ -1,16 +1,14 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Web.Common.Security; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Configures the back office security stamp options +/// +public class + ConfigureBackOfficeSecurityStampValidatorOptions : IConfigureOptions { - /// - /// Configures the back office security stamp options - /// - public class ConfigureBackOfficeSecurityStampValidatorOptions : IConfigureOptions - { - public void Configure(BackOfficeSecurityStampValidatorOptions options) - => ConfigureSecurityStampOptions.ConfigureOptions(options); - } - - + public void Configure(BackOfficeSecurityStampValidatorOptions options) + => ConfigureSecurityStampOptions.ConfigureOptions(options); } diff --git a/src/Umbraco.Web.BackOffice/Security/DefaultBackOfficeTwoFactorOptions.cs b/src/Umbraco.Web.BackOffice/Security/DefaultBackOfficeTwoFactorOptions.cs index 5ef0f53695..27312642b4 100644 --- a/src/Umbraco.Web.BackOffice/Security/DefaultBackOfficeTwoFactorOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/DefaultBackOfficeTwoFactorOptions.cs @@ -1,10 +1,6 @@ -using System; +namespace Umbraco.Cms.Web.BackOffice.Security; -namespace Umbraco.Cms.Web.BackOffice.Security +public class DefaultBackOfficeTwoFactorOptions : IBackOfficeTwoFactorOptions { - public class DefaultBackOfficeTwoFactorOptions : IBackOfficeTwoFactorOptions - { - public string GetTwoFactorView(string username) => "views\\common\\login-2fa.html"; - } - + public string GetTwoFactorView(string username) => "views\\common\\login-2fa.html"; } diff --git a/src/Umbraco.Web.BackOffice/Security/ExternalLoginSignInResult.cs b/src/Umbraco.Web.BackOffice/Security/ExternalLoginSignInResult.cs index 188961b2ac..e35d4e632e 100644 --- a/src/Umbraco.Web.BackOffice/Security/ExternalLoginSignInResult.cs +++ b/src/Umbraco.Web.BackOffice/Security/ExternalLoginSignInResult.cs @@ -1,15 +1,11 @@ using Microsoft.AspNetCore.Identity; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Result returned from signing in when external logins are used. +/// +public class ExternalLoginSignInResult : SignInResult { - /// - /// Result returned from signing in when external logins are used. - /// - public class ExternalLoginSignInResult : SignInResult - { - public static ExternalLoginSignInResult NotAllowed { get; } = new ExternalLoginSignInResult() - { - Succeeded = false - }; - } + public static ExternalLoginSignInResult NotAllowed { get; } = new() { Succeeded = false }; } diff --git a/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs index add8eca7b8..bbf37ed8bb 100644 --- a/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ExternalSignInAutoLinkOptions.cs @@ -1,72 +1,72 @@ -using System; using System.Runtime.Serialization; using Microsoft.AspNetCore.Identity; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Security; -using Umbraco.Cms.Web.Common.Security; using SecurityConstants = Umbraco.Cms.Core.Constants.Security; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Options used to configure auto-linking external OAuth providers +/// +public class ExternalSignInAutoLinkOptions { + private readonly string? _defaultCulture; + /// - /// Options used to configure auto-linking external OAuth providers + /// Initializes a new instance of the class. /// - public class ExternalSignInAutoLinkOptions + /// + /// If null, the default will be the 'editor' group + /// + /// + public ExternalSignInAutoLinkOptions( + bool autoLinkExternalAccount = false, + string[]? defaultUserGroups = null, + string? defaultCulture = null, + bool allowManualLinking = true) { - /// - /// Initializes a new instance of the class. - /// - /// - /// If null, the default will be the 'editor' group - /// - /// - public ExternalSignInAutoLinkOptions( - bool autoLinkExternalAccount = false, - string[]? defaultUserGroups = null, - string? defaultCulture = null, - bool allowManualLinking = true) - { - DefaultUserGroups = defaultUserGroups ?? new[] { SecurityConstants.EditorGroupAlias }; - AutoLinkExternalAccount = autoLinkExternalAccount; - AllowManualLinking = allowManualLinking; - _defaultCulture = defaultCulture; - } - - /// - /// A callback executed during account auto-linking and before the user is persisted - /// - [IgnoreDataMember] - public Action? OnAutoLinking { get; set; } - - /// - /// A callback executed during every time a user authenticates using an external login. - /// returns a boolean indicating if sign in should continue or not. - /// - [IgnoreDataMember] - public Func? OnExternalLogin { get; set; } - - /// - /// Gets a value indicating whether flag indicating if logging in with the external provider should auto-link/create a local user - /// - public bool AutoLinkExternalAccount { get; } - - /// - /// By default this is true which allows the user to manually link and unlink the external provider, if set to false the back office user - /// will not see and cannot perform manual linking or unlinking of the external provider. - /// - public bool AllowManualLinking { get; protected set; } - - /// - /// The default user groups to assign to the created local user linked - /// - public string[] DefaultUserGroups { get; } - - private readonly string? _defaultCulture; - - /// - /// The default Culture to use for auto-linking users - /// - // TODO: Should we use IDefaultCultureAccessor here instead? - public string GetUserAutoLinkCulture(GlobalSettings globalSettings) => _defaultCulture ?? globalSettings.DefaultUILanguage; + DefaultUserGroups = defaultUserGroups ?? new[] { SecurityConstants.EditorGroupAlias }; + AutoLinkExternalAccount = autoLinkExternalAccount; + AllowManualLinking = allowManualLinking; + _defaultCulture = defaultCulture; } + + /// + /// A callback executed during account auto-linking and before the user is persisted + /// + [IgnoreDataMember] + public Action? OnAutoLinking { get; set; } + + /// + /// A callback executed during every time a user authenticates using an external login. + /// returns a boolean indicating if sign in should continue or not. + /// + [IgnoreDataMember] + public Func? OnExternalLogin { get; set; } + + /// + /// Gets a value indicating whether flag indicating if logging in with the external provider should auto-link/create a + /// local user + /// + public bool AutoLinkExternalAccount { get; } + + /// + /// By default this is true which allows the user to manually link and unlink the external provider, if set to false + /// the back office user + /// will not see and cannot perform manual linking or unlinking of the external provider. + /// + public bool AllowManualLinking { get; protected set; } + + /// + /// The default user groups to assign to the created local user linked + /// + public string[] DefaultUserGroups { get; } + + /// + /// The default Culture to use for auto-linking users + /// + // TODO: Should we use IDefaultCultureAccessor here instead? + public string GetUserAutoLinkCulture(GlobalSettings globalSettings) => + _defaultCulture ?? globalSettings.DefaultUILanguage; } diff --git a/src/Umbraco.Web.BackOffice/Security/IBackOfficeAntiforgery.cs b/src/Umbraco.Web.BackOffice/Security/IBackOfficeAntiforgery.cs index 59b81ef35d..c4e0c1d91c 100644 --- a/src/Umbraco.Web.BackOffice/Security/IBackOfficeAntiforgery.cs +++ b/src/Umbraco.Web.BackOffice/Security/IBackOfficeAntiforgery.cs @@ -1,28 +1,26 @@ -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Umbraco.Cms.Core; -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Antiforgery implementation for the Umbraco back office +/// +public interface IBackOfficeAntiforgery { /// - /// Antiforgery implementation for the Umbraco back office + /// Validates the headers/cookies passed in for the request /// - public interface IBackOfficeAntiforgery - { - /// - /// Validates the headers/cookies passed in for the request - /// - /// - /// - /// - Task> ValidateRequestAsync(HttpContext httpContext); + /// + /// + /// + Task> ValidateRequestAsync(HttpContext httpContext); - /// - /// Generates tokens to use for the cookie and header antiforgery values - /// - /// - /// - /// - void GetAndStoreTokens(HttpContext httpContext); - } + /// + /// Generates tokens to use for the cookie and header antiforgery values + /// + /// + /// + /// + void GetAndStoreTokens(HttpContext httpContext); } diff --git a/src/Umbraco.Web.BackOffice/Security/IBackOfficeExternalLoginProviders.cs b/src/Umbraco.Web.BackOffice/Security/IBackOfficeExternalLoginProviders.cs index 0d242847b3..78ae41d66e 100644 --- a/src/Umbraco.Web.BackOffice/Security/IBackOfficeExternalLoginProviders.cs +++ b/src/Umbraco.Web.BackOffice/Security/IBackOfficeExternalLoginProviders.cs @@ -1,39 +1,34 @@ -using System.Collections.Generic; -using System.Threading.Tasks; +namespace Umbraco.Cms.Web.BackOffice.Security; -namespace Umbraco.Cms.Web.BackOffice.Security +/// +/// Service to return instances +/// +public interface IBackOfficeExternalLoginProviders { + /// + /// Get the for the specified scheme + /// + /// + /// + Task GetAsync(string authenticationType); /// - /// Service to return instances + /// Get all registered /// - public interface IBackOfficeExternalLoginProviders - { - /// - /// Get the for the specified scheme - /// - /// - /// - Task GetAsync(string authenticationType); + /// + Task> GetBackOfficeProvidersAsync(); - /// - /// Get all registered - /// - /// - Task> GetBackOfficeProvidersAsync(); - - /// - /// Returns the authentication type for the last registered external login (oauth) provider that specifies an auto-login redirect option - /// - /// - /// - string? GetAutoLoginProvider(); - - /// - /// Returns true if there is any external provider that has the Deny Local Login option configured - /// - /// - bool HasDenyLocalLogin(); - } + /// + /// Returns the authentication type for the last registered external login (oauth) provider that specifies an + /// auto-login redirect option + /// + /// + /// + string? GetAutoLoginProvider(); + /// + /// Returns true if there is any external provider that has the Deny Local Login option configured + /// + /// + bool HasDenyLocalLogin(); } diff --git a/src/Umbraco.Web.BackOffice/Security/IBackOfficeTwoFactorOptions.cs b/src/Umbraco.Web.BackOffice/Security/IBackOfficeTwoFactorOptions.cs index 9c060714d7..2199a3c3c0 100644 --- a/src/Umbraco.Web.BackOffice/Security/IBackOfficeTwoFactorOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/IBackOfficeTwoFactorOptions.cs @@ -1,16 +1,14 @@ -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Options used to control 2FA for the Umbraco back office +/// +public interface IBackOfficeTwoFactorOptions { /// - /// Options used to control 2FA for the Umbraco back office + /// Returns the angular view for handling 2FA interaction /// - public interface IBackOfficeTwoFactorOptions - { - /// - /// Returns the angular view for handling 2FA interaction - /// - /// - /// - string? GetTwoFactorView(string username); - } - + /// + /// + string? GetTwoFactorView(string username); } diff --git a/src/Umbraco.Web.BackOffice/Security/IPasswordChanger.cs b/src/Umbraco.Web.BackOffice/Security/IPasswordChanger.cs index 77053602be..66c69d4d70 100644 --- a/src/Umbraco.Web.BackOffice/Security/IPasswordChanger.cs +++ b/src/Umbraco.Web.BackOffice/Security/IPasswordChanger.cs @@ -1,14 +1,12 @@ -using System.Threading.Tasks; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; -namespace Umbraco.Cms.Web.Common.Security +namespace Umbraco.Cms.Web.Common.Security; + +public interface IPasswordChanger where TUser : UmbracoIdentityUser { - public interface IPasswordChanger where TUser : UmbracoIdentityUser - { - public Task> ChangePasswordWithIdentityAsync( - ChangingPasswordModel passwordModel, - IUmbracoUserManager userMgr); - } + public Task> ChangePasswordWithIdentityAsync( + ChangingPasswordModel passwordModel, + IUmbracoUserManager userMgr); } diff --git a/src/Umbraco.Web.BackOffice/Security/NoopBackOfficeTwoFactorOptions.cs b/src/Umbraco.Web.BackOffice/Security/NoopBackOfficeTwoFactorOptions.cs index 09eefb4559..ce4ea203ad 100644 --- a/src/Umbraco.Web.BackOffice/Security/NoopBackOfficeTwoFactorOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/NoopBackOfficeTwoFactorOptions.cs @@ -1,11 +1,7 @@ -using System; +namespace Umbraco.Cms.Web.BackOffice.Security; -namespace Umbraco.Cms.Web.BackOffice.Security +[Obsolete("Not used anymore")] +public class NoopBackOfficeTwoFactorOptions : IBackOfficeTwoFactorOptions { - [Obsolete("Not used anymore")] - public class NoopBackOfficeTwoFactorOptions : IBackOfficeTwoFactorOptions - { - public string? GetTwoFactorView(string username) => null; - } - + public string? GetTwoFactorView(string username) => null; } diff --git a/src/Umbraco.Web.BackOffice/Security/PasswordChanger.cs b/src/Umbraco.Web.BackOffice/Security/PasswordChanger.cs index 5caf477a5f..59ea9a7b81 100644 --- a/src/Umbraco.Web.BackOffice/Security/PasswordChanger.cs +++ b/src/Umbraco.Web.BackOffice/Security/PasswordChanger.cs @@ -1,6 +1,4 @@ -using System; using System.ComponentModel.DataAnnotations; -using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; @@ -8,96 +6,108 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.Common.Security -{ - /// - /// Changes the password for an identity user - /// - internal class PasswordChanger : IPasswordChanger where TUser : UmbracoIdentityUser - { - private readonly ILogger> _logger; +namespace Umbraco.Cms.Web.Common.Security; - /// - /// Initializes a new instance of the class. - /// Password changing functionality - /// - /// Logger for this class - public PasswordChanger(ILogger> logger) +/// +/// Changes the password for an identity user +/// +internal class PasswordChanger : IPasswordChanger where TUser : UmbracoIdentityUser +{ + private readonly ILogger> _logger; + + /// + /// Initializes a new instance of the class. + /// Password changing functionality + /// + /// Logger for this class + public PasswordChanger(ILogger> logger) => _logger = logger; + + /// + /// Changes the password for a user based on the many different rules and config options + /// + /// The changing password model + /// The identity manager to use to update the password + /// Create an adapter to pass through everything - adapting the member into a user for this functionality + /// The outcome of the password changed model + public async Task> ChangePasswordWithIdentityAsync( + ChangingPasswordModel changingPasswordModel, + IUmbracoUserManager userMgr) + { + if (changingPasswordModel == null) { - _logger = logger; + throw new ArgumentNullException(nameof(changingPasswordModel)); } - /// - /// Changes the password for a user based on the many different rules and config options - /// - /// The changing password model - /// The identity manager to use to update the password - /// Create an adapter to pass through everything - adapting the member into a user for this functionality - /// The outcome of the password changed model - public async Task> ChangePasswordWithIdentityAsync( - ChangingPasswordModel changingPasswordModel, - IUmbracoUserManager userMgr) + if (userMgr == null) { - if (changingPasswordModel == null) + throw new ArgumentNullException(nameof(userMgr)); + } + + if (changingPasswordModel.NewPassword.IsNullOrWhiteSpace()) + { + return Attempt.Fail(new PasswordChangedModel { - throw new ArgumentNullException(nameof(changingPasswordModel)); - } + ChangeError = new ValidationResult("Cannot set an empty password", new[] { "value" }) + }); + } - if (userMgr == null) + var userId = changingPasswordModel.Id.ToString(); + TUser identityUser = await userMgr.FindByIdAsync(userId); + if (identityUser == null) + { + // this really shouldn't ever happen... but just in case + return Attempt.Fail(new PasswordChangedModel { - throw new ArgumentNullException(nameof(userMgr)); - } + ChangeError = new ValidationResult("Password could not be verified", new[] { "oldPassword" }) + }); + } - if (changingPasswordModel.NewPassword.IsNullOrWhiteSpace()) + // Are we just changing another user/member's password? + if (changingPasswordModel.OldPassword.IsNullOrWhiteSpace()) + { + // ok, we should be able to reset it + var resetToken = await userMgr.GeneratePasswordResetTokenAsync(identityUser); + + IdentityResult resetResult = + await userMgr.ChangePasswordWithResetAsync(userId, resetToken, changingPasswordModel.NewPassword); + + if (resetResult.Succeeded == false) { - return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Cannot set an empty password", new[] { "value" }) }); - } - - var userId = changingPasswordModel.Id.ToString(); - TUser identityUser = await userMgr.FindByIdAsync(userId); - if (identityUser == null) - { - // this really shouldn't ever happen... but just in case - return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Password could not be verified", new[] { "oldPassword" }) }); - } - - // Are we just changing another user/member's password? - if (changingPasswordModel.OldPassword.IsNullOrWhiteSpace()) - { - // ok, we should be able to reset it - string resetToken = await userMgr.GeneratePasswordResetTokenAsync(identityUser); - - IdentityResult resetResult = await userMgr.ChangePasswordWithResetAsync(userId, resetToken, changingPasswordModel.NewPassword); - - if (resetResult.Succeeded == false) + var errors = resetResult.Errors.ToErrorMessage(); + _logger.LogWarning("Could not reset user password {PasswordErrors}", errors); + return Attempt.Fail(new PasswordChangedModel { - string errors = resetResult.Errors.ToErrorMessage(); - _logger.LogWarning("Could not reset user password {PasswordErrors}", errors); - return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult(errors, new[] { "value" }) }); - } - - return Attempt.Succeed(new PasswordChangedModel()); - } - - // is the old password correct? - bool validateResult = await userMgr.CheckPasswordAsync(identityUser, changingPasswordModel.OldPassword); - if (validateResult == false) - { - // no, fail with an error message for "oldPassword" - return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Incorrect password", new[] { "oldPassword" }) }); - } - - // can we change to the new password? - IdentityResult changeResult = await userMgr.ChangePasswordAsync(identityUser, changingPasswordModel.OldPassword, changingPasswordModel.NewPassword); - if (changeResult.Succeeded == false) - { - // no, fail with error messages for "password" - string errors = changeResult.Errors.ToErrorMessage(); - _logger.LogWarning("Could not change user password {PasswordErrors}", errors); - return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult(errors, new[] { "password" }) }); + ChangeError = new ValidationResult(errors, new[] { "value" }) + }); } return Attempt.Succeed(new PasswordChangedModel()); } + + // is the old password correct? + var validateResult = await userMgr.CheckPasswordAsync(identityUser, changingPasswordModel.OldPassword); + if (validateResult == false) + { + // no, fail with an error message for "oldPassword" + return Attempt.Fail(new PasswordChangedModel + { + ChangeError = new ValidationResult("Incorrect password", new[] { "oldPassword" }) + }); + } + + // can we change to the new password? + IdentityResult changeResult = await userMgr.ChangePasswordAsync(identityUser, changingPasswordModel.OldPassword, changingPasswordModel.NewPassword); + if (changeResult.Succeeded == false) + { + // no, fail with error messages for "password" + var errors = changeResult.Errors.ToErrorMessage(); + _logger.LogWarning("Could not change user password {PasswordErrors}", errors); + return Attempt.Fail(new PasswordChangedModel + { + ChangeError = new ValidationResult(errors, new[] { "password" }) + }); + } + + return Attempt.Succeed(new PasswordChangedModel()); } } diff --git a/src/Umbraco.Web.BackOffice/Security/TwoFactorLoginViewOptions.cs b/src/Umbraco.Web.BackOffice/Security/TwoFactorLoginViewOptions.cs index bdd585a5d8..b5487b249e 100644 --- a/src/Umbraco.Web.BackOffice/Security/TwoFactorLoginViewOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/TwoFactorLoginViewOptions.cs @@ -1,13 +1,12 @@ -namespace Umbraco.Cms.Web.BackOffice.Security +namespace Umbraco.Cms.Web.BackOffice.Security; + +/// +/// Options used as named options for 2fa providers +/// +public class TwoFactorLoginViewOptions { /// - /// Options used as named options for 2fa providers + /// Gets or sets the path of the view to show when setting up this 2fa provider /// - public class TwoFactorLoginViewOptions - { - /// - /// Gets or sets the path of the view to show when setting up this 2fa provider - /// - public string? SetupViewPath { get; set; } - } + public string? SetupViewPath { get; set; } } diff --git a/src/Umbraco.Web.BackOffice/Services/ConflictingRouteService.cs b/src/Umbraco.Web.BackOffice/Services/ConflictingRouteService.cs index e610ca1ee7..86bc607edb 100644 --- a/src/Umbraco.Web.BackOffice/Services/ConflictingRouteService.cs +++ b/src/Umbraco.Web.BackOffice/Services/ConflictingRouteService.cs @@ -1,52 +1,44 @@ -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 +namespace Umbraco.Cms.Web.BackOffice.Services; + +public class ConflictingRouteService : IConflictingRouteService { - public class ConflictingRouteService : IConflictingRouteService + private readonly TypeLoader _typeLoader; + + /// + /// Initializes a new instance of the class. + /// + public ConflictingRouteService(TypeLoader typeLoader) => _typeLoader = typeLoader; + + /// + public bool HasConflictingRoutes(out string controllerName) { - private readonly TypeLoader _typeLoader; - - /// - /// Initializes a new instance of the class. - /// - public ConflictingRouteService(TypeLoader typeLoader) + var controllers = _typeLoader.GetTypes().ToList(); + foreach (Type controller in controllers) { - _typeLoader = typeLoader; - } - - /// - public bool HasConflictingRoutes(out string controllerName) - { - var controllers = _typeLoader.GetTypes().ToList(); - foreach (Type controller in controllers) + Type[] potentialConflicting = controllers.Where(x => x.Name == controller.Name).ToArray(); + if (potentialConflicting.Length > 1) { - var potentialConflicting = controllers.Where(x => x.Name == controller.Name).ToArray(); - if (potentialConflicting.Length > 1) - { - //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 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; - } + if (conflicting) + { + controllerName = controller.Name; + return true; } } - - controllerName = string.Empty; - return false; } + + controllerName = string.Empty; + return false; } } diff --git a/src/Umbraco.Web.BackOffice/Services/IconService.cs b/src/Umbraco.Web.BackOffice/Services/IconService.cs index 53c50e1e40..7f060dc756 100644 --- a/src/Umbraco.Web.BackOffice/Services/IconService.cs +++ b/src/Umbraco.Web.BackOffice/Services/IconService.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; @@ -13,164 +9,158 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; +using File = System.IO.File; using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; -namespace Umbraco.Cms.Web.BackOffice.Services +namespace Umbraco.Cms.Web.BackOffice.Services; + +public class IconService : IIconService { - public class IconService : IIconService + private readonly IAppPolicyCache _cache; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IWebHostEnvironment _webHostEnvironment; + private GlobalSettings _globalSettings; + + [Obsolete("Use other ctor - Will be removed in Umbraco 12")] + public IconService( + IOptionsMonitor globalSettings, + IHostingEnvironment hostingEnvironment, + AppCaches appCaches) + : this( + globalSettings, + hostingEnvironment, + appCaches, + StaticServiceProvider.Instance.GetRequiredService()) { - private GlobalSettings _globalSettings; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly IWebHostEnvironment _webHostEnvironment; - private readonly IAppPolicyCache _cache; + } - [Obsolete("Use other ctor - Will be removed in Umbraco 12")] - public IconService( - IOptionsMonitor globalSettings, - IHostingEnvironment hostingEnvironment, - AppCaches appCaches) - : this(globalSettings, - hostingEnvironment, - appCaches, - StaticServiceProvider.Instance.GetRequiredService()) + [Obsolete("Use other ctor - Will be removed in Umbraco 12")] + public IconService( + IOptionsMonitor globalSettings, + IHostingEnvironment hostingEnvironment, + AppCaches appCaches, + IWebHostEnvironment webHostEnvironment) + { + _globalSettings = globalSettings.CurrentValue; + _hostingEnvironment = hostingEnvironment; + _webHostEnvironment = webHostEnvironment; + _cache = appCaches.RuntimeCache; + + globalSettings.OnChange(x => _globalSettings = x); + } + + /// + public IReadOnlyDictionary? GetIcons() => GetIconDictionary(); + + /// + public IconModel? GetIcon(string iconName) + { + if (iconName.IsNullOrWhiteSpace()) { - - } - - [Obsolete("Use other ctor - Will be removed in Umbraco 12")] - public IconService( - IOptionsMonitor globalSettings, - IHostingEnvironment hostingEnvironment, - AppCaches appCaches, - IWebHostEnvironment webHostEnvironment) - { - _globalSettings = globalSettings.CurrentValue; - _hostingEnvironment = hostingEnvironment; - _webHostEnvironment = webHostEnvironment; - _cache = appCaches.RuntimeCache; - - globalSettings.OnChange(x => _globalSettings = x); - } - - /// - public IReadOnlyDictionary? GetIcons() => GetIconDictionary(); - - /// - public IconModel? GetIcon(string iconName) - { - if (iconName.IsNullOrWhiteSpace()) - { - return null; - } - - var allIconModels = GetIconDictionary(); - if (allIconModels?.ContainsKey(iconName) ?? false) - { - return new IconModel - { - Name = iconName, - SvgString = allIconModels[iconName] - }; - } - return null; } - /// - /// Gets an IconModel using values from a FileInfo model - /// - /// - /// - private IconModel? GetIcon(FileInfo fileInfo) + IReadOnlyDictionary? allIconModels = GetIconDictionary(); + if (allIconModels?.ContainsKey(iconName) ?? false) { - return fileInfo == null || string.IsNullOrWhiteSpace(fileInfo.Name) - ? null - : CreateIconModel(fileInfo.Name.StripFileExtension(), fileInfo.FullName); + return new IconModel { Name = iconName, SvgString = allIconModels[iconName] }; } - /// - /// Gets an IconModel containing the icon name and SvgString - /// - /// - /// - /// - private IconModel? CreateIconModel(string iconName, string iconPath) + return null; + } + + /// + /// Gets an IconModel using values from a FileInfo model + /// + /// + /// + private IconModel? GetIcon(FileInfo fileInfo) => + fileInfo == null || string.IsNullOrWhiteSpace(fileInfo.Name) + ? null + : CreateIconModel(fileInfo.Name.StripFileExtension(), fileInfo.FullName); + + /// + /// Gets an IconModel containing the icon name and SvgString + /// + /// + /// + /// + private IconModel? CreateIconModel(string iconName, string iconPath) + { + try { - try - { - var svgContent = System.IO.File.ReadAllText(iconPath); + var svgContent = File.ReadAllText(iconPath); - var svg = new IconModel - { - Name = iconName, - SvgString = svgContent - }; + var svg = new IconModel { Name = iconName, SvgString = svgContent }; - return svg; - } - catch - { - return null; - } + return svg; } - - private IEnumerable GetAllIconsFiles() + catch { - var icons = new HashSet(new CaseInsensitiveFileInfoComparer()); + return null; + } + } - // add icons from plugins - var appPluginsDirectoryPath = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.AppPlugins); - if (Directory.Exists(appPluginsDirectoryPath)) + private IEnumerable GetAllIconsFiles() + { + var icons = new HashSet(new CaseInsensitiveFileInfoComparer()); + + // add icons from plugins + var appPluginsDirectoryPath = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.AppPlugins); + if (Directory.Exists(appPluginsDirectoryPath)) + { + var appPlugins = new DirectoryInfo(appPluginsDirectoryPath); + + // iterate sub directories of app plugins + foreach (DirectoryInfo dir in appPlugins.EnumerateDirectories()) { - var appPlugins = new DirectoryInfo(appPluginsDirectoryPath); + // AppPluginIcons path was previoulsy the wrong case, so we first check for the prefered directory + // and then check the legacy directory. + var iconPath = _hostingEnvironment.MapPathContentRoot( + $"{Constants.SystemDirectories.AppPlugins}/{dir.Name}{Constants.SystemDirectories.PluginIcons}"); + var iconPathExists = Directory.Exists(iconPath); - // iterate sub directories of app plugins - foreach (var dir in appPlugins.EnumerateDirectories()) + if (!iconPathExists) { - // AppPluginIcons path was previoulsy the wrong case, so we first check for the prefered directory - // and then check the legacy directory. - var iconPath = _hostingEnvironment.MapPathContentRoot($"{Constants.SystemDirectories.AppPlugins}/{dir.Name}{Constants.SystemDirectories.PluginIcons}"); - var iconPathExists = Directory.Exists(iconPath); + iconPath = _hostingEnvironment.MapPathContentRoot( + $"{Constants.SystemDirectories.AppPlugins}/{dir.Name}{Constants.SystemDirectories.AppPluginIcons}"); + iconPathExists = Directory.Exists(iconPath); + } - if (!iconPathExists) - { - iconPath = _hostingEnvironment.MapPathContentRoot($"{Constants.SystemDirectories.AppPlugins}/{dir.Name}{Constants.SystemDirectories.AppPluginIcons}"); - iconPathExists = Directory.Exists(iconPath); - } - - if (iconPathExists) - { - var dirIcons = new DirectoryInfo(iconPath).EnumerateFiles("*.svg", SearchOption.TopDirectoryOnly); - icons.UnionWith(dirIcons); - } + if (iconPathExists) + { + IEnumerable dirIcons = + new DirectoryInfo(iconPath).EnumerateFiles("*.svg", SearchOption.TopDirectoryOnly); + icons.UnionWith(dirIcons); } } - - var iconFolder = _webHostEnvironment.WebRootFileProvider.GetDirectoryContents(_globalSettings.IconsPath); - - var coreIcons = iconFolder - .Where(x => !x.IsDirectory && x.Name.EndsWith(".svg")) - .Select(x => new FileInfo(x.PhysicalPath)); - - icons.UnionWith(coreIcons); - - return icons; } - private class CaseInsensitiveFileInfoComparer : IEqualityComparer - { - public bool Equals(FileInfo? one, FileInfo? two) => StringComparer.InvariantCultureIgnoreCase.Equals(one?.Name, two?.Name); + IDirectoryContents? iconFolder = + _webHostEnvironment.WebRootFileProvider.GetDirectoryContents(_globalSettings.IconsPath); - public int GetHashCode(FileInfo item) => StringComparer.InvariantCultureIgnoreCase.GetHashCode(item.Name); - } + IEnumerable coreIcons = iconFolder + .Where(x => !x.IsDirectory && x.Name.EndsWith(".svg")) + .Select(x => new FileInfo(x.PhysicalPath)); - private IReadOnlyDictionary? GetIconDictionary() => _cache.GetCacheItem( - $"{typeof(IconService).FullName}.{nameof(GetIconDictionary)}", - () => GetAllIconsFiles() - .Select(GetIcon) - .WhereNotNull() - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.First().SvgString, StringComparer.OrdinalIgnoreCase) - ); + icons.UnionWith(coreIcons); + + return icons; + } + + private IReadOnlyDictionary? GetIconDictionary() => _cache.GetCacheItem( + $"{typeof(IconService).FullName}.{nameof(GetIconDictionary)}", + () => GetAllIconsFiles() + .Select(GetIcon) + .WhereNotNull() + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().SvgString, StringComparer.OrdinalIgnoreCase)); + + private class CaseInsensitiveFileInfoComparer : IEqualityComparer + { + public bool Equals(FileInfo? one, FileInfo? two) => + StringComparer.InvariantCultureIgnoreCase.Equals(one?.Name, two?.Name); + + public int GetHashCode(FileInfo item) => StringComparer.InvariantCultureIgnoreCase.GetHashCode(item.Name); } } diff --git a/src/Umbraco.Web.BackOffice/SignalR/IPreviewHub.cs b/src/Umbraco.Web.BackOffice/SignalR/IPreviewHub.cs index 1123bc6b16..4ba4b9fd26 100644 --- a/src/Umbraco.Web.BackOffice/SignalR/IPreviewHub.cs +++ b/src/Umbraco.Web.BackOffice/SignalR/IPreviewHub.cs @@ -1,14 +1,11 @@ -using System.Threading.Tasks; +namespace Umbraco.Cms.Web.BackOffice.SignalR; -namespace Umbraco.Cms.Web.BackOffice.SignalR +public interface IPreviewHub { - public interface IPreviewHub - { - // define methods implemented by client - // ReSharper disable InconsistentNaming + // define methods implemented by client + // ReSharper disable InconsistentNaming - Task refreshed(int id); + Task refreshed(int id); - // ReSharper restore InconsistentNaming - } + // ReSharper restore InconsistentNaming } diff --git a/src/Umbraco.Web.BackOffice/SignalR/PreviewHub.cs b/src/Umbraco.Web.BackOffice/SignalR/PreviewHub.cs index 38ab3b478d..b5407f385c 100644 --- a/src/Umbraco.Web.BackOffice/SignalR/PreviewHub.cs +++ b/src/Umbraco.Web.BackOffice/SignalR/PreviewHub.cs @@ -1,7 +1,7 @@ -using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR; -namespace Umbraco.Cms.Web.BackOffice.SignalR +namespace Umbraco.Cms.Web.BackOffice.SignalR; + +public class PreviewHub : Hub { - public class PreviewHub : Hub - { } } diff --git a/src/Umbraco.Web.BackOffice/SignalR/PreviewHubUpdater.cs b/src/Umbraco.Web.BackOffice/SignalR/PreviewHubUpdater.cs index ddab717aa6..104deceba2 100644 --- a/src/Umbraco.Web.BackOffice/SignalR/PreviewHubUpdater.cs +++ b/src/Umbraco.Web.BackOffice/SignalR/PreviewHubUpdater.cs @@ -1,35 +1,33 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Sync; -namespace Umbraco.Cms.Web.BackOffice.SignalR -{ - public class PreviewHubUpdater :INotificationAsyncHandler - { - private readonly Lazy> _hubContext; +namespace Umbraco.Cms.Web.BackOffice.SignalR; - // using a lazy arg here means that we won't create the hub until necessary - // and therefore we won't have too bad an impact on boot time - public PreviewHubUpdater(Lazy> hubContext) +public class PreviewHubUpdater : INotificationAsyncHandler +{ + private readonly Lazy> _hubContext; + + // using a lazy arg here means that we won't create the hub until necessary + // and therefore we won't have too bad an impact on boot time + public PreviewHubUpdater(Lazy> hubContext) => _hubContext = hubContext; + + + public async Task HandleAsync(ContentCacheRefresherNotification args, CancellationToken cancellationToken) + { + if (args.MessageType != MessageType.RefreshByPayload) { - _hubContext = hubContext; + return; } - - public async Task HandleAsync(ContentCacheRefresherNotification args, CancellationToken cancellationToken) { - if (args.MessageType != MessageType.RefreshByPayload) return; - var payloads = (ContentCacheRefresher.JsonPayload[])args.MessageObject; - var hubContextInstance = _hubContext.Value; - foreach (var payload in payloads) - { - var id = payload.Id; // keep it simple for now, ignore ChangeTypes - await hubContextInstance.Clients.All.refreshed(id); - } + var payloads = (ContentCacheRefresher.JsonPayload[])args.MessageObject; + IHubContext hubContextInstance = _hubContext.Value; + foreach (ContentCacheRefresher.JsonPayload payload in payloads) + { + var id = payload.Id; // keep it simple for now, ignore ChangeTypes + await hubContextInstance.Clients.All.refreshed(id); } } } diff --git a/src/Umbraco.Web.BackOffice/Trees/ApplicationTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ApplicationTreeController.cs index 67b97892ea..7e709b5904 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ApplicationTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ApplicationTreeController.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; @@ -10,6 +6,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Primitives; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models.Trees; +using Umbraco.Cms.Core.Sections; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.BackOffice.Controllers; @@ -20,349 +17,355 @@ using Umbraco.Cms.Web.Common.ModelBinders; using Umbraco.Extensions; using static Umbraco.Cms.Core.Constants.Web.Routing; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// Used to return tree root nodes +/// +[AngularJsonOnlyConfiguration] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +public class ApplicationTreeController : UmbracoAuthorizedApiController { + private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; + private readonly IControllerFactory _controllerFactory; + private readonly ILocalizedTextService _localizedTextService; + private readonly ISectionService _sectionService; + private readonly ITreeService _treeService; + /// - /// Used to return tree root nodes + /// Initializes a new instance of the class. /// - [AngularJsonOnlyConfiguration] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - public class ApplicationTreeController : UmbracoAuthorizedApiController + public ApplicationTreeController( + ITreeService treeService, + ISectionService sectionService, + ILocalizedTextService localizedTextService, + IControllerFactory controllerFactory, + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) { - private readonly ITreeService _treeService; - private readonly ISectionService _sectionService; - private readonly ILocalizedTextService _localizedTextService; - private readonly IControllerFactory _controllerFactory; - private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; + _treeService = treeService; + _sectionService = sectionService; + _localizedTextService = localizedTextService; + _controllerFactory = controllerFactory; + _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; + } - /// - /// Initializes a new instance of the class. - /// - public ApplicationTreeController( - ITreeService treeService, - ISectionService sectionService, - ILocalizedTextService localizedTextService, - IControllerFactory controllerFactory, - IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) + /// + /// Returns the tree nodes for an application + /// + /// The application to load tree for + /// An optional single tree alias, if specified will only load the single tree for the request app + /// The query strings + /// Tree use. + public async Task> GetApplicationTrees(string? application, string? tree, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection? queryStrings, TreeUse use = TreeUse.Main) + { + application = application?.CleanForXss(); + + if (string.IsNullOrEmpty(application)) { - _treeService = treeService; - _sectionService = sectionService; - _localizedTextService = localizedTextService; - _controllerFactory = controllerFactory; - _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; + return NotFound(); } - /// - /// Returns the tree nodes for an application - /// - /// The application to load tree for - /// An optional single tree alias, if specified will only load the single tree for the request app - /// The query strings - /// Tree use. - public async Task> GetApplicationTrees(string? application, string? tree, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection? queryStrings, TreeUse use = TreeUse.Main) + ISection? section = _sectionService.GetByAlias(application); + if (section == null) { - application = application?.CleanForXss(); + return NotFound(); + } - if (string.IsNullOrEmpty(application)) + // find all tree definitions that have the current application alias + IDictionary> groupedTrees = _treeService.GetBySectionGrouped(application, use); + var allTrees = groupedTrees.Values.SelectMany(x => x).ToList(); + + if (allTrees.Count == 0) + { + // if there are no trees defined for this section but the section is defined then we can have a simple + // full screen section without trees + var name = _localizedTextService.Localize("sections", application); + return TreeRootNode.CreateSingleTreeRoot(Constants.System.RootString, null, null, name, TreeNodeCollection.Empty, true); + } + + // handle request for a specific tree / or when there is only one tree + if (!tree.IsNullOrWhiteSpace() || allTrees.Count == 1) + { + Tree? t = tree.IsNullOrWhiteSpace() + ? allTrees[0] + : allTrees.FirstOrDefault(x => x.TreeAlias == tree); + + if (t == null) { return NotFound(); } - var section = _sectionService.GetByAlias(application); - if (section == null) + ActionResult? treeRootNode = await GetTreeRootNode(t, Constants.System.Root, queryStrings); + + if (treeRootNode != null) { - return NotFound(); + return treeRootNode; } - // find all tree definitions that have the current application alias - var groupedTrees = _treeService.GetBySectionGrouped(application, use); - var allTrees = groupedTrees.Values.SelectMany(x => x).ToList(); - - if (allTrees.Count == 0) - { - // if there are no trees defined for this section but the section is defined then we can have a simple - // full screen section without trees - var name = _localizedTextService.Localize("sections", application); - return TreeRootNode.CreateSingleTreeRoot(Constants.System.RootString, null, null, name, TreeNodeCollection.Empty, true); - } - - // handle request for a specific tree / or when there is only one tree - if (!tree.IsNullOrWhiteSpace() || allTrees.Count == 1) - { - var t = tree.IsNullOrWhiteSpace() - ? allTrees[0] - : allTrees.FirstOrDefault(x => x.TreeAlias == tree); - - if (t == null) - { - return NotFound(); - } - - var treeRootNode = await GetTreeRootNode(t, Constants.System.Root, queryStrings); - - if (treeRootNode != null) - { - return treeRootNode; - } - - return NotFound(); - } - - // handle requests for all trees - // for only 1 group - if (groupedTrees.Count == 1) - { - var nodes = new TreeNodeCollection(); - foreach (var t in allTrees) - { - var nodeResult = await TryGetRootNode(t, queryStrings); - if (!(nodeResult?.Result is null)) - { - return nodeResult.Result; - } - - var node = nodeResult?.Value; - if (node != null) - { - nodes.Add(node); - } - } - - var name = _localizedTextService.Localize("sections", application); - - if (nodes.Count > 0) - { - var treeRootNode = TreeRootNode.CreateMultiTreeRoot(nodes); - treeRootNode.Name = name; - return treeRootNode; - } - - // otherwise it's a section with all empty trees, aka a fullscreen section - // todo is this true? what if we just failed to TryGetRootNode on all of them? SD: Yes it's true but we should check the result of TryGetRootNode and throw? - return TreeRootNode.CreateSingleTreeRoot(Constants.System.RootString, null, null, name, TreeNodeCollection.Empty, true); - } - - // for many groups - var treeRootNodes = new List(); - foreach (var (groupName, trees) in groupedTrees) - { - var nodes = new TreeNodeCollection(); - foreach (var t in trees) - { - var nodeResult = await TryGetRootNode(t, queryStrings); - if (nodeResult != null && nodeResult.Result is not null) - { - return nodeResult.Result; - } - var node = nodeResult?.Value; - - if (node != null) - { - nodes.Add(node); - } - } - - if (nodes.Count == 0) - { - continue; - } - - // no name => third party - // use localization key treeHeaders/thirdPartyGroup - // todo this is an odd convention - var name = groupName.IsNullOrWhiteSpace() ? "thirdPartyGroup" : groupName; - - var groupRootNode = TreeRootNode.CreateGroupNode(nodes, application); - groupRootNode.Name = _localizedTextService.Localize("treeHeaders", name); - treeRootNodes.Add(groupRootNode); - } - - return TreeRootNode.CreateGroupedMultiTreeRoot(new TreeNodeCollection(treeRootNodes.OrderBy(x => x.Name))); + return NotFound(); } - /// - /// Tries to get the root node of a tree. - /// - /// - /// Returns null if the root node could not be obtained due to that - /// the user isn't authorized to view that tree. In this case since we are - /// loading multiple trees we will just return null so that it's not added - /// to the list - /// - private async Task?> TryGetRootNode(Tree tree, FormCollection? querystring) + // handle requests for all trees + // for only 1 group + if (groupedTrees.Count == 1) { - if (tree == null) + var nodes = new TreeNodeCollection(); + foreach (Tree t in allTrees) { - throw new ArgumentNullException(nameof(tree)); + ActionResult? nodeResult = await TryGetRootNode(t, queryStrings); + if (!(nodeResult?.Result is null)) + { + return nodeResult.Result; + } + + TreeNode? node = nodeResult?.Value; + if (node != null) + { + nodes.Add(node); + } } - return await GetRootNode(tree, querystring); + var name = _localizedTextService.Localize("sections", application); + + if (nodes.Count > 0) + { + var treeRootNode = TreeRootNode.CreateMultiTreeRoot(nodes); + treeRootNode.Name = name; + return treeRootNode; + } + + // otherwise it's a section with all empty trees, aka a fullscreen section + // todo is this true? what if we just failed to TryGetRootNode on all of them? SD: Yes it's true but we should check the result of TryGetRootNode and throw? + return TreeRootNode.CreateSingleTreeRoot(Constants.System.RootString, null, null, name, TreeNodeCollection.Empty, true); } - /// - /// Get the tree root node of a tree. - /// - private async Task> GetTreeRootNode(Tree tree, int id, FormCollection? querystring) + // for many groups + var treeRootNodes = new List(); + foreach ((var groupName, IEnumerable trees) in groupedTrees) { - if (tree == null) + var nodes = new TreeNodeCollection(); + foreach (Tree t in trees) { - throw new ArgumentNullException(nameof(tree)); + ActionResult? nodeResult = await TryGetRootNode(t, queryStrings); + if (nodeResult != null && nodeResult.Result is not null) + { + return nodeResult.Result; + } + + TreeNode? node = nodeResult?.Value; + + if (node != null) + { + nodes.Add(node); + } } - var childrenResult = await GetChildren(tree, id, querystring); - if (!(childrenResult?.Result is null)) + if (nodes.Count == 0) { - return new ActionResult(childrenResult.Result); + continue; } - var children = childrenResult?.Value; - var rootNodeResult = await GetRootNode(tree, querystring); - if (!(rootNodeResult?.Result is null)) + // no name => third party + // use localization key treeHeaders/thirdPartyGroup + // todo this is an odd convention + var name = groupName.IsNullOrWhiteSpace() ? "thirdPartyGroup" : groupName; + + var groupRootNode = TreeRootNode.CreateGroupNode(nodes, application); + groupRootNode.Name = _localizedTextService.Localize("treeHeaders", name); + treeRootNodes.Add(groupRootNode); + } + + return TreeRootNode.CreateGroupedMultiTreeRoot(new TreeNodeCollection(treeRootNodes.OrderBy(x => x.Name))); + } + + /// + /// Tries to get the root node of a tree. + /// + /// + /// + /// Returns null if the root node could not be obtained due to that + /// the user isn't authorized to view that tree. In this case since we are + /// loading multiple trees we will just return null so that it's not added + /// to the list + /// + /// + private async Task?> TryGetRootNode(Tree tree, FormCollection? querystring) + { + if (tree == null) + { + throw new ArgumentNullException(nameof(tree)); + } + + return await GetRootNode(tree, querystring); + } + + /// + /// Get the tree root node of a tree. + /// + private async Task> GetTreeRootNode(Tree tree, int id, FormCollection? querystring) + { + if (tree == null) + { + throw new ArgumentNullException(nameof(tree)); + } + + ActionResult? childrenResult = await GetChildren(tree, id, querystring); + if (!(childrenResult?.Result is null)) + { + return new ActionResult(childrenResult.Result); + } + + TreeNodeCollection? children = childrenResult?.Value; + ActionResult? rootNodeResult = await GetRootNode(tree, querystring); + if (!(rootNodeResult?.Result is null)) + { + return rootNodeResult.Result; + } + + TreeNode? rootNode = rootNodeResult?.Value; + + + var sectionRoot = TreeRootNode.CreateSingleTreeRoot( + Constants.System.RootString, + rootNode!.ChildNodesUrl, + rootNode.MenuUrl, + rootNode.Name, + children, + tree.IsSingleNodeTree); + + // assign the route path based on the root node, this means it will route there when the + // section is navigated to and no dashboards will be available for this section + sectionRoot.RoutePath = rootNode.RoutePath; + sectionRoot.Path = rootNode.Path; + + foreach (KeyValuePair d in rootNode.AdditionalData) + { + sectionRoot.AdditionalData[d.Key] = d.Value; + } + + return sectionRoot; + } + + /// + /// Gets the root node of a tree. + /// + private async Task?> GetRootNode(Tree tree, FormCollection? querystring) + { + if (tree == null) + { + throw new ArgumentNullException(nameof(tree)); + } + + ActionResult result = await GetApiControllerProxy(tree.TreeControllerType, "GetRootNode", querystring); + + // return null if the user isn't authorized to view that tree + if (!((ForbidResult?)result.Result is null)) + { + return null; + } + + var controller = (TreeControllerBase?)result.Value; + TreeNode? rootNode = null; + if (controller is not null) + { + ActionResult rootNodeResult = await controller.GetRootNode(querystring); + if (!(rootNodeResult.Result is null)) { return rootNodeResult.Result; } - var rootNode = rootNodeResult?.Value; + rootNode = rootNodeResult.Value; - - var sectionRoot = TreeRootNode.CreateSingleTreeRoot( - Constants.System.RootString, - rootNode!.ChildNodesUrl, - rootNode.MenuUrl, - rootNode.Name, - children, - tree.IsSingleNodeTree); - - // assign the route path based on the root node, this means it will route there when the - // section is navigated to and no dashboards will be available for this section - sectionRoot.RoutePath = rootNode.RoutePath; - sectionRoot.Path = rootNode.Path; - - foreach (var d in rootNode.AdditionalData) + if (rootNode == null) { - sectionRoot.AdditionalData[d.Key] = d.Value; + throw new InvalidOperationException($"Failed to get root node for tree \"{tree.TreeAlias}\"."); } - - return sectionRoot; } - /// - /// Gets the root node of a tree. - /// - private async Task?> GetRootNode(Tree tree, FormCollection? querystring) + return rootNode; + } + + /// + /// Get the child nodes of a tree node. + /// + private async Task?> GetChildren(Tree tree, int id, FormCollection? querystring) + { + if (tree == null) { - if (tree == null) - { - throw new ArgumentNullException(nameof(tree)); - } - - var result = await GetApiControllerProxy(tree.TreeControllerType, "GetRootNode", querystring); - - // return null if the user isn't authorized to view that tree - if (!((ForbidResult?)result.Result is null)) - { - return null; - } - - var controller = (TreeControllerBase?)result.Value; - TreeNode? rootNode = null; - if (controller is not null) - { - var rootNodeResult = await controller.GetRootNode(querystring); - if (!(rootNodeResult.Result is null)) - { - return rootNodeResult.Result; - } - - rootNode = rootNodeResult.Value; - - if (rootNode == null) - { - throw new InvalidOperationException($"Failed to get root node for tree \"{tree.TreeAlias}\"."); - } - } - - return rootNode; + throw new ArgumentNullException(nameof(tree)); } - /// - /// Get the child nodes of a tree node. - /// - private async Task?> GetChildren(Tree tree, int id, FormCollection? querystring) + // the method we proxy has an 'id' parameter which is *not* in the querystring, + // we need to add it for the proxy to work (else, it does not find the method, + // when trying to run auth filters etc). + Dictionary d = querystring?.ToDictionary(x => x.Key, x => x.Value) ?? + new Dictionary(); + d["id"] = StringValues.Empty; + var proxyQuerystring = new FormCollection(d); + + ActionResult controllerResult = + await GetApiControllerProxy(tree.TreeControllerType, "GetNodes", proxyQuerystring); + if (!(controllerResult.Result is null)) { - if (tree == null) - { - throw new ArgumentNullException(nameof(tree)); - } - - // the method we proxy has an 'id' parameter which is *not* in the querystring, - // we need to add it for the proxy to work (else, it does not find the method, - // when trying to run auth filters etc). - var d = querystring?.ToDictionary(x => x.Key, x => x.Value) ?? new Dictionary(); - d["id"] = StringValues.Empty; - var proxyQuerystring = new FormCollection(d); - - var controllerResult = await GetApiControllerProxy(tree.TreeControllerType, "GetNodes", proxyQuerystring); - if (!(controllerResult.Result is null)) - { - return new ActionResult(controllerResult.Result); - } - - var controller = (TreeControllerBase?)controllerResult.Value; - return controller is not null ? await controller.GetNodes(id.ToInvariantString(), querystring) : null; + return new ActionResult(controllerResult.Result); } - /// - /// Gets a proxy to a controller for a specified action. - /// - /// The type of the controller. - /// The action. - /// The querystring. - /// An instance of the controller. - /// - /// Creates an instance of the and initializes it with a route - /// and context etc. so it can execute the specified . Runs the authorization - /// filters for that action, to ensure that the user has permission to execute it. - /// - private async Task> GetApiControllerProxy(Type controllerType, string action, FormCollection? querystring) + var controller = (TreeControllerBase?)controllerResult.Value; + return controller is not null ? await controller.GetNodes(id.ToInvariantString(), querystring) : null; + } + + /// + /// Gets a proxy to a controller for a specified action. + /// + /// The type of the controller. + /// The action. + /// The querystring. + /// An instance of the controller. + /// + /// + /// Creates an instance of the and initializes it with a route + /// and context etc. so it can execute the specified . Runs the authorization + /// filters for that action, to ensure that the user has permission to execute it. + /// + /// + private async Task> GetApiControllerProxy(Type controllerType, string action, FormCollection? querystring) + { + // note: this is all required in order to execute the auth-filters for the sub request, we + // need to "trick" mvc into thinking that it is actually executing the proxied controller. + + var controllerName = ControllerExtensions.GetControllerName(controllerType); + + // create proxy route data specifying the action & controller to execute + var routeData = new RouteData(new RouteValueDictionary { - // note: this is all required in order to execute the auth-filters for the sub request, we - // need to "trick" mvc into thinking that it is actually executing the proxied controller. - - var controllerName = ControllerExtensions.GetControllerName(controllerType); - - // create proxy route data specifying the action & controller to execute - var routeData = new RouteData(new RouteValueDictionary() + [ActionToken] = action, + [ControllerToken] = controllerName + }); + if (!(querystring is null)) + { + foreach ((var key, StringValues value) in querystring) { - [ActionToken] = action, - [ControllerToken] = controllerName - }); - if (!(querystring is null)) - { - foreach (var (key, value) in querystring) - { - routeData.Values[key] = value; - } + routeData.Values[key] = value; } - - var actionDescriptor = _actionDescriptorCollectionProvider.ActionDescriptors.Items - .Cast() - .First(x => - x.ControllerName.Equals(controllerName) && - x.ActionName == action); - - var actionContext = new ActionContext(HttpContext, routeData, actionDescriptor); - var proxyControllerContext = new ControllerContext(actionContext); - var controller = (TreeControllerBase)_controllerFactory.CreateController(proxyControllerContext); - - // TODO: What about other filters? Will they execute? - var isAllowed = await controller.ControllerContext.InvokeAuthorizationFiltersForRequest(actionContext); - if (!isAllowed) - { - return Forbid(); - } - - return controller; } + + ControllerActionDescriptor? actionDescriptor = _actionDescriptorCollectionProvider.ActionDescriptors.Items + .Cast() + .First(x => + x.ControllerName.Equals(controllerName) && + x.ActionName == action); + + var actionContext = new ActionContext(HttpContext, routeData, actionDescriptor); + var proxyControllerContext = new ControllerContext(actionContext); + var controller = (TreeControllerBase)_controllerFactory.CreateController(proxyControllerContext); + + // TODO: What about other filters? Will they execute? + var isAllowed = await controller.ControllerContext.InvokeAuthorizationFiltersForRequest(actionContext); + if (!isAllowed) + { + return Forbid(); + } + + return controller; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentBlueprintTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentBlueprintTreeController.cs index 15f5839f30..db07717b40 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentBlueprintTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentBlueprintTreeController.cs @@ -1,6 +1,4 @@ -using System; using System.Globalization; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -14,148 +12,159 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// The content blueprint tree controller +/// +/// +/// This authorizes based on access to the content section even though it exists in the settings +/// +[Authorize(Policy = AuthorizationPolicies.SectionAccessContent)] +[Tree(Constants.Applications.Settings, Constants.Trees.ContentBlueprints, SortOrder = 12, + TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class ContentBlueprintTreeController : TreeController { - /// - /// The content blueprint tree controller - /// - /// - /// This authorizes based on access to the content section even though it exists in the settings - /// - [Authorize(Policy = AuthorizationPolicies.SectionAccessContent)] - [Tree(Constants.Applications.Settings, Constants.Trees.ContentBlueprints, SortOrder = 12, TreeGroup = Constants.Trees.Groups.Settings)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class ContentBlueprintTreeController : TreeController + private readonly IContentService _contentService; + private readonly IContentTypeService _contentTypeService; + private readonly IEntityService _entityService; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + + public ContentBlueprintTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IContentService contentService, + IContentTypeService contentTypeService, + IEntityService entityService, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) { - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly IContentService _contentService; - private readonly IContentTypeService _contentTypeService; - private readonly IEntityService _entityService; + _menuItemCollectionFactory = menuItemCollectionFactory ?? + throw new ArgumentNullException(nameof(menuItemCollectionFactory)); + _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); + _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); + _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); + } - public ContentBlueprintTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IContentService contentService, - IContentTypeService contentTypeService, - IEntityService entityService, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - _menuItemCollectionFactory = menuItemCollectionFactory ?? throw new ArgumentNullException(nameof(menuItemCollectionFactory)); - _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); - _contentTypeService = contentTypeService ?? throw new ArgumentNullException(nameof(contentTypeService)); - _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); + return rootResult; } - protected override ActionResult CreateRootNode(FormCollection queryStrings) + TreeNode? root = rootResult.Value; + + if (root is not null) { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; + //this will load in a custom UI instead of the dashboard for the root node + root.RoutePath = $"{Constants.Applications.Settings}/{Constants.Trees.ContentBlueprints}/intro"; - if (root is not null) - { - //this will load in a custom UI instead of the dashboard for the root node - root.RoutePath = $"{Constants.Applications.Settings}/{Constants.Trees.ContentBlueprints}/intro"; - - //check if there are any content blueprints - root.HasChildren = _contentService.GetBlueprintsForContentTypes()?.Any() ?? false; - } - - - return root; + //check if there are any content blueprints + root.HasChildren = _contentService.GetBlueprintsForContentTypes()?.Any() ?? false; } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + + + return root; + } + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + var nodes = new TreeNodeCollection(); + + //get all blueprints + IEntitySlim[] entities = _entityService.GetChildren(Constants.System.Root, UmbracoObjectTypes.DocumentBlueprint) + .ToArray(); + + //check if we're rendering the root in which case we'll render the content types that have blueprints + if (id == Constants.System.RootString) { - var nodes = new TreeNodeCollection(); + //get all blueprint content types + IEnumerable contentTypeAliases = + entities.Select(x => ((IContentEntitySlim)x).ContentTypeAlias).Distinct(); + //get the ids + var contentTypeIds = _contentTypeService.GetAllContentTypeIds(contentTypeAliases.ToArray()).ToArray(); - //get all blueprints - var entities = _entityService.GetChildren(Constants.System.Root, UmbracoObjectTypes.DocumentBlueprint).ToArray(); + //now get the entities ... it's a bit round about but still smaller queries than getting all document types + IEntitySlim[] docTypeEntities = contentTypeIds.Length == 0 + ? new IEntitySlim[0] + : _entityService.GetAll(UmbracoObjectTypes.DocumentType, contentTypeIds).ToArray(); - //check if we're rendering the root in which case we'll render the content types that have blueprints - if (id == Constants.System.RootString) - { - //get all blueprint content types - var contentTypeAliases = entities.Select(x => ((IContentEntitySlim)x).ContentTypeAlias).Distinct(); - //get the ids - var contentTypeIds = _contentTypeService.GetAllContentTypeIds(contentTypeAliases.ToArray()).ToArray(); - - //now get the entities ... it's a bit round about but still smaller queries than getting all document types - var docTypeEntities = contentTypeIds.Length == 0 - ? new IEntitySlim[0] - : _entityService.GetAll(UmbracoObjectTypes.DocumentType, contentTypeIds).ToArray(); - - nodes.AddRange(docTypeEntities - .Select(entity => - { - var treeNode = CreateTreeNode(entity, Constants.ObjectTypes.DocumentBlueprint, id, queryStrings, Constants.Icons.ContentType, true); - treeNode.Path = $"-1,{entity.Id}"; - treeNode.NodeType = "document-type-blueprints"; - // TODO: This isn't the best way to ensure a no operation process for clicking a node but it works for now. - treeNode.AdditionalData["jsClickCallback"] = "javascript:void(0);"; - return treeNode; - })); - - return nodes; - } - - if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) - { - return nodes; - } - - //Get the content type - var ct = _contentTypeService.Get(intId); - if (ct == null) return nodes; - - var blueprintsForDocType = entities.Where(x => ct.Alias == ((IContentEntitySlim)x).ContentTypeAlias); - nodes.AddRange(blueprintsForDocType + nodes.AddRange(docTypeEntities .Select(entity => { - var treeNode = CreateTreeNode(entity, Constants.ObjectTypes.DocumentBlueprint, id, queryStrings, Constants.Icons.Blueprint, false); - treeNode.Path = $"-1,{ct.Id},{entity.Id}"; + TreeNode treeNode = CreateTreeNode(entity, Constants.ObjectTypes.DocumentBlueprint, id, + queryStrings, Constants.Icons.ContentType, true); + treeNode.Path = $"-1,{entity.Id}"; + treeNode.NodeType = "document-type-blueprints"; + // TODO: This isn't the best way to ensure a no operation process for clicking a node but it works for now. + treeNode.AdditionalData["jsClickCallback"] = "javascript:void(0);"; return treeNode; })); return nodes; } - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) { - var menu = _menuItemCollectionFactory.Create(); + return nodes; + } - if (id == Constants.System.RootString) + //Get the content type + IContentType? ct = _contentTypeService.Get(intId); + if (ct == null) + { + return nodes; + } + + IEnumerable blueprintsForDocType = + entities.Where(x => ct.Alias == ((IContentEntitySlim)x).ContentTypeAlias); + nodes.AddRange(blueprintsForDocType + .Select(entity => { - // root actions - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - return menu; - } + TreeNode treeNode = CreateTreeNode(entity, Constants.ObjectTypes.DocumentBlueprint, id, queryStrings, + Constants.Icons.Blueprint, false); + treeNode.Path = $"-1,{ct.Id},{entity.Id}"; + return treeNode; + })); - var cte = _entityService.Get(int.Parse(id, CultureInfo.InvariantCulture), UmbracoObjectTypes.DocumentType); - //only refresh & create if it's a content type - if (cte != null) - { - var ct = _contentTypeService.Get(cte.Id); - var createItem = menu.Items.Add(LocalizedTextService, opensDialog: true); - createItem?.NavigateToRoute("/settings/contentBlueprints/edit/-1?create=true&doctype=" + ct?.Alias); + return nodes; + } - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); - return menu; - } + if (id == Constants.System.RootString) + { + // root actions + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + return menu; + } - menu.Items.Add(LocalizedTextService, opensDialog: true); + IEntitySlim? cte = + _entityService.Get(int.Parse(id, CultureInfo.InvariantCulture), UmbracoObjectTypes.DocumentType); + //only refresh & create if it's a content type + if (cte != null) + { + IContentType? ct = _contentTypeService.Get(cte.Id); + MenuItem? createItem = + menu.Items.Add(LocalizedTextService, opensDialog: true); + createItem?.NavigateToRoute("/settings/contentBlueprints/edit/-1?create=true&doctype=" + ct?.Alias); + + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } + + menu.Items.Add(LocalizedTextService, opensDialog: true); + + return menu; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs index 13e8a40e39..ee1fde64f4 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -15,6 +11,7 @@ using Umbraco.Cms.Core.Mail; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.Trees; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -23,354 +20,381 @@ using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.SectionAccessForContentTree)] +[Tree(Constants.Applications.Content, Constants.Trees.Content)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +[SearchableTree("searchResultFormatter", "configureContentResult", 10)] +public class ContentTreeController : ContentTreeControllerBase, ISearchableTree { - [Authorize(Policy = AuthorizationPolicies.SectionAccessForContentTree)] - [Tree(Constants.Applications.Content, Constants.Trees.Content)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - [SearchableTree("searchResultFormatter", "configureContentResult", 10)] - public class ContentTreeController : ContentTreeControllerBase, ISearchableTree + private readonly ActionCollection _actions; + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IContentService _contentService; + private readonly IEmailSender _emailSender; + private readonly IEntityService _entityService; + private readonly ILocalizationService _localizationService; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + private readonly IPublicAccessService _publicAccessService; + private readonly UmbracoTreeSearcher _treeSearcher; + private readonly IUserService _userService; + + private int[]? _userStartNodes; + + public ContentTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IEntityService entityService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILogger logger, + ActionCollection actionCollection, + IUserService userService, + IDataTypeService dataTypeService, + UmbracoTreeSearcher treeSearcher, + ActionCollection actions, + IContentService contentService, + IPublicAccessService publicAccessService, + ILocalizationService localizationService, + IEventAggregator eventAggregator, + IEmailSender emailSender, + AppCaches appCaches) + : base( + localizedTextService, + umbracoApiControllerTypeCollection, + menuItemCollectionFactory, + entityService, + backofficeSecurityAccessor, + logger, + actionCollection, + userService, + dataTypeService, + eventAggregator, + appCaches) { - private readonly UmbracoTreeSearcher _treeSearcher; - private readonly ActionCollection _actions; - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly IContentService _contentService; - private readonly IEntityService _entityService; - private readonly IPublicAccessService _publicAccessService; - private readonly IUserService _userService; - private readonly ILocalizationService _localizationService; - private readonly IEmailSender _emailSender; - private readonly AppCaches _appCaches; + _treeSearcher = treeSearcher; + _actions = actions; + _menuItemCollectionFactory = menuItemCollectionFactory; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _contentService = contentService; + _entityService = entityService; + _publicAccessService = publicAccessService; + _userService = userService; + _localizationService = localizationService; + _emailSender = emailSender; + _appCaches = appCaches; + } - public ContentTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IEntityService entityService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - ILogger logger, - ActionCollection actionCollection, - IUserService userService, - IDataTypeService dataTypeService, - UmbracoTreeSearcher treeSearcher, - ActionCollection actions, - IContentService contentService, - IPublicAccessService publicAccessService, - ILocalizationService localizationService, - IEventAggregator eventAggregator, - IEmailSender emailSender, - AppCaches appCaches) - : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, entityService, backofficeSecurityAccessor, logger, actionCollection, userService, dataTypeService, eventAggregator, appCaches) + protected override int RecycleBinId => Constants.System.RecycleBinContent; + + protected override bool RecycleBinSmells => _contentService.RecycleBinSmells(); + + protected override int[] UserStartNodes + => _userStartNodes ??= + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateContentStartNodeIds(_entityService, _appCaches) ?? Array.Empty(); + + protected override UmbracoObjectTypes UmbracoObjectType => UmbracoObjectTypes.Document; + + public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + { + IEnumerable results = _treeSearcher.ExamineSearch(query, UmbracoEntityTypes.Document, pageSize, pageIndex, out var totalFound, searchFrom); + return new EntitySearchResults(results, totalFound); + } + + + /// + protected override TreeNode? GetSingleTreeNode(IEntitySlim entity, string parentId, FormCollection? queryStrings) + { + var culture = queryStrings?["culture"].ToString(); + + IEnumerable allowedUserOptions = GetAllowedUserMenuItemsForNode(entity); + if (CanUserAccessNode(entity, allowedUserOptions, culture)) { - _treeSearcher = treeSearcher; - _actions = actions; - _menuItemCollectionFactory = menuItemCollectionFactory; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _contentService = contentService; - _entityService = entityService; - _publicAccessService = publicAccessService; - _userService = userService; - _localizationService = localizationService; - _emailSender = emailSender; - _appCaches = appCaches; - } + //Special check to see if it is a container, if so then we'll hide children. + var isContainer = entity.IsContainer; // && (queryStrings.Get("isDialog") != "true"); - protected override int RecycleBinId => Constants.System.RecycleBinContent; + TreeNode node = CreateTreeNode( + entity, + Constants.ObjectTypes.Document, + parentId, + queryStrings, + entity.HasChildren); - protected override bool RecycleBinSmells => _contentService.RecycleBinSmells(); - - private int[]? _userStartNodes; - - protected override int[] UserStartNodes - => _userStartNodes ?? (_userStartNodes = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateContentStartNodeIds(_entityService, _appCaches) ?? Array.Empty()); - - - - /// - protected override TreeNode? GetSingleTreeNode(IEntitySlim entity, string parentId, FormCollection? queryStrings) - { - var culture = queryStrings?["culture"].ToString(); - - var allowedUserOptions = GetAllowedUserMenuItemsForNode(entity); - if (CanUserAccessNode(entity, allowedUserOptions, culture)) + // set container style if it is one + if (isContainer) { - //Special check to see if it is a container, if so then we'll hide children. - var isContainer = entity.IsContainer; // && (queryStrings.Get("isDialog") != "true"); - - var node = CreateTreeNode( - entity, - Constants.ObjectTypes.Document, - parentId, - queryStrings, - entity.HasChildren); - - // set container style if it is one - if (isContainer) - { - node.AdditionalData.Add("isContainer", true); - node.SetContainerStyle(); - } - - var documentEntity = (IDocumentEntitySlim)entity; - - if (!documentEntity.Variations.VariesByCulture()) - { - if (!documentEntity.Published) - node.SetNotPublishedStyle(); - else if (documentEntity.Edited) - node.SetHasPendingVersionStyle(); - } - else - { - if (!culture.IsNullOrWhiteSpace()) - { - if (!documentEntity.Published || !documentEntity.PublishedCultures.Contains(culture)) - node.SetNotPublishedStyle(); - else if (documentEntity.EditedCultures.Contains(culture)) - node.SetHasPendingVersionStyle(); - } - } - - node.AdditionalData.Add("variesByCulture", documentEntity.Variations.VariesByCulture()); - node.AdditionalData.Add("contentType", documentEntity.ContentTypeAlias); - - if (_publicAccessService.IsProtected(entity.Path).Success) - node.SetProtectedStyle(); - - return node; + node.AdditionalData.Add("isContainer", true); + node.SetContainerStyle(); } - return null; - } + var documentEntity = (IDocumentEntitySlim)entity; - protected override ActionResult PerformGetMenuForNode(string id, FormCollection queryStrings) - { - if (id == Constants.System.RootString) + if (!documentEntity.Variations.VariesByCulture()) { - var menu = _menuItemCollectionFactory.Create(); - - // if the user's start node is not the root then the only menu item to display is refresh - if (UserStartNodes.Contains(Constants.System.Root) == false) + if (!documentEntity.Published) { - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - return menu; + node.SetNotPublishedStyle(); } - - //set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - - // we need to get the default permissions as you can't set permissions on the very root node - var permission = _userService.GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, Constants.System.Root).First(); - var nodeActions = _actions.FromEntityPermission(permission) - .Select(x => new MenuItem(x)); - - //these two are the standard items - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(LocalizedTextService, true, opensDialog: true); - - //filter the standard items - FilterUserAllowedMenuItems(menu, nodeActions); - - if (menu.Items.Any()) + else if (documentEntity.Edited) { - menu.Items.Last().SeparatorBefore = true; + node.SetHasPendingVersionStyle(); } - - // add default actions for *all* users - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - return menu; - } - - - //return a normal node menu: - int iid; - if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out iid) == false) - { - return NotFound(); - } - var item = _entityService.Get(iid, UmbracoObjectTypes.Document); - if (item == null) - { - return NotFound(); - } - - //if the user has no path access for this node, all they can do is refresh - if (!_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasContentPathAccess(item, _entityService, _appCaches) ?? false) - { - var menu = _menuItemCollectionFactory.Create(); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - return menu; - } - - var nodeMenu = GetAllNodeMenuItems(item); - - //if the content node is in the recycle bin, don't have a default menu, just show the regular menu - if (item.Path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Contains(RecycleBinId.ToInvariantString())) - { - nodeMenu.DefaultMenuAlias = null; - nodeMenu = GetNodeMenuItemsForDeletedContent(item); } else { - //set the default to create - nodeMenu.DefaultMenuAlias = ActionNew.ActionAlias; - } - - var allowedMenuItems = GetAllowedUserMenuItemsForNode(item); - FilterUserAllowedMenuItems(nodeMenu, allowedMenuItems); - - return nodeMenu; - } - - protected override UmbracoObjectTypes UmbracoObjectType => UmbracoObjectTypes.Document; - - /// - /// Returns true or false if the current user has access to the node based on the user's allowed start node (path) access - /// - /// - /// - /// - protected override bool HasPathAccess(string id, FormCollection queryStrings) - { - var entity = GetEntityFromId(id); - return HasPathAccess(entity, queryStrings); - } - - protected override ActionResult> GetChildEntities(string id, FormCollection queryStrings) - { - var result = base.GetChildEntities(id, queryStrings); - - if (!(result.Result is null)) - { - return result.Result; - } - - var culture = queryStrings["culture"].TryConvertTo(); - - //if this is null we'll set it to the default. - var cultureVal = (culture.Success ? culture.Result : null).IfNullOrWhiteSpace(_localizationService.GetDefaultLanguageIsoCode()); - - // set names according to variations - foreach (var entity in result.Value!) - { - EnsureName(entity, cultureVal); - } - - return result; - } - /// - /// Returns a collection of all menu items that can be on a content node - /// - /// - /// - protected MenuItemCollection GetAllNodeMenuItems(IUmbracoEntity item) - { - var menu = _menuItemCollectionFactory.Create(); - AddActionNode(item, menu, opensDialog: true); - AddActionNode(item, menu, opensDialog: true); - AddActionNode(item, menu, opensDialog: true); - AddActionNode(item, menu, true, opensDialog: true); - AddActionNode(item, menu, opensDialog: true); - AddActionNode(item, menu, true, opensDialog: true); - AddActionNode(item, menu, opensDialog: true); - AddActionNode(item, menu, opensDialog: true); - AddActionNode(item, menu, true, opensDialog: true); - - if (_emailSender.CanSendRequiredEmail()) - { - menu.Items.Add(new MenuItem("notify", LocalizedTextService) + if (!culture.IsNullOrWhiteSpace()) { - Icon = "megaphone", - SeparatorBefore = true, - OpensDialog = true - }); + if (!documentEntity.Published || !documentEntity.PublishedCultures.Contains(culture)) + { + node.SetNotPublishedStyle(); + } + else if (documentEntity.EditedCultures.Contains(culture)) + { + node.SetHasPendingVersionStyle(); + } + } } - if((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false) + node.AdditionalData.Add("variesByCulture", documentEntity.Variations.VariesByCulture()); + node.AdditionalData.Add("contentType", documentEntity.ContentTypeAlias); + + if (_publicAccessService.IsProtected(entity.Path).Success) + { + node.SetProtectedStyle(); + } + + return node; + } + + return null; + } + + protected override ActionResult PerformGetMenuForNode(string id, FormCollection queryStrings) + { + if (id == Constants.System.RootString) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + + // if the user's start node is not the root then the only menu item to display is refresh + if (UserStartNodes.Contains(Constants.System.Root) == false) { menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + return menu; } - return menu; - } + //set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; - /// - /// Returns a collection of all menu items that can be on a deleted (in recycle bin) content node - /// - /// - /// - protected MenuItemCollection GetNodeMenuItemsForDeletedContent(IUmbracoEntity item) - { - var menu = _menuItemCollectionFactory.Create(); - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(LocalizedTextService, opensDialog: true); + // we need to get the default permissions as you can't set permissions on the very root node + EntityPermission permission = _userService + .GetPermissions(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, Constants.System.Root) + .First(); + IEnumerable nodeActions = _actions.FromEntityPermission(permission) + .Select(x => new MenuItem(x)); + //these two are the standard items + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(LocalizedTextService, true, true); + + //filter the standard items + FilterUserAllowedMenuItems(menu, nodeActions); + + if (menu.Items.Any()) + { + menu.Items.Last().SeparatorBefore = true; + } + + // add default actions for *all* users menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } - /// - /// set name according to variations - /// - /// - /// - private void EnsureName(IEntitySlim entity, string? culture) + + //return a normal node menu: + if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out int iid) == false) { - if (culture == null) + return NotFound(); + } + + IEntitySlim? item = _entityService.Get(iid, UmbracoObjectTypes.Document); + if (item == null) + { + return NotFound(); + } + + //if the user has no path access for this node, all they can do is refresh + if (!_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasContentPathAccess(item, _entityService, _appCaches) ?? false) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + return menu; + } + + MenuItemCollection nodeMenu = GetAllNodeMenuItems(item); + + //if the content node is in the recycle bin, don't have a default menu, just show the regular menu + if (item.Path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries) + .Contains(RecycleBinId.ToInvariantString())) + { + nodeMenu.DefaultMenuAlias = null; + nodeMenu = GetNodeMenuItemsForDeletedContent(item); + } + else + { + //set the default to create + nodeMenu.DefaultMenuAlias = ActionNew.ActionAlias; + } + + IEnumerable allowedMenuItems = GetAllowedUserMenuItemsForNode(item); + FilterUserAllowedMenuItems(nodeMenu, allowedMenuItems); + + return nodeMenu; + } + + /// + /// Returns true or false if the current user has access to the node based on the user's allowed start node (path) + /// access + /// + /// + /// + /// + protected override bool HasPathAccess(string id, FormCollection queryStrings) + { + IEntitySlim? entity = GetEntityFromId(id); + return HasPathAccess(entity, queryStrings); + } + + protected override ActionResult> GetChildEntities(string id, FormCollection queryStrings) + { + ActionResult> result = base.GetChildEntities(id, queryStrings); + + if (!(result.Result is null)) + { + return result.Result; + } + + Attempt culture = queryStrings["culture"].TryConvertTo(); + + //if this is null we'll set it to the default. + var cultureVal = + (culture.Success ? culture.Result : null).IfNullOrWhiteSpace( + _localizationService.GetDefaultLanguageIsoCode()); + + // set names according to variations + foreach (IEntitySlim entity in result.Value!) + { + EnsureName(entity, cultureVal); + } + + return result; + } + + /// + /// Returns a collection of all menu items that can be on a content node + /// + /// + /// + protected MenuItemCollection GetAllNodeMenuItems(IUmbracoEntity item) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + AddActionNode(item, menu, opensDialog: true); + AddActionNode(item, menu, opensDialog: true); + AddActionNode(item, menu, opensDialog: true); + AddActionNode(item, menu, true, true); + AddActionNode(item, menu, opensDialog: true); + AddActionNode(item, menu, true, true); + AddActionNode(item, menu, opensDialog: true); + AddActionNode(item, menu, opensDialog: true); + AddActionNode(item, menu, true, true); + + if (_emailSender.CanSendRequiredEmail()) + { + menu.Items.Add(new MenuItem("notify", LocalizedTextService) { - if (string.IsNullOrWhiteSpace(entity.Name)) - { - entity.Name = "[[" + entity.Id + "]]"; - } + Icon = "megaphone", + SeparatorBefore = true, + OpensDialog = true + }); + } - return; - } + if ((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false) + { + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + } - if (!(entity is IDocumentEntitySlim docEntity)) - { - throw new InvalidOperationException($"Cannot render a tree node for a culture when the entity isn't {typeof(IDocumentEntitySlim)}, instead it is {entity.GetType()}"); - } + return menu; + } - // we are getting the tree for a given culture, - // for those items that DO support cultures, we need to get the proper name, IF it exists - // otherwise, invariant is fine (with brackets) + /// + /// Returns a collection of all menu items that can be on a deleted (in recycle bin) content node + /// + /// + /// + protected MenuItemCollection GetNodeMenuItemsForDeletedContent(IUmbracoEntity item) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(LocalizedTextService, opensDialog: true); - if (docEntity.Variations.VariesByCulture()) - { - if (docEntity.CultureNames.TryGetValue(culture, out var name) && - !string.IsNullOrWhiteSpace(name)) - { - entity.Name = name; - } - else - { - entity.Name = "(" + entity.Name + ")"; - } - } + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + return menu; + } + + /// + /// set name according to variations + /// + /// + /// + private void EnsureName(IEntitySlim entity, string? culture) + { + if (culture == null) + { if (string.IsNullOrWhiteSpace(entity.Name)) { entity.Name = "[[" + entity.Id + "]]"; } + + return; } - private void AddActionNode(IUmbracoEntity item, MenuItemCollection menu, bool hasSeparator = false, bool opensDialog = false) - where TAction : IAction + if (!(entity is IDocumentEntitySlim docEntity)) { - var menuItem = menu.Items.Add(LocalizedTextService, hasSeparator, opensDialog); + throw new InvalidOperationException( + $"Cannot render a tree node for a culture when the entity isn't {typeof(IDocumentEntitySlim)}, instead it is {entity.GetType()}"); } - public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + // we are getting the tree for a given culture, + // for those items that DO support cultures, we need to get the proper name, IF it exists + // otherwise, invariant is fine (with brackets) + + if (docEntity.Variations.VariesByCulture()) { - var results = _treeSearcher.ExamineSearch(query, UmbracoEntityTypes.Document, pageSize, pageIndex, out long totalFound, searchFrom); - return new EntitySearchResults(results, totalFound); + if (docEntity.CultureNames.TryGetValue(culture, out var name) && + !string.IsNullOrWhiteSpace(name)) + { + entity.Name = name; + } + else + { + entity.Name = "(" + entity.Name + ")"; + } + } + + if (string.IsNullOrWhiteSpace(entity.Name)) + { + entity.Name = "[[" + entity.Id + "]]"; } } + + private void AddActionNode(IUmbracoEntity item, MenuItemCollection menu, bool hasSeparator = false, bool opensDialog = false) + where TAction : IAction + { + MenuItem? menuItem = menu.Items.Add(LocalizedTextService, hasSeparator, opensDialog); + } } diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTreeControllerBase.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTreeControllerBase.cs index f0e62f8a66..1e0d4725a4 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTreeControllerBase.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTreeControllerBase.cs @@ -1,8 +1,5 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -18,608 +15,657 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.ModelBinders; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +public abstract class ContentTreeControllerBase : TreeController, ITreeNodeController { - public abstract class ContentTreeControllerBase : TreeController, ITreeNodeController + private static readonly char[] Comma = { ',' }; + private readonly ActionCollection _actionCollection; + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IDataTypeService _dataTypeService; + + private readonly ConcurrentDictionary _entityCache = new(); + private readonly IEntityService _entityService; + private readonly ILogger _logger; + private readonly IUserService _userService; + + private bool? _ignoreUserStartNodes; + + + protected ContentTreeControllerBase( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IEntityService entityService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILogger logger, + ActionCollection actionCollection, + IUserService userService, + IDataTypeService dataTypeService, + IEventAggregator eventAggregator, + AppCaches appCaches) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) { - private readonly IEntityService _entityService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; - private readonly ILogger _logger; - private readonly ActionCollection _actionCollection; - private readonly IUserService _userService; - private readonly IDataTypeService _dataTypeService; - private readonly AppCaches _appCaches; - public IMenuItemCollectionFactory MenuItemCollectionFactory { get; } + _entityService = entityService; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _logger = logger; + _actionCollection = actionCollection; + _userService = userService; + _dataTypeService = dataTypeService; + _appCaches = appCaches; + MenuItemCollectionFactory = menuItemCollectionFactory; + } + + public IMenuItemCollectionFactory MenuItemCollectionFactory { get; } + + /// + /// Returns the + /// + protected abstract int RecycleBinId { get; } + + /// + /// Returns true if the recycle bin has items in it + /// + protected abstract bool RecycleBinSmells { get; } + + /// + /// Returns the user's start node for this tree + /// + protected abstract int[] UserStartNodes { get; } + + protected abstract UmbracoObjectTypes UmbracoObjectType { get; } - protected ContentTreeControllerBase( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IEntityService entityService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - ILogger logger, - ActionCollection actionCollection, - IUserService userService, - IDataTypeService dataTypeService, - IEventAggregator eventAggregator, - AppCaches appCaches - ) - : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + #region Actions + + /// + /// Gets an individual tree node + /// + /// + /// + /// + public ActionResult GetTreeNode([FromRoute] string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection? queryStrings) + { + Guid asGuid = Guid.Empty; + if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out int asInt) == false) { - _entityService = entityService; - _backofficeSecurityAccessor = backofficeSecurityAccessor; - _logger = logger; - _actionCollection = actionCollection; - _userService = userService; - _dataTypeService = dataTypeService; - _appCaches = appCaches; - MenuItemCollectionFactory = menuItemCollectionFactory; + if (Guid.TryParse(id, out asGuid) == false) + { + return NotFound(); + } } - - #region Actions - - /// - /// Gets an individual tree node - /// - /// - /// - /// - public ActionResult GetTreeNode([FromRoute] string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection? queryStrings) + IEntitySlim? entity = asGuid == Guid.Empty + ? _entityService.Get(asInt, UmbracoObjectType) + : _entityService.Get(asGuid, UmbracoObjectType); + if (entity == null) { - int asInt; - Guid asGuid = Guid.Empty; - if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out asInt) == false) + return NotFound(); + } + + TreeNode? node = GetSingleTreeNode(entity, entity.ParentId.ToInvariantString(), queryStrings); + + if (node is not null) + { + // Add the tree alias to the node since it is standalone (has no root for which this normally belongs) + node.AdditionalData["treeAlias"] = TreeAlias; + } + + return node; + } + + #endregion + + /// + /// Ensure the noAccess metadata is applied for the root node if in dialog mode and the user doesn't have path access + /// to it + /// + /// + /// + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult nodeResult = base.CreateRootNode(queryStrings); + if (nodeResult.Result is null) + { + return nodeResult; + } + + TreeNode? node = nodeResult.Value; + + if (node is not null && IsDialog(queryStrings) && UserStartNodes.Contains(Constants.System.Root) == false && + IgnoreUserStartNodes(queryStrings) == false) + { + node.AdditionalData["noAccess"] = true; + } + + return node; + } + + protected abstract TreeNode? GetSingleTreeNode(IEntitySlim entity, string parentId, FormCollection? queryStrings); + + /// + /// Returns a for the and + /// attaches some meta data to the node if the user doesn't have start node access to it when in dialog mode + /// + /// + /// + /// + /// + /// + /// + internal TreeNode? GetSingleTreeNodeWithAccessCheck(IEntitySlim e, string parentId, FormCollection queryStrings, int[]? startNodeIds, string[]? startNodePaths) + { + var entityIsAncestorOfStartNodes = + ContentPermissions.IsInBranchOfStartNode(e.Path, startNodeIds, startNodePaths, out var hasPathAccess); + var ignoreUserStartNodes = IgnoreUserStartNodes(queryStrings); + if (ignoreUserStartNodes == false && entityIsAncestorOfStartNodes == false) + { + return null; + } + + TreeNode? treeNode = GetSingleTreeNode(e, parentId, queryStrings); + if (treeNode == null) + { + //this means that the user has NO access to this node via permissions! They at least need to have browse permissions to see + //the node so we need to return null; + return null; + } + + if (!ignoreUserStartNodes && !hasPathAccess) + { + treeNode.AdditionalData["noAccess"] = true; + } + + return treeNode; + } + + private void GetUserStartNodes(out int[]? startNodeIds, out string[]? startNodePaths) + { + switch (RecycleBinId) + { + case Constants.System.RecycleBinMedia: + startNodeIds = + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds( + _entityService, _appCaches); + startNodePaths = + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetMediaStartNodePaths(_entityService, _appCaches); + break; + case Constants.System.RecycleBinContent: + startNodeIds = + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateContentStartNodeIds( + _entityService, _appCaches); + startNodePaths = + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetContentStartNodePaths( + _entityService, _appCaches); + break; + default: + throw new NotSupportedException("Path access is only determined on content or media"); + } + } + + protected virtual ActionResult PerformGetTreeNodes(string id, FormCollection queryStrings) + { + var nodes = new TreeNodeCollection(); + + var rootIdString = Constants.System.RootString; + var hasAccessToRoot = UserStartNodes.Contains(Constants.System.Root); + + var startNodeId = queryStrings.HasKey(TreeQueryStringParameters.StartNodeId) + ? queryStrings.GetValue(TreeQueryStringParameters.StartNodeId) + : string.Empty; + + var ignoreUserStartNodes = IgnoreUserStartNodes(queryStrings); + + if (string.IsNullOrEmpty(startNodeId) == false && startNodeId != "undefined" && startNodeId != rootIdString) + { + // request has been made to render from a specific, non-root, start node + id = startNodeId; + + // ensure that the user has access to that node, otherwise return the empty tree nodes collection + // TODO: in the future we could return a validation statement so we can have some UI to notify the user they don't have access + if (ignoreUserStartNodes == false && HasPathAccess(id, queryStrings) == false) { - if (Guid.TryParse(id, out asGuid) == false) - { - return NotFound(); - } + _logger.LogWarning( + "User {Username} does not have access to node with id {Id}", + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Username, + id); + return nodes; } - var entity = asGuid == Guid.Empty - ? _entityService.Get(asInt, UmbracoObjectType) - : _entityService.Get(asGuid, UmbracoObjectType); + // if the tree is rendered... + // - in a dialog: render only the children of the specific start node, nothing to do + // - in a section: if the current user's start nodes do not contain the root node, we need + // to include these start nodes in the tree too, to provide some context - i.e. change + // start node back to root node, and then GetChildEntities method will take care of the rest. + if (IsDialog(queryStrings) == false && hasAccessToRoot == false) + { + id = rootIdString; + } + } + + // get child entities - if id is root, but user's start nodes do not contain the + // root node, this returns the start nodes instead of root's children + ActionResult> entitiesResult = GetChildEntities(id, queryStrings); + if (!(entitiesResult.Result is null)) + { + return entitiesResult.Result; + } + + var entities = entitiesResult.Value?.ToList(); + + //get the current user start node/paths + GetUserStartNodes(out var userStartNodes, out var userStartNodePaths); + + // if the user does not have access to the root node, what we have is the start nodes, + // but to provide some context we need to add their topmost nodes when they are not + // topmost nodes themselves (level > 1). + if (id == rootIdString && hasAccessToRoot == false) + { + // first add the entities that are topmost to the nodes collection + IEntitySlim[]? topMostEntities = entities?.Where(x => x.Level == 1).ToArray(); + nodes.AddRange( + topMostEntities?.Select(x => + GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)) + .WhereNotNull() ?? Enumerable.Empty()); + + // now add the topmost nodes of the entities that aren't topmost to the nodes collection as well + // - these will appear as "no-access" nodes in the tree, but will allow the editors to drill down through the tree + // until they reach their start nodes + var topNodeIds = entities?.Except(topMostEntities ?? Enumerable.Empty()).Select(GetTopNodeId) + .Where(x => x != 0).Distinct().ToArray(); + if (topNodeIds?.Length > 0) + { + IEnumerable topNodes = _entityService.GetAll(UmbracoObjectType, topNodeIds.ToArray()); + nodes.AddRange(topNodes.Select(x => + GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)) + .WhereNotNull()); + } + } + else + { + // the user has access to the root, just add the entities + nodes.AddRange( + entities?.Select(x => + GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)) + .WhereNotNull() ?? Enumerable.Empty()); + } + + return nodes; + } + + private int GetTopNodeId(IUmbracoEntity entity) + { + var parts = entity.Path.Split(Comma, StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id) + ? id + : 0; + } + + protected abstract ActionResult PerformGetMenuForNode(string id, FormCollection queryStrings); + + protected virtual ActionResult> GetChildEntities(string id, FormCollection queryStrings) + { + // try to parse id as an integer else use GetEntityFromId + // which will grok Guids, Udis, etc and let use obtain the id + if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var entityId)) + { + IEntitySlim? entity = GetEntityFromId(id); if (entity == null) { return NotFound(); } - var node = GetSingleTreeNode(entity, entity.ParentId.ToInvariantString(), queryStrings); - - if (node is not null) - { - // Add the tree alias to the node since it is standalone (has no root for which this normally belongs) - node.AdditionalData["treeAlias"] = TreeAlias; - } - - return node; + entityId = entity.Id; } - #endregion + var ignoreUserStartNodes = IgnoreUserStartNodes(queryStrings); - /// - /// Ensure the noAccess metadata is applied for the root node if in dialog mode and the user doesn't have path access to it - /// - /// - /// - protected override ActionResult CreateRootNode(FormCollection queryStrings) + IEntitySlim[] result; + + // if a request is made for the root node but user has no access to + // root node, return start nodes instead + if (!ignoreUserStartNodes && entityId == Constants.System.Root && + UserStartNodes.Contains(Constants.System.Root) == false) { - var nodeResult = base.CreateRootNode(queryStrings); - if ((nodeResult.Result is null)) - { - return nodeResult; - } - var node = nodeResult.Value; - - if (node is not null && IsDialog(queryStrings) && UserStartNodes.Contains(Constants.System.Root) == false && IgnoreUserStartNodes(queryStrings) == false) - { - node.AdditionalData["noAccess"] = true; - } - - return node; + result = UserStartNodes.Length > 0 + ? _entityService.GetAll(UmbracoObjectType, UserStartNodes).ToArray() + : Array.Empty(); + } + else + { + result = GetChildrenFromEntityService(entityId).ToArray(); } - protected abstract TreeNode? GetSingleTreeNode(IEntitySlim entity, string parentId, FormCollection? queryStrings); + return result; + } - /// - /// Returns a for the and - /// attaches some meta data to the node if the user doesn't have start node access to it when in dialog mode - /// - /// - /// - /// - /// - internal TreeNode? GetSingleTreeNodeWithAccessCheck(IEntitySlim e, string parentId, FormCollection queryStrings, - int[]? startNodeIds, string[]? startNodePaths) + private IEnumerable GetChildrenFromEntityService(int entityId) + => _entityService.GetChildren(entityId, UmbracoObjectType).ToList(); + + /// + /// Returns true or false if the current user has access to the node based on the user's allowed start node (path) + /// access + /// + /// + /// + /// + //we should remove this in v8, it's now here for backwards compat only + protected abstract bool HasPathAccess(string id, FormCollection queryStrings); + + /// + /// Returns true or false if the current user has access to the node based on the user's allowed start node (path) + /// access + /// + /// + /// + /// + protected bool HasPathAccess(IUmbracoEntity? entity, FormCollection queryStrings) + { + if (entity == null) { - var entityIsAncestorOfStartNodes = ContentPermissions.IsInBranchOfStartNode(e.Path, startNodeIds, startNodePaths, out var hasPathAccess); - var ignoreUserStartNodes = IgnoreUserStartNodes(queryStrings); - if (ignoreUserStartNodes == false && entityIsAncestorOfStartNodes == false) - return null; - - var treeNode = GetSingleTreeNode(e, parentId, queryStrings); - if (treeNode == null) - { - //this means that the user has NO access to this node via permissions! They at least need to have browse permissions to see - //the node so we need to return null; - return null; - } - if (!ignoreUserStartNodes && !hasPathAccess) - { - treeNode.AdditionalData["noAccess"] = true; - } - return treeNode; + return false; } - private void GetUserStartNodes(out int[]? startNodeIds, out string[]? startNodePaths) + return RecycleBinId == Constants.System.RecycleBinContent + ? _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasContentPathAccess(entity, _entityService, _appCaches) ?? false + : _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasMediaPathAccess(entity, _entityService, _appCaches) ?? false; + } + + /// + /// Ensures the recycle bin is appended when required (i.e. user has access to the root and it's not in dialog mode) + /// + /// + /// + /// + /// + /// This method is overwritten strictly to render the recycle bin, it should serve no other purpose + /// + protected sealed override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + //check if we're rendering the root + if (id == Constants.System.RootString && UserStartNodes.Contains(Constants.System.Root)) { - switch (RecycleBinId) + var altStartId = string.Empty; + + if (queryStrings.HasKey(TreeQueryStringParameters.StartNodeId)) { - case Constants.System.RecycleBinMedia: - startNodeIds = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds(_entityService, _appCaches); - startNodePaths = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetMediaStartNodePaths(_entityService, _appCaches); - break; - case Constants.System.RecycleBinContent: - startNodeIds = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateContentStartNodeIds(_entityService, _appCaches); - startNodePaths = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetContentStartNodePaths(_entityService, _appCaches); - break; - default: - throw new NotSupportedException("Path access is only determined on content or media"); + altStartId = queryStrings.GetValue(TreeQueryStringParameters.StartNodeId); } + + //check if a request has been made to render from a specific start node + if (string.IsNullOrEmpty(altStartId) == false && altStartId != "undefined" && + altStartId != Constants.System.RootString) + { + id = altStartId; + } + + ActionResult nodesResult = GetTreeNodesInternal(id, queryStrings); + if (!(nodesResult.Result is null)) + { + return nodesResult.Result; + } + + TreeNodeCollection? nodes = nodesResult.Value; + + //only render the recycle bin if we are not in dialog and the start id is still the root + //we need to check for the "application" key in the queryString because its value is required here, + //and for some reason when there are no dashboards, this parameter is missing + if (IsDialog(queryStrings) == false && id == Constants.System.RootString && + queryStrings.HasKey("application")) + { + nodes?.Add(CreateTreeNode( + RecycleBinId.ToInvariantString(), + id, + queryStrings, + LocalizedTextService.Localize("general", "recycleBin"), + "icon-trash", + RecycleBinSmells, + queryStrings.GetRequiredValue("application") + TreeAlias.EnsureStartsWith('/') + + "/recyclebin")); + } + + return nodes ?? new TreeNodeCollection(); } - /// - /// Returns the - /// - protected abstract int RecycleBinId { get; } + return GetTreeNodesInternal(id, queryStrings); + } - /// - /// Returns true if the recycle bin has items in it - /// - protected abstract bool RecycleBinSmells { get; } + /// + /// Check to see if we should return children of a container node + /// + /// + /// + /// + /// This is required in case a user has custom start nodes that are children of a list view since in that case we'll + /// need to render the tree node. In normal cases we don't render + /// children of a list view. + /// + protected bool ShouldRenderChildrenOfContainer(IEntitySlim e) + { + var isContainer = e.IsContainer; - /// - /// Returns the user's start node for this tree - /// - protected abstract int[] UserStartNodes { get; } + var renderChildren = e.HasChildren && isContainer == false; - protected virtual ActionResult PerformGetTreeNodes(string id, FormCollection queryStrings) + //Here we need to figure out if the node is a container and if so check if the user has a custom start node, then check if that start node is a child + // of this container node. If that is true, the HasChildren must be true so that the tree node still renders even though this current node is a container/list view. + if (isContainer && UserStartNodes.Length > 0 && UserStartNodes.Contains(Constants.System.Root) == false) { - var nodes = new TreeNodeCollection(); + IEnumerable startNodes = _entityService.GetAll(UmbracoObjectType, UserStartNodes); + //if any of these start nodes' parent is current, then we need to render children normally so we need to switch some logic and tell + // the UI that this node does have children and that it isn't a container - var rootIdString = Constants.System.RootString; - var hasAccessToRoot = UserStartNodes.Contains(Constants.System.Root); - - var startNodeId = queryStrings.HasKey(TreeQueryStringParameters.StartNodeId) - ? queryStrings.GetValue(TreeQueryStringParameters.StartNodeId) - : string.Empty; - - var ignoreUserStartNodes = IgnoreUserStartNodes(queryStrings); - - if (string.IsNullOrEmpty(startNodeId) == false && startNodeId != "undefined" && startNodeId != rootIdString) - { - // request has been made to render from a specific, non-root, start node - id = startNodeId; - - // ensure that the user has access to that node, otherwise return the empty tree nodes collection - // TODO: in the future we could return a validation statement so we can have some UI to notify the user they don't have access - if (ignoreUserStartNodes == false && HasPathAccess(id, queryStrings) == false) - { - _logger.LogWarning("User {Username} does not have access to node with id {Id}", _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Username, id); - return nodes; - } - - // if the tree is rendered... - // - in a dialog: render only the children of the specific start node, nothing to do - // - in a section: if the current user's start nodes do not contain the root node, we need - // to include these start nodes in the tree too, to provide some context - i.e. change - // start node back to root node, and then GetChildEntities method will take care of the rest. - if (IsDialog(queryStrings) == false && hasAccessToRoot == false) - id = rootIdString; - } - - // get child entities - if id is root, but user's start nodes do not contain the - // root node, this returns the start nodes instead of root's children - var entitiesResult = GetChildEntities(id, queryStrings); - if (!(entitiesResult.Result is null)) - { - return entitiesResult.Result; - } - - var entities = entitiesResult.Value?.ToList(); - - //get the current user start node/paths - GetUserStartNodes(out var userStartNodes, out var userStartNodePaths); - - // if the user does not have access to the root node, what we have is the start nodes, - // but to provide some context we need to add their topmost nodes when they are not - // topmost nodes themselves (level > 1). - if (id == rootIdString && hasAccessToRoot == false) - { - // first add the entities that are topmost to the nodes collection - var topMostEntities = entities?.Where(x => x.Level == 1).ToArray(); - nodes.AddRange(topMostEntities?.Select(x => GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)).WhereNotNull() ?? Enumerable.Empty()); - - // now add the topmost nodes of the entities that aren't topmost to the nodes collection as well - // - these will appear as "no-access" nodes in the tree, but will allow the editors to drill down through the tree - // until they reach their start nodes - var topNodeIds = entities?.Except(topMostEntities ?? Enumerable.Empty()).Select(GetTopNodeId).Where(x => x != 0).Distinct().ToArray(); - if (topNodeIds?.Length > 0) - { - var topNodes = _entityService.GetAll(UmbracoObjectType, topNodeIds.ToArray()); - nodes.AddRange(topNodes.Select(x => GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)).WhereNotNull()); - } - } - else - { - // the user has access to the root, just add the entities - nodes.AddRange(entities?.Select(x => GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)).WhereNotNull() ?? Enumerable.Empty()); - } - - return nodes; - } - - private static readonly char[] Comma = { ',' }; - - private int GetTopNodeId(IUmbracoEntity entity) - { - int id; - var parts = entity.Path.Split(Comma, StringSplitOptions.RemoveEmptyEntries); - return parts.Length >= 2 && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out id) ? id : 0; - } - - protected abstract ActionResult PerformGetMenuForNode(string id, FormCollection queryStrings); - - protected abstract UmbracoObjectTypes UmbracoObjectType { get; } - - protected virtual ActionResult> GetChildEntities(string id, FormCollection queryStrings) - { - // try to parse id as an integer else use GetEntityFromId - // which will grok Guids, Udis, etc and let use obtain the id - if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var entityId)) - { - var entity = GetEntityFromId(id); - if (entity == null) - return NotFound(); - - entityId = entity.Id; - } - - var ignoreUserStartNodes = IgnoreUserStartNodes(queryStrings); - - IEntitySlim[] result; - - // if a request is made for the root node but user has no access to - // root node, return start nodes instead - if (!ignoreUserStartNodes && entityId == Constants.System.Root && UserStartNodes.Contains(Constants.System.Root) == false) - { - result = UserStartNodes.Length > 0 - ? _entityService.GetAll(UmbracoObjectType, UserStartNodes).ToArray() - : Array.Empty(); - } - else - { - result = GetChildrenFromEntityService(entityId).ToArray(); - } - - return result; - } - - private IEnumerable GetChildrenFromEntityService(int entityId) - => _entityService.GetChildren(entityId, UmbracoObjectType).ToList(); - - /// - /// Returns true or false if the current user has access to the node based on the user's allowed start node (path) access - /// - /// - /// - /// - //we should remove this in v8, it's now here for backwards compat only - protected abstract bool HasPathAccess(string id, FormCollection queryStrings); - - /// - /// Returns true or false if the current user has access to the node based on the user's allowed start node (path) access - /// - /// - /// - /// - protected bool HasPathAccess(IUmbracoEntity? entity, FormCollection queryStrings) - { - if (entity == null) - return false; - return RecycleBinId == Constants.System.RecycleBinContent - ? _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasContentPathAccess(entity, _entityService, _appCaches) ?? false - : _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasMediaPathAccess(entity, _entityService, _appCaches) ?? false; - } - - /// - /// Ensures the recycle bin is appended when required (i.e. user has access to the root and it's not in dialog mode) - /// - /// - /// - /// - /// - /// This method is overwritten strictly to render the recycle bin, it should serve no other purpose - /// - protected sealed override ActionResult GetTreeNodes(string id, FormCollection queryStrings) - { - //check if we're rendering the root - if (id == Constants.System.RootString && UserStartNodes.Contains(Constants.System.Root)) - { - var altStartId = string.Empty; - - if (queryStrings.HasKey(TreeQueryStringParameters.StartNodeId)) - altStartId = queryStrings.GetValue(TreeQueryStringParameters.StartNodeId); - - //check if a request has been made to render from a specific start node - if (string.IsNullOrEmpty(altStartId) == false && altStartId != "undefined" && altStartId != Constants.System.RootString) - { - id = altStartId; - } - - var nodesResult = GetTreeNodesInternal(id, queryStrings); - if (!(nodesResult.Result is null)) - { - return nodesResult.Result; - } - - var nodes = nodesResult.Value; - - //only render the recycle bin if we are not in dialog and the start id is still the root - //we need to check for the "application" key in the queryString because its value is required here, - //and for some reason when there are no dashboards, this parameter is missing - if (IsDialog(queryStrings) == false && id == Constants.System.RootString && queryStrings.HasKey("application")) - { - nodes?.Add(CreateTreeNode( - RecycleBinId.ToInvariantString(), - id, - queryStrings, - LocalizedTextService.Localize("general", "recycleBin"), - "icon-trash", - RecycleBinSmells, - queryStrings.GetRequiredValue("application") + TreeAlias.EnsureStartsWith('/') + "/recyclebin")); - } - - return nodes ?? new TreeNodeCollection(); - } - - return GetTreeNodesInternal(id, queryStrings); - } - - /// - /// Check to see if we should return children of a container node - /// - /// - /// - /// - /// This is required in case a user has custom start nodes that are children of a list view since in that case we'll need to render the tree node. In normal cases we don't render - /// children of a list view. - /// - protected bool ShouldRenderChildrenOfContainer(IEntitySlim e) - { - var isContainer = e.IsContainer; - - var renderChildren = e.HasChildren && (isContainer == false); - - //Here we need to figure out if the node is a container and if so check if the user has a custom start node, then check if that start node is a child - // of this container node. If that is true, the HasChildren must be true so that the tree node still renders even though this current node is a container/list view. - if (isContainer && UserStartNodes.Length > 0 && UserStartNodes.Contains(Constants.System.Root) == false) - { - var startNodes = _entityService.GetAll(UmbracoObjectType, UserStartNodes); - //if any of these start nodes' parent is current, then we need to render children normally so we need to switch some logic and tell - // the UI that this node does have children and that it isn't a container - - if (startNodes.Any(x => + if (startNodes.Any(x => { var pathParts = x.Path.Split(Constants.CharArrays.Comma); return pathParts.Contains(e.Id.ToInvariantString()); })) - { - renderChildren = true; - } - } - - return renderChildren; - } - - /// - /// Before we make a call to get the tree nodes we have to check if they can actually be rendered - /// - /// - /// - /// - /// - /// Currently this just checks if it is a container type, if it is we cannot render children. In the future this might check for other things. - /// - private ActionResult GetTreeNodesInternal(string id, FormCollection queryStrings) - { - var current = GetEntityFromId(id); - - //before we get the children we need to see if this is a container node - - //test if the parent is a listview / container - if (current != null && ShouldRenderChildrenOfContainer(current) == false) { - //no children! - return new TreeNodeCollection(); - } - - return PerformGetTreeNodes(id, queryStrings); - } - - /// - /// Checks if the menu requested is for the recycle bin and renders that, otherwise renders the result of PerformGetMenuForNode - /// - /// - /// - /// - protected sealed override ActionResult GetMenuForNode(string id, FormCollection queryStrings) - { - if (RecycleBinId.ToInvariantString() == id) - { - // get the default assigned permissions for this user - var deleteAllowed = false; - var deleteAction = _actionCollection.FirstOrDefault(y => y.Letter == ActionDelete.ActionLetter); - if (deleteAction != null) - { - var perms = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetPermissions(Constants.System.RecycleBinContentString, _userService); - deleteAllowed = perms?.FirstOrDefault(x => x.Contains(deleteAction.Letter)) != null; - } - - var menu = MenuItemCollectionFactory.Create(); - // only add empty recycle bin if the current user is allowed to delete by default - if (deleteAllowed) - { - menu.Items.Add(new MenuItem("emptyrecyclebin", LocalizedTextService) - { - Icon = "trash", - OpensDialog = true - }); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - } - return menu; - } - - return PerformGetMenuForNode(id, queryStrings); - } - - /// - /// Based on the allowed actions, this will filter the ones that the current user is allowed - /// - /// - /// - /// - protected void FilterUserAllowedMenuItems(MenuItemCollection menuWithAllItems, IEnumerable userAllowedMenuItems) - { - var userAllowedActions = userAllowedMenuItems.Where(x => x.Action != null).Select(x => x.Action).ToArray(); - - var notAllowed = menuWithAllItems.Items.Where( - a => (a.Action != null - && a.Action.CanBePermissionAssigned - && (a.Action.CanBePermissionAssigned == false || userAllowedActions.Contains(a.Action) == false))) - .ToArray(); - - //remove the ones that aren't allowed. - foreach (var m in notAllowed) - { - menuWithAllItems.Items.Remove(m); - // if the disallowed action is set as default action, make sure to reset the default action as well - if (menuWithAllItems.DefaultMenuAlias == m.Alias) - { - menuWithAllItems.DefaultMenuAlias = null; - } + renderChildren = true; } } - internal IEnumerable GetAllowedUserMenuItemsForNode(IUmbracoEntity dd) + return renderChildren; + } + + /// + /// Before we make a call to get the tree nodes we have to check if they can actually be rendered + /// + /// + /// + /// + /// + /// Currently this just checks if it is a container type, if it is we cannot render children. In the future this might + /// check for other things. + /// + private ActionResult GetTreeNodesInternal(string id, FormCollection queryStrings) + { + IEntitySlim? current = GetEntityFromId(id); + + //before we get the children we need to see if this is a container node + + //test if the parent is a listview / container + if (current != null && ShouldRenderChildrenOfContainer(current) == false) { - var permissionsForPath = _userService.GetPermissionsForPath(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, dd.Path).GetAllPermissions(); - return _actionCollection.GetByLetters(permissionsForPath).Select(x => new MenuItem(x)); + //no children! + return new TreeNodeCollection(); } - /// - /// Determines if the user has access to view the node/document - /// - /// The Document to check permissions against - /// A list of MenuItems that the user has permissions to execute on the current document - /// By default the user must have Browse permissions to see the node in the Content tree - /// - internal bool CanUserAccessNode(IUmbracoEntity doc, IEnumerable allowedUserOptions, string? culture) + return PerformGetTreeNodes(id, queryStrings); + } + + /// + /// Checks if the menu requested is for the recycle bin and renders that, otherwise renders the result of + /// PerformGetMenuForNode + /// + /// + /// + /// + protected sealed override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + if (RecycleBinId.ToInvariantString() == id) { - // TODO: At some stage when we implement permissions on languages we'll need to take care of culture - return allowedUserOptions.Select(x => x.Action).OfType().Any(); - } - - - /// - /// this will parse the string into either a GUID or INT - /// - /// - /// - internal Tuple? GetIdentifierFromString(string id) - { - Guid idGuid; - int idInt; - Udi? idUdi; - - if (Guid.TryParse(id, out idGuid)) + // get the default assigned permissions for this user + var deleteAllowed = false; + IAction? deleteAction = _actionCollection.FirstOrDefault(y => y.Letter == ActionDelete.ActionLetter); + if (deleteAction != null) { - return new Tuple(idGuid, null); - } - if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out idInt)) - { - return new Tuple(null, idInt); - } - if (UdiParser.TryParse(id, out idUdi)) - { - var guidUdi = idUdi as GuidUdi; - if (guidUdi != null) - return new Tuple(guidUdi.Guid, null); + IEnumerable? perms = + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.GetPermissions( + Constants.System.RecycleBinContentString, _userService); + deleteAllowed = perms?.FirstOrDefault(x => x.Contains(deleteAction.Letter)) != null; } - return null; - } - - /// - /// Get an entity via an id that can be either an integer, Guid or UDI - /// - /// - /// - /// - /// This object has it's own contextual cache for these lookups - /// - internal IEntitySlim? GetEntityFromId(string id) - { - return _entityCache.GetOrAdd(id, s => + MenuItemCollection menu = MenuItemCollectionFactory.Create(); + // only add empty recycle bin if the current user is allowed to delete by default + if (deleteAllowed) { - IEntitySlim? entity; + menu.Items.Add(new MenuItem("emptyrecyclebin", LocalizedTextService) + { + Icon = "trash", + OpensDialog = true + }); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + } - if (Guid.TryParse(s, out var idGuid)) - { - entity = _entityService.Get(idGuid, UmbracoObjectType); - } - else if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idInt)) - { - entity = _entityService.Get(idInt, UmbracoObjectType); - } - else if (UdiParser.TryParse(s, out var idUdi)) - { - var guidUdi = idUdi as GuidUdi; - entity = guidUdi != null ? _entityService.Get(guidUdi.Guid, UmbracoObjectType) : null; - } - else - { - entity = null; - } - - return entity; - }); + return menu; } - private readonly ConcurrentDictionary _entityCache = new ConcurrentDictionary(); + return PerformGetMenuForNode(id, queryStrings); + } - private bool? _ignoreUserStartNodes; + /// + /// Based on the allowed actions, this will filter the ones that the current user is allowed + /// + /// + /// + /// + protected void FilterUserAllowedMenuItems(MenuItemCollection menuWithAllItems, IEnumerable userAllowedMenuItems) + { + IAction?[] userAllowedActions = + userAllowedMenuItems.Where(x => x.Action != null).Select(x => x.Action).ToArray(); + MenuItem[] notAllowed = menuWithAllItems.Items.Where( + a => a.Action != null + && a.Action.CanBePermissionAssigned + && (a.Action.CanBePermissionAssigned == false || userAllowedActions.Contains(a.Action) == false)) + .ToArray(); - - /// - /// If the request should allows a user to choose nodes that they normally don't have access to - /// - /// - /// - internal bool IgnoreUserStartNodes(FormCollection queryStrings) + //remove the ones that aren't allowed. + foreach (MenuItem m in notAllowed) { - if (_ignoreUserStartNodes.HasValue) - return _ignoreUserStartNodes.Value; - - var dataTypeKey = queryStrings.GetValue(TreeQueryStringParameters.DataTypeKey); - _ignoreUserStartNodes = dataTypeKey.HasValue && _dataTypeService.IsDataTypeIgnoringUserStartNodes(dataTypeKey.Value); - - return _ignoreUserStartNodes.Value; + menuWithAllItems.Items.Remove(m); + // if the disallowed action is set as default action, make sure to reset the default action as well + if (menuWithAllItems.DefaultMenuAlias == m.Alias) + { + menuWithAllItems.DefaultMenuAlias = null; + } } } + + internal IEnumerable GetAllowedUserMenuItemsForNode(IUmbracoEntity dd) + { + IEnumerable permissionsForPath = _userService + .GetPermissionsForPath(_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser, dd.Path) + .GetAllPermissions(); + return _actionCollection.GetByLetters(permissionsForPath).Select(x => new MenuItem(x)); + } + + /// + /// Determines if the user has access to view the node/document + /// + /// The Document to check permissions against + /// A list of MenuItems that the user has permissions to execute on the current document + /// The culture of the node + /// By default the user must have Browse permissions to see the node in the Content tree + /// + internal bool CanUserAccessNode(IUmbracoEntity doc, IEnumerable allowedUserOptions, string? culture) => + // TODO: At some stage when we implement permissions on languages we'll need to take care of culture + allowedUserOptions.Select(x => x.Action).OfType().Any(); + + + /// + /// this will parse the string into either a GUID or INT + /// + /// + /// + internal Tuple? GetIdentifierFromString(string id) + { + + if (Guid.TryParse(id, out Guid idGuid)) + { + return new Tuple(idGuid, null); + } + + if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out int idInt)) + { + return new Tuple(null, idInt); + } + + if (UdiParser.TryParse(id, out Udi? idUdi)) + { + var guidUdi = idUdi as GuidUdi; + if (guidUdi != null) + { + return new Tuple(guidUdi.Guid, null); + } + } + + return null; + } + + /// + /// Get an entity via an id that can be either an integer, Guid or UDI + /// + /// + /// + /// + /// This object has it's own contextual cache for these lookups + /// + internal IEntitySlim? GetEntityFromId(string id) => + _entityCache.GetOrAdd(id, s => + { + IEntitySlim? entity; + + if (Guid.TryParse(s, out Guid idGuid)) + { + entity = _entityService.Get(idGuid, UmbracoObjectType); + } + else if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idInt)) + { + entity = _entityService.Get(idInt, UmbracoObjectType); + } + else if (UdiParser.TryParse(s, out Udi? idUdi)) + { + var guidUdi = idUdi as GuidUdi; + entity = guidUdi != null ? _entityService.Get(guidUdi.Guid, UmbracoObjectType) : null; + } + else + { + entity = null; + } + + return entity; + }); + + + /// + /// If the request should allows a user to choose nodes that they normally don't have access to + /// + /// + /// + internal bool IgnoreUserStartNodes(FormCollection queryStrings) + { + if (_ignoreUserStartNodes.HasValue) + { + return _ignoreUserStartNodes.Value; + } + + Guid? dataTypeKey = queryStrings.GetValue(TreeQueryStringParameters.DataTypeKey); + _ignoreUserStartNodes = + dataTypeKey.HasValue && _dataTypeService.IsDataTypeIgnoringUserStartNodes(dataTypeKey.Value); + + return _ignoreUserStartNodes.Value; + } } diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs index aea6d8ab42..6e2bba141e 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -11,6 +7,7 @@ using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Trees; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; @@ -18,174 +15,181 @@ using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] +[Tree(Constants.Applications.Settings, Constants.Trees.DocumentTypes, SortOrder = 0, TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class ContentTypeTreeController : TreeController, ISearchableTree { - [Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)] - [Tree(Constants.Applications.Settings, Constants.Trees.DocumentTypes, SortOrder = 0, TreeGroup = Constants.Trees.Groups.Settings)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class ContentTypeTreeController : TreeController, ISearchableTree + private readonly IContentTypeService _contentTypeService; + private readonly IEntityService _entityService; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + private readonly UmbracoTreeSearcher _treeSearcher; + + public ContentTypeTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + UmbracoTreeSearcher treeSearcher, + IMenuItemCollectionFactory menuItemCollectionFactory, + IContentTypeService contentTypeService, + IEntityService entityService, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) { - private readonly UmbracoTreeSearcher _treeSearcher; - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly IContentTypeService _contentTypeService; - private readonly IEntityService _entityService; + _treeSearcher = treeSearcher; + _menuItemCollectionFactory = menuItemCollectionFactory; + _contentTypeService = contentTypeService; + _entityService = entityService; + } - public ContentTypeTreeController(ILocalizedTextService localizedTextService, UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, UmbracoTreeSearcher treeSearcher, IMenuItemCollectionFactory menuItemCollectionFactory, IContentTypeService contentTypeService, IEntityService entityService, IEventAggregator eventAggregator) : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + { + IEnumerable results = _treeSearcher.EntitySearch(UmbracoObjectTypes.DocumentType, query, pageSize, pageIndex, out var totalFound, searchFrom); + return new EntitySearchResults(results, totalFound); + } + + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - _treeSearcher = treeSearcher; - _menuItemCollectionFactory = menuItemCollectionFactory; - _contentTypeService = contentTypeService; - _entityService = entityService; + return rootResult; } - protected override ActionResult CreateRootNode(FormCollection queryStrings) + TreeNode? root = rootResult.Value; + + if (root is not null) { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; - - if (root is not null) - { - //check if there are any types - root.HasChildren = _contentTypeService.GetAll().Any(); - } - - return root; + //check if there are any types + root.HasChildren = _contentTypeService.GetAll().Any(); } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + return root; + } + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) { - if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) - { - throw new InvalidOperationException("Id must be an integer"); - } + throw new InvalidOperationException("Id must be an integer"); + } - var nodes = new TreeNodeCollection(); + var nodes = new TreeNodeCollection(); - nodes.AddRange( - _entityService.GetChildren(intId, UmbracoObjectTypes.DocumentTypeContainer) - .OrderBy(entity => entity.Name) - .Select(dt => - { - var node = CreateTreeNode(dt.Id.ToString(), id, queryStrings, dt.Name, Constants.Icons.Folder, dt.HasChildren, ""); - node.Path = dt.Path; - node.NodeType = "container"; - // TODO: This isn't the best way to ensure a no operation process for clicking a node but it works for now. - node.AdditionalData["jsClickCallback"] = "javascript:void(0);"; - return node; - })); + nodes.AddRange( + _entityService.GetChildren(intId, UmbracoObjectTypes.DocumentTypeContainer) + .OrderBy(entity => entity.Name) + .Select(dt => + { + TreeNode node = CreateTreeNode(dt.Id.ToString(), id, queryStrings, dt.Name, Constants.Icons.Folder, dt.HasChildren, string.Empty); + node.Path = dt.Path; + node.NodeType = "container"; - //if the request is for folders only then just return - if (queryStrings["foldersonly"].ToString().IsNullOrWhiteSpace() == false && queryStrings["foldersonly"] == "1") - return nodes; - - var children = _entityService.GetChildren(intId, UmbracoObjectTypes.DocumentType).ToArray(); - var contentTypes = _contentTypeService.GetAll(children.Select(c => c.Id).ToArray()).ToDictionary(c => c.Id); - nodes.AddRange( - children - .OrderBy(entity => entity.Name) - .Select(dt => - { - // get the content type here so we can get the icon from it to use when we create the tree node - // and we can enrich the result with content type data that's not available in the entity service output - var contentType = contentTypes[dt.Id]; - - // since 7.4+ child type creation is enabled by a config option. It defaults to on, but can be disabled if we decide to. - // need this check to keep supporting sites where children have already been created. - var hasChildren = dt.HasChildren; - var node = CreateTreeNode(dt, Constants.ObjectTypes.DocumentType, id, queryStrings, contentType?.Icon ?? Constants.Icons.ContentType, hasChildren); - - node.Path = dt.Path; - - // now we can enrich the result with content type data that's not available in the entity service output - node.Alias = contentType?.Alias ?? string.Empty; - node.AdditionalData["isElement"] = contentType?.IsElement; - - return node; - })); + // TODO: This isn't the best way to ensure a no operation process for clicking a node but it works for now. + node.AdditionalData["jsClickCallback"] = "javascript:void(0);"; + return node; + })); + //if the request is for folders only then just return + if (queryStrings["foldersonly"].ToString().IsNullOrWhiteSpace() == false && queryStrings["foldersonly"] == "1") + { return nodes; } - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + IEntitySlim[] children = _entityService.GetChildren(intId, UmbracoObjectTypes.DocumentType).ToArray(); + var contentTypes = _contentTypeService.GetAll(children.Select(c => c.Id).ToArray()).ToDictionary(c => c.Id); + nodes.AddRange( + children + .OrderBy(entity => entity.Name) + .Select(dt => + { + // get the content type here so we can get the icon from it to use when we create the tree node + // and we can enrich the result with content type data that's not available in the entity service output + IContentType? contentType = contentTypes[dt.Id]; + + // since 7.4+ child type creation is enabled by a config option. It defaults to on, but can be disabled if we decide to. + // need this check to keep supporting sites where children have already been created. + var hasChildren = dt.HasChildren; + TreeNode node = CreateTreeNode(dt, Constants.ObjectTypes.DocumentType, id, queryStrings, contentType?.Icon ?? Constants.Icons.ContentType, hasChildren); + + node.Path = dt.Path; + + // now we can enrich the result with content type data that's not available in the entity service output + node.Alias = contentType?.Alias ?? string.Empty; + node.AdditionalData["isElement"] = contentType?.IsElement; + + return node; + })); + + return nodes; + } + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + + if (id == Constants.System.RootString) { - var menu = _menuItemCollectionFactory.Create(); + //set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; - if (id == Constants.System.RootString) + // root actions + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(new MenuItem("importdocumenttype", LocalizedTextService) { - //set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - - // root actions - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(new MenuItem("importdocumenttype", LocalizedTextService) - { - Icon = "page-up", - SeparatorBefore = true, - OpensDialog = true - }); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - return menu; - } - - var container = _entityService.Get(int.Parse(id, CultureInfo.InvariantCulture), UmbracoObjectTypes.DocumentTypeContainer); - if (container != null) - { - //set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - - menu.Items.Add(LocalizedTextService, opensDialog: true); - - menu.Items.Add(new MenuItem("rename", LocalizedTextService) - { - Icon = "icon icon-edit" - }); - - if (container.HasChildren == false) - { - //can delete doc type - menu.Items.Add(LocalizedTextService, true, opensDialog: true); - } - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - } - else - { - var ct = _contentTypeService.Get(int.Parse(id, CultureInfo.InvariantCulture)); - var parent = ct == null ? null : _contentTypeService.Get(ct.ParentId); - - menu.Items.Add(LocalizedTextService, opensDialog: true); - //no move action if this is a child doc type - if (parent == null) - { - menu.Items.Add(LocalizedTextService, true, opensDialog: true); - } - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(new MenuItem("export", LocalizedTextService) - { - Icon = "download-alt", - SeparatorBefore = true, - OpensDialog = true - }); - menu.Items.Add(LocalizedTextService, true, opensDialog: true); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - } + Icon = "page-up", + SeparatorBefore = true, + OpensDialog = true + }); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } - public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + IEntitySlim? container = _entityService.Get(int.Parse(id, CultureInfo.InvariantCulture), UmbracoObjectTypes.DocumentTypeContainer); + if (container != null) { - var results = _treeSearcher.EntitySearch(UmbracoObjectTypes.DocumentType, query, pageSize, pageIndex, out long totalFound, searchFrom); - return new EntitySearchResults(results, totalFound); + //set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; + + menu.Items.Add(LocalizedTextService, opensDialog: true); + + menu.Items.Add(new MenuItem("rename", LocalizedTextService) { Icon = "icon icon-edit" }); + + if (container.HasChildren == false) + { + //can delete doc type + menu.Items.Add(LocalizedTextService, true, true); + } + + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + } + else + { + IContentType? ct = _contentTypeService.Get(int.Parse(id, CultureInfo.InvariantCulture)); + IContentType? parent = ct == null ? null : _contentTypeService.Get(ct.ParentId); + + menu.Items.Add(LocalizedTextService, opensDialog: true); + //no move action if this is a child doc type + if (parent == null) + { + menu.Items.Add(LocalizedTextService, true, true); + } + + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(new MenuItem("export", LocalizedTextService) + { + Icon = "download-alt", + SeparatorBefore = true, + OpensDialog = true + }); + menu.Items.Add(LocalizedTextService, true, true); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); } + return menu; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/DataTypeTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/DataTypeTreeController.cs index 574540e428..6121273a22 100644 --- a/src/Umbraco.Web.BackOffice/Trees/DataTypeTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/DataTypeTreeController.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -11,6 +7,7 @@ using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Trees; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; @@ -18,163 +15,174 @@ using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)] +[Tree(Constants.Applications.Settings, Constants.Trees.DataTypes, SortOrder = 3, + TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class DataTypeTreeController : TreeController, ISearchableTree { - [Authorize(Policy = AuthorizationPolicies.TreeAccessDataTypes)] - [Tree(Constants.Applications.Settings, Constants.Trees.DataTypes, SortOrder = 3, TreeGroup = Constants.Trees.Groups.Settings)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class DataTypeTreeController : TreeController, ISearchableTree + private readonly IDataTypeService _dataTypeService; + private readonly IEntityService _entityService; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + private readonly UmbracoTreeSearcher _treeSearcher; + + + public DataTypeTreeController(ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, UmbracoTreeSearcher treeSearcher, + IMenuItemCollectionFactory menuItemCollectionFactory, IEntityService entityService, + IDataTypeService dataTypeService, IEventAggregator eventAggregator) : base(localizedTextService, + umbracoApiControllerTypeCollection, eventAggregator) { - private readonly UmbracoTreeSearcher _treeSearcher; - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly IEntityService _entityService; - private readonly IDataTypeService _dataTypeService; + _treeSearcher = treeSearcher; + _menuItemCollectionFactory = menuItemCollectionFactory; + _entityService = entityService; + _dataTypeService = dataTypeService; + } + public async Task SearchAsync(string query, int pageSize, long pageIndex, + string? searchFrom = null) + { + IEnumerable results = _treeSearcher.EntitySearch(UmbracoObjectTypes.DataType, query, + pageSize, pageIndex, out var totalFound, searchFrom); + return new EntitySearchResults(results, totalFound); + } - public DataTypeTreeController(ILocalizedTextService localizedTextService, UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, UmbracoTreeSearcher treeSearcher, IMenuItemCollectionFactory menuItemCollectionFactory, IEntityService entityService, IDataTypeService dataTypeService, IEventAggregator eventAggregator) : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) { - _treeSearcher = treeSearcher; - _menuItemCollectionFactory = menuItemCollectionFactory; - _entityService = entityService; - _dataTypeService = dataTypeService; + throw new InvalidOperationException("Id must be an integer"); } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + var nodes = new TreeNodeCollection(); + + //Folders first + nodes.AddRange( + _entityService.GetChildren(intId, UmbracoObjectTypes.DataTypeContainer) + .OrderBy(entity => entity.Name) + .Select(dt => + { + TreeNode node = CreateTreeNode(dt, Constants.ObjectTypes.DataType, id, queryStrings, + Constants.Icons.Folder, dt.HasChildren); + node.Path = dt.Path; + node.NodeType = "container"; + // TODO: This isn't the best way to ensure a no operation process for clicking a node but it works for now. + node.AdditionalData["jsClickCallback"] = "javascript:void(0);"; + return node; + })); + + //if the request is for folders only then just return + if (queryStrings["foldersonly"].ToString().IsNullOrWhiteSpace() == false && queryStrings["foldersonly"] == "1") { - if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) - { - throw new InvalidOperationException("Id must be an integer"); - } - - var nodes = new TreeNodeCollection(); - - //Folders first - nodes.AddRange( - _entityService.GetChildren(intId, UmbracoObjectTypes.DataTypeContainer) - .OrderBy(entity => entity.Name) - .Select(dt => - { - var node = CreateTreeNode(dt, Constants.ObjectTypes.DataType, id, queryStrings, Constants.Icons.Folder, dt.HasChildren); - node.Path = dt.Path; - node.NodeType = "container"; - // TODO: This isn't the best way to ensure a no operation process for clicking a node but it works for now. - node.AdditionalData["jsClickCallback"] = "javascript:void(0);"; - return node; - })); - - //if the request is for folders only then just return - if (queryStrings["foldersonly"].ToString().IsNullOrWhiteSpace() == false && queryStrings["foldersonly"] == "1") return nodes; - - //System ListView nodes - var systemListViewDataTypeIds = GetNonDeletableSystemListViewDataTypeIds(); - - var children = _entityService.GetChildren(intId, UmbracoObjectTypes.DataType).ToArray(); - var dataTypes = Enumerable.ToDictionary(_dataTypeService.GetAll(children.Select(c => c.Id).ToArray()), dt => dt.Id); - - nodes.AddRange( - children - .OrderBy(entity => entity.Name) - .Select(dt => - { - var dataType = dataTypes[dt.Id]; - var node = CreateTreeNode(dt.Id.ToInvariantString(), id, queryStrings, dt.Name, dataType.Editor?.Icon, false); - node.Path = dt.Path; - return node; - }) - ); - return nodes; } - /// - /// Get all integer identifiers for the non-deletable system datatypes. - /// - private static IEnumerable GetNonDeletableSystemDataTypeIds() - { - var systemIds = new[] - { - Constants.DataTypes.Boolean, // Used by the Member Type: "Member" - Constants.DataTypes.Textarea, // Used by the Member Type: "Member" - Constants.DataTypes.LabelBigint, // Used by the Media Type: "Image"; Used by the Media Type: "File" - Constants.DataTypes.LabelDateTime, // Used by the Member Type: "Member" - Constants.DataTypes.LabelDecimal, // Used by the Member Type: "Member" - Constants.DataTypes.LabelInt, // Used by the Media Type: "Image"; Used by the Member Type: "Member" - Constants.DataTypes.LabelString, // Used by the Media Type: "Image"; Used by the Media Type: "File" - Constants.DataTypes.ImageCropper, // Used by the Media Type: "Image" - Constants.DataTypes.Upload, // Used by the Media Type: "File" - }; + //System ListView nodes + IEnumerable systemListViewDataTypeIds = GetNonDeletableSystemListViewDataTypeIds(); - return systemIds.Concat(GetNonDeletableSystemListViewDataTypeIds()); - } + IEntitySlim[] children = _entityService.GetChildren(intId, UmbracoObjectTypes.DataType).ToArray(); + var dataTypes = _dataTypeService.GetAll(children.Select(c => c.Id).ToArray()).ToDictionary(dt => dt.Id); - /// - /// Get all integer identifiers for the non-deletable system listviews. - /// - private static IEnumerable GetNonDeletableSystemListViewDataTypeIds() - { - return new[] - { - Constants.DataTypes.DefaultContentListView, - Constants.DataTypes.DefaultMediaListView, - Constants.DataTypes.DefaultMembersListView - }; - } - - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) - { - var menu = _menuItemCollectionFactory.Create(); - - if (id == Constants.System.RootString) - { - //set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - - // root actions - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - return menu; - } - - var container = _entityService.Get(int.Parse(id, CultureInfo.InvariantCulture), UmbracoObjectTypes.DataTypeContainer); - if (container != null) - { - //set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - - menu.Items.Add(LocalizedTextService, opensDialog: true); - - menu.Items.Add(new MenuItem("rename", LocalizedTextService.Localize("actions", "rename")) + nodes.AddRange( + children + .OrderBy(entity => entity.Name) + .Select(dt => { - Icon = "icon icon-edit" - }); + IDataType dataType = dataTypes[dt.Id]; + TreeNode node = CreateTreeNode(dt.Id.ToInvariantString(), id, queryStrings, dt.Name, + dataType.Editor?.Icon, false); + node.Path = dt.Path; + return node; + }) + ); - if (container.HasChildren == false) - { - //can delete data type - menu.Items.Add(LocalizedTextService, opensDialog: true); - } - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - } - else - { - var nonDeletableSystemDataTypeIds = GetNonDeletableSystemDataTypeIds(); + return nodes; + } - if (nonDeletableSystemDataTypeIds.Contains(int.Parse(id, CultureInfo.InvariantCulture)) == false) - menu.Items.Add(LocalizedTextService, opensDialog: true); + /// + /// Get all integer identifiers for the non-deletable system datatypes. + /// + private static IEnumerable GetNonDeletableSystemDataTypeIds() + { + var systemIds = new[] + { + Constants.DataTypes.Boolean, // Used by the Member Type: "Member" + Constants.DataTypes.Textarea, // Used by the Member Type: "Member" + Constants.DataTypes.LabelBigint, // Used by the Media Type: "Image"; Used by the Media Type: "File" + Constants.DataTypes.LabelDateTime, // Used by the Member Type: "Member" + Constants.DataTypes.LabelDecimal, // Used by the Member Type: "Member" + Constants.DataTypes.LabelInt, // Used by the Media Type: "Image"; Used by the Member Type: "Member" + Constants.DataTypes.LabelString, // Used by the Media Type: "Image"; Used by the Media Type: "File" + Constants.DataTypes.ImageCropper, // Used by the Media Type: "Image" + Constants.DataTypes.Upload // Used by the Media Type: "File" + }; - menu.Items.Add(LocalizedTextService, hasSeparator: true, opensDialog: true); - } + return systemIds.Concat(GetNonDeletableSystemListViewDataTypeIds()); + } + /// + /// Get all integer identifiers for the non-deletable system listviews. + /// + private static IEnumerable GetNonDeletableSystemListViewDataTypeIds() => + new[] + { + Constants.DataTypes.DefaultContentListView, Constants.DataTypes.DefaultMediaListView, + Constants.DataTypes.DefaultMembersListView + }; + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + + if (id == Constants.System.RootString) + { + //set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; + + // root actions + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } - public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + IEntitySlim? container = _entityService.Get(int.Parse(id, CultureInfo.InvariantCulture), + UmbracoObjectTypes.DataTypeContainer); + if (container != null) { - var results = _treeSearcher.EntitySearch(UmbracoObjectTypes.DataType, query, pageSize, pageIndex, out long totalFound, searchFrom); - return new EntitySearchResults(results, totalFound); + //set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; + + menu.Items.Add(LocalizedTextService, opensDialog: true); + + menu.Items.Add(new MenuItem("rename", LocalizedTextService.Localize("actions", "rename")) + { + Icon = "icon icon-edit" + }); + + if (container.HasChildren == false) + { + //can delete data type + menu.Items.Add(LocalizedTextService, opensDialog: true); + } + + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); } + else + { + IEnumerable nonDeletableSystemDataTypeIds = GetNonDeletableSystemDataTypeIds(); + + if (nonDeletableSystemDataTypeIds.Contains(int.Parse(id, CultureInfo.InvariantCulture)) == false) + { + menu.Items.Add(LocalizedTextService, opensDialog: true); + } + + menu.Items.Add(LocalizedTextService, true, true); + } + + return menu; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/DictionaryTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/DictionaryTreeController.cs index 2e97769ca5..274e26c4bf 100644 --- a/src/Umbraco.Web.BackOffice/Trees/DictionaryTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/DictionaryTreeController.cs @@ -1,6 +1,4 @@ -using System; using System.Globalization; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -14,130 +12,145 @@ using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +// We are allowed to see the dictionary tree, if we are allowed to manage templates, such that se can use the +// dictionary items in templates, even when we dont have authorization to manage the dictionary items +[Authorize(Policy = AuthorizationPolicies.TreeAccessDictionaryOrTemplates)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +[Tree(Constants.Applications.Translation, Constants.Trees.Dictionary, TreeGroup = Constants.Trees.Groups.Settings)] +public class DictionaryTreeController : TreeController { + private readonly ILocalizationService _localizationService; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - // We are allowed to see the dictionary tree, if we are allowed to manage templates, such that se can use the - // dictionary items in templates, even when we dont have authorization to manage the dictionary items - [Authorize(Policy = AuthorizationPolicies.TreeAccessDictionaryOrTemplates)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - [Tree(Constants.Applications.Translation, Constants.Trees.Dictionary, TreeGroup = Constants.Trees.Groups.Settings)] - public class DictionaryTreeController : TreeController + public DictionaryTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + ILocalizationService localizationService, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) { - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly ILocalizationService _localizationService; + _menuItemCollectionFactory = menuItemCollectionFactory; + _localizationService = localizationService; + } - public DictionaryTreeController(ILocalizedTextService localizedTextService, UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, IMenuItemCollectionFactory menuItemCollectionFactory, ILocalizationService localizationService, IEventAggregator eventAggregator) : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - _menuItemCollectionFactory = menuItemCollectionFactory; - _localizationService = localizationService; + return rootResult; } - protected override ActionResult CreateRootNode(FormCollection queryStrings) + TreeNode? root = rootResult.Value; + + // the default section is settings, falling back to this if we can't + // figure out where we are from the querystring parameters + var section = Constants.Applications.Translation; + if (!queryStrings["application"].ToString().IsNullOrWhiteSpace()) { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; - - // the default section is settings, falling back to this if we can't - // figure out where we are from the querystring parameters - var section = Constants.Applications.Translation; - if (!queryStrings["application"].ToString().IsNullOrWhiteSpace()) - section = queryStrings["application"]; - - if (root is not null) - { - // this will load in a custom UI instead of the dashboard for the root node - root.RoutePath = $"{section}/{Constants.Trees.Dictionary}/list"; - } - - return root; + section = queryStrings["application"]; } - /// - /// The method called to render the contents of the tree structure - /// - /// The id of the tree item - /// - /// All of the query string parameters passed from jsTree - /// - /// - /// We are allowing an arbitrary number of query strings to be passed in so that developers are able to persist custom data from the front-end - /// to the back end to be used in the query for model data. - /// - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + if (root is not null) { - if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) - { - throw new InvalidOperationException("Id must be an integer"); - } + // this will load in a custom UI instead of the dashboard for the root node + root.RoutePath = $"{section}/{Constants.Trees.Dictionary}/list"; + } - var nodes = new TreeNodeCollection(); + return root; + } - Func ItemSort() => item => item.ItemKey; + /// + /// The method called to render the contents of the tree structure + /// + /// The id of the tree item + /// + /// All of the query string parameters passed from jsTree + /// + /// + /// We are allowing an arbitrary number of query strings to be passed in so that developers are able to persist custom + /// data from the front-end + /// to the back end to be used in the query for model data. + /// + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) + { + throw new InvalidOperationException("Id must be an integer"); + } - if (id == Constants.System.RootString) - { - nodes.AddRange( - _localizationService.GetRootDictionaryItems()?.OrderBy(ItemSort()).Select( - x => CreateTreeNode( - x.Id.ToInvariantString(), - id, - queryStrings, - x.ItemKey, - Constants.Icons.Dictionary, - _localizationService.GetDictionaryItemChildren(x.Key)?.Any() ?? false)) ?? Enumerable.Empty()); - } - else - { - // maybe we should use the guid as URL param to avoid the extra call for getting dictionary item - var parentDictionary = _localizationService.GetDictionaryItemById(intId); - if (parentDictionary == null) - return nodes; + var nodes = new TreeNodeCollection(); - nodes.AddRange(_localizationService.GetDictionaryItemChildren(parentDictionary.Key)?.ToList().OrderBy(ItemSort()).Select( + static Func ItemSort() + { + return item => item.ItemKey; + } + + if (id == Constants.System.RootString) + { + nodes.AddRange( + _localizationService.GetRootDictionaryItems()?.OrderBy(ItemSort()).Select( x => CreateTreeNode( x.Id.ToInvariantString(), id, queryStrings, x.ItemKey, Constants.Icons.Dictionary, - _localizationService.GetDictionaryItemChildren(x.Key)?.Any() ?? false)) ?? Enumerable.Empty()); - } - - return nodes; + _localizationService.GetDictionaryItemChildren(x.Key)?.Any() ?? false)) ?? + Enumerable.Empty()); } - - /// - /// Returns the menu structure for the node - /// - /// The id of the tree item - /// - /// All of the query string parameters passed from jsTree - /// - /// - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + else { - var menu = _menuItemCollectionFactory.Create(); - - menu.Items.Add(LocalizedTextService, opensDialog: true); - - if (id != Constants.System.RootString) + // maybe we should use the guid as URL param to avoid the extra call for getting dictionary item + IDictionaryItem? parentDictionary = _localizationService.GetDictionaryItemById(intId); + if (parentDictionary == null) { - menu.Items.Add(LocalizedTextService, true, opensDialog: true); - menu.Items.Add(LocalizedTextService, true, opensDialog: true); + return nodes; } - - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - return menu; + nodes.AddRange(_localizationService.GetDictionaryItemChildren(parentDictionary.Key)?.ToList() + .OrderBy(ItemSort()).Select( + x => CreateTreeNode( + x.Id.ToInvariantString(), + id, + queryStrings, + x.ItemKey, + Constants.Icons.Dictionary, + _localizationService.GetDictionaryItemChildren(x.Key)?.Any() ?? false)) ?? + Enumerable.Empty()); } + + return nodes; + } + + /// + /// Returns the menu structure for the node + /// + /// The id of the tree item + /// + /// All of the query string parameters passed from jsTree + /// + /// + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + + menu.Items.Add(LocalizedTextService, opensDialog: true); + + if (id != Constants.System.RootString) + { + menu.Items.Add(LocalizedTextService, true, true); + menu.Items.Add(LocalizedTextService, true, true); + } + + + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + + return menu; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/FileSystemTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/FileSystemTreeController.cs index ba32715f59..ea44d5c381 100644 --- a/src/Umbraco.Web.BackOffice/Trees/FileSystemTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/FileSystemTreeController.cs @@ -1,6 +1,3 @@ -using System; -using System.IO; -using System.Linq; using System.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -12,193 +9,201 @@ using Umbraco.Cms.Core.Models.Trees; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +public abstract class FileSystemTreeController : TreeController { - public abstract class FileSystemTreeController : TreeController + protected FileSystemTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IEventAggregator eventAggregator + ) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) => + MenuItemCollectionFactory = menuItemCollectionFactory; + + protected abstract IFileSystem? FileSystem { get; } + protected IMenuItemCollectionFactory MenuItemCollectionFactory { get; } + protected abstract string[] Extensions { get; } + protected abstract string FileIcon { get; } + + /// + /// Inheritors can override this method to modify the file node that is created. + /// + /// + protected virtual void OnRenderFileNode(ref TreeNode treeNode) { } + + /// + /// Inheritors can override this method to modify the folder node that is created. + /// + /// + protected virtual void OnRenderFolderNode(ref TreeNode treeNode) => + // TODO: This isn't the best way to ensure a noop process for clicking a node but it works for now. + treeNode.AdditionalData["jsClickCallback"] = "javascript:void(0);"; + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) { - protected FileSystemTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IEventAggregator eventAggregator - ) - : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + var path = string.IsNullOrEmpty(id) == false && id != Constants.System.RootString + ? WebUtility.UrlDecode(id).TrimStart("/") + : ""; + + IEnumerable? directories = FileSystem?.GetDirectories(path); + + var nodes = new TreeNodeCollection(); + if (directories is not null) { - MenuItemCollectionFactory = menuItemCollectionFactory; - } - - protected abstract IFileSystem? FileSystem { get; } - protected IMenuItemCollectionFactory MenuItemCollectionFactory { get; } - protected abstract string[] Extensions { get; } - protected abstract string FileIcon { get; } - - /// - /// Inheritors can override this method to modify the file node that is created. - /// - /// - protected virtual void OnRenderFileNode(ref TreeNode treeNode) { } - - /// - /// Inheritors can override this method to modify the folder node that is created. - /// - /// - protected virtual void OnRenderFolderNode(ref TreeNode treeNode) { - // TODO: This isn't the best way to ensure a noop process for clicking a node but it works for now. - treeNode.AdditionalData["jsClickCallback"] = "javascript:void(0);"; - } - - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) - { - var path = string.IsNullOrEmpty(id) == false && id != Constants.System.RootString - ? WebUtility.UrlDecode(id).TrimStart("/") - : ""; - - var directories = FileSystem?.GetDirectories(path); - - var nodes = new TreeNodeCollection(); - if (directories is not null) + foreach (var directory in directories) { - foreach (var directory in directories) - { - var hasChildren = FileSystem is not null && (FileSystem.GetFiles(directory).Any() || FileSystem.GetDirectories(directory).Any()); + var hasChildren = FileSystem is not null && + (FileSystem.GetFiles(directory).Any() || FileSystem.GetDirectories(directory).Any()); - var name = Path.GetFileName(directory); - var node = CreateTreeNode(WebUtility.UrlEncode(directory), path, queryStrings, name, "icon-folder", hasChildren); - OnRenderFolderNode(ref node); - if (node != null) - nodes.Add(node); + var name = Path.GetFileName(directory); + TreeNode? node = CreateTreeNode(WebUtility.UrlEncode(directory), path, queryStrings, name, + "icon-folder", hasChildren); + OnRenderFolderNode(ref node); + if (node != null) + { + nodes.Add(node); } } + } - //this is a hack to enable file system tree to support multiple file extension look-up - //so the pattern both support *.* *.xml and xml,js,vb for lookups - var files = FileSystem?.GetFiles(path).Where(x => + //this is a hack to enable file system tree to support multiple file extension look-up + //so the pattern both support *.* *.xml and xml,js,vb for lookups + IEnumerable? files = FileSystem?.GetFiles(path).Where(x => + { + var extension = Path.GetExtension(x); + + if (Extensions.Contains("*")) { - var extension = Path.GetExtension(x); + return true; + } - if (Extensions.Contains("*")) - return true; + return extension != null && Extensions.Contains(extension.Trim(Constants.CharArrays.Period), + StringComparer.InvariantCultureIgnoreCase); + }); - return extension != null && Extensions.Contains(extension.Trim(Constants.CharArrays.Period), StringComparer.InvariantCultureIgnoreCase); - }); - - if (files is not null) + if (files is not null) + { + foreach (var file in files) { - foreach (var file in files) + var withoutExt = Path.GetFileNameWithoutExtension(file); + if (withoutExt.IsNullOrWhiteSpace()) { - var withoutExt = Path.GetFileNameWithoutExtension(file); - if (withoutExt.IsNullOrWhiteSpace()) continue; + continue; + } - var name = Path.GetFileName(file); - var node = CreateTreeNode(WebUtility.UrlEncode(file), path, queryStrings, name, FileIcon, false); - OnRenderFileNode(ref node); - if (node != null) - nodes.Add(node); + var name = Path.GetFileName(file); + TreeNode? node = CreateTreeNode(WebUtility.UrlEncode(file), path, queryStrings, name, FileIcon, false); + OnRenderFileNode(ref node); + if (node != null) + { + nodes.Add(node); } } - - return nodes; } - protected override ActionResult CreateRootNode(FormCollection queryStrings) + return nodes; + } + + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; - - //check if there are any children - var treeNodesResult = GetTreeNodes(Constants.System.RootString, queryStrings); - if (!(treeNodesResult.Result is null)) - { - return treeNodesResult.Result; - } - - if (root is not null) - { - root.HasChildren = treeNodesResult.Value?.Any() ?? false; - - } - - return root; + return rootResult; } - protected virtual MenuItemCollection GetMenuForRootNode(FormCollection queryStrings) + TreeNode? root = rootResult.Value; + + //check if there are any children + ActionResult treeNodesResult = GetTreeNodes(Constants.System.RootString, queryStrings); + if (!(treeNodesResult.Result is null)) { - var menu = MenuItemCollectionFactory.Create(); - - //set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - //create action - menu.Items.Add(LocalizedTextService, opensDialog: true); - //refresh action - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - return menu; + return treeNodesResult.Result; } - protected virtual MenuItemCollection GetMenuForFolder(string path, FormCollection queryStrings) + if (root is not null) { - var menu = MenuItemCollectionFactory.Create(); - - //set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - //create action - menu.Items.Add(LocalizedTextService, opensDialog: true); - - var hasChildren = FileSystem is not null && (FileSystem.GetFiles(path).Any() || FileSystem.GetDirectories(path).Any()); - - //We can only delete folders if it doesn't have any children (folders or files) - if (hasChildren == false) - { - //delete action - menu.Items.Add(LocalizedTextService, true, opensDialog: true); - } - - //refresh action - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - return menu; + root.HasChildren = treeNodesResult.Value?.Any() ?? false; } - protected virtual MenuItemCollection GetMenuForFile(string path, FormCollection queryStrings) + return root; + } + + protected virtual MenuItemCollection GetMenuForRootNode(FormCollection queryStrings) + { + MenuItemCollection menu = MenuItemCollectionFactory.Create(); + + //set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; + //create action + menu.Items.Add(LocalizedTextService, opensDialog: true); + //refresh action + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + + return menu; + } + + protected virtual MenuItemCollection GetMenuForFolder(string path, FormCollection queryStrings) + { + MenuItemCollection menu = MenuItemCollectionFactory.Create(); + + //set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; + //create action + menu.Items.Add(LocalizedTextService, opensDialog: true); + + var hasChildren = FileSystem is not null && + (FileSystem.GetFiles(path).Any() || FileSystem.GetDirectories(path).Any()); + + //We can only delete folders if it doesn't have any children (folders or files) + if (hasChildren == false) { - var menu = MenuItemCollectionFactory.Create(); - - //if it's not a directory then we only allow to delete the item - menu.Items.Add(LocalizedTextService, opensDialog: true); - - return menu; + //delete action + menu.Items.Add(LocalizedTextService, true, true); } - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + //refresh action + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + + return menu; + } + + protected virtual MenuItemCollection GetMenuForFile(string path, FormCollection queryStrings) + { + MenuItemCollection menu = MenuItemCollectionFactory.Create(); + + //if it's not a directory then we only allow to delete the item + menu.Items.Add(LocalizedTextService, opensDialog: true); + + return menu; + } + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + //if root node no need to visit the filesystem so lets just create the menu and return it + if (id == Constants.System.RootString) { - //if root node no need to visit the filesystem so lets just create the menu and return it - if (id == Constants.System.RootString) - { - return GetMenuForRootNode(queryStrings); - } - - var menu = MenuItemCollectionFactory.Create(); - - var path = string.IsNullOrEmpty(id) == false && id != Constants.System.RootString - ? WebUtility.UrlDecode(id).TrimStart("/") - : ""; - - var isFile = FileSystem?.FileExists(path) ?? false; - var isDirectory = FileSystem?.DirectoryExists(path) ?? false; - - if (isDirectory) - { - return GetMenuForFolder(path, queryStrings); - } - - return isFile ? GetMenuForFile(path, queryStrings) : menu; + return GetMenuForRootNode(queryStrings); } + + MenuItemCollection menu = MenuItemCollectionFactory.Create(); + + var path = string.IsNullOrEmpty(id) == false && id != Constants.System.RootString + ? WebUtility.UrlDecode(id).TrimStart("/") + : ""; + + var isFile = FileSystem?.FileExists(path) ?? false; + var isDirectory = FileSystem?.DirectoryExists(path) ?? false; + + if (isDirectory) + { + return GetMenuForFolder(path, queryStrings); + } + + return isFile ? GetMenuForFile(path, queryStrings) : menu; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/FilesTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/FilesTreeController.cs index 7caf6256d9..eb630460e8 100644 --- a/src/Umbraco.Web.BackOffice/Trees/FilesTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/FilesTreeController.cs @@ -3,31 +3,27 @@ using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Tree(Constants.Applications.Settings, "files", TreeTitle = "Files", TreeUse = TreeUse.Dialog)] +[CoreTree] +public class FilesTreeController : FileSystemTreeController { - [Tree(Constants.Applications.Settings, "files", TreeTitle = "Files", TreeUse = TreeUse.Dialog)] - [CoreTree] - public class FilesTreeController : FileSystemTreeController - { - protected override IFileSystem FileSystem { get; } + private static readonly string[] ExtensionsStatic = { "*" }; - private static readonly string[] ExtensionsStatic = { "*" }; + public FilesTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IPhysicalFileSystem fileSystem, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator) => + FileSystem = fileSystem; - public FilesTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IPhysicalFileSystem fileSystem, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator) - { - FileSystem = fileSystem; - } + protected override IFileSystem FileSystem { get; } - protected override string[] Extensions => ExtensionsStatic; + protected override string[] Extensions => ExtensionsStatic; - protected override string FileIcon => Constants.Icons.MediaFile; - } + protected override string FileIcon => Constants.Icons.MediaFile; } diff --git a/src/Umbraco.Web.BackOffice/Trees/ITreeNodeController.cs b/src/Umbraco.Web.BackOffice/Trees/ITreeNodeController.cs index 2faffdfa7f..2ec1e7f520 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ITreeNodeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ITreeNodeController.cs @@ -3,22 +3,22 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.ModelBinders; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// Represents an TreeNodeController +/// +public interface ITreeNodeController { /// - /// Represents an TreeNodeController + /// Gets an individual tree node /// - public interface ITreeNodeController - { - /// - /// Gets an individual tree node - /// - /// - /// - /// - ActionResult GetTreeNode( - string id, - [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection? queryStrings - ); - } + /// + /// + /// + ActionResult GetTreeNode( + string id, + [ModelBinder(typeof(HttpQueryStringModelBinder))] + FormCollection? queryStrings + ); } diff --git a/src/Umbraco.Web.BackOffice/Trees/LanguageTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/LanguageTreeController.cs index c763bc1167..bf1d019f85 100644 --- a/src/Umbraco.Web.BackOffice/Trees/LanguageTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/LanguageTreeController.cs @@ -7,65 +7,57 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessLanguages)] +[Tree(Constants.Applications.Settings, Constants.Trees.Languages, SortOrder = 11, + TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class LanguageTreeController : TreeController { - [Authorize(Policy = AuthorizationPolicies.TreeAccessLanguages)] - [Tree(Constants.Applications.Settings, Constants.Trees.Languages, SortOrder = 11, TreeGroup = Constants.Trees.Groups.Settings)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class LanguageTreeController : TreeController + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + + public LanguageTreeController( + ILocalizedTextService textService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IEventAggregator eventAggregator, + IMenuItemCollectionFactory menuItemCollectionFactory) + : base(textService, umbracoApiControllerTypeCollection, eventAggregator) => + _menuItemCollectionFactory = menuItemCollectionFactory; + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) => + //We don't have any child nodes & only use the root node to load a custom UI + new TreeNodeCollection(); + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) => + //We don't have any menu item options (such as create/delete/reload) & only use the root node to load a custom UI + _menuItemCollectionFactory.Create(); + + /// + /// Helper method to create a root model for a tree + /// + /// + protected override ActionResult CreateRootNode(FormCollection queryStrings) { - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - - public LanguageTreeController( - ILocalizedTextService textService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IEventAggregator eventAggregator, - IMenuItemCollectionFactory menuItemCollectionFactory) - : base(textService, umbracoApiControllerTypeCollection, eventAggregator) + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - _menuItemCollectionFactory = menuItemCollectionFactory; + return rootResult; } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + TreeNode? root = rootResult.Value; + + if (root is not null) { - //We don't have any child nodes & only use the root node to load a custom UI - return new TreeNodeCollection(); + // This will load in a custom UI instead of the dashboard for the root node + root.RoutePath = $"{Constants.Applications.Settings}/{Constants.Trees.Languages}/overview"; + root.Icon = Constants.Icons.Language; + root.HasChildren = false; + root.MenuUrl = null; } - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) - { - //We don't have any menu item options (such as create/delete/reload) & only use the root node to load a custom UI - return _menuItemCollectionFactory.Create(); - } - - /// - /// Helper method to create a root model for a tree - /// - /// - protected override ActionResult CreateRootNode(FormCollection queryStrings) - { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; - - if (root is not null) - { - // This will load in a custom UI instead of the dashboard for the root node - root.RoutePath = $"{Constants.Applications.Settings}/{Constants.Trees.Languages}/overview"; - root.Icon = Constants.Icons.Language; - root.HasChildren = false; - root.MenuUrl = null; - } - - return root; - } - - + return root; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/LogViewerTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/LogViewerTreeController.cs index 6e3bdd7ee2..c493cd86b4 100644 --- a/src/Umbraco.Web.BackOffice/Trees/LogViewerTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/LogViewerTreeController.cs @@ -7,63 +7,57 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessLogs)] +[Tree(Constants.Applications.Settings, Constants.Trees.LogViewer, SortOrder = 9, + TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class LogViewerTreeController : TreeController { - [Authorize(Policy = AuthorizationPolicies.TreeAccessLogs)] - [Tree(Constants.Applications.Settings, Constants.Trees.LogViewer, SortOrder= 9, TreeGroup = Constants.Trees.Groups.Settings)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class LogViewerTreeController : TreeController + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + + public LogViewerTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IEventAggregator eventAggregator, + IMenuItemCollectionFactory menuItemCollectionFactory) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) => + _menuItemCollectionFactory = menuItemCollectionFactory; + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) => + //We don't have any child nodes & only use the root node to load a custom UI + new TreeNodeCollection(); + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) => + //We don't have any menu item options (such as create/delete/reload) & only use the root node to load a custom UI + _menuItemCollectionFactory.Create(); + + /// + /// Helper method to create a root model for a tree + /// + /// + protected override ActionResult CreateRootNode(FormCollection queryStrings) { - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - - public LogViewerTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IEventAggregator eventAggregator, - IMenuItemCollectionFactory menuItemCollectionFactory) - : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - _menuItemCollectionFactory = menuItemCollectionFactory; + return rootResult; } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + TreeNode? root = rootResult.Value; + + if (root is not null) { - //We don't have any child nodes & only use the root node to load a custom UI - return new TreeNodeCollection(); + // This will load in a custom UI instead of the dashboard for the root node + root.RoutePath = $"{Constants.Applications.Settings}/{Constants.Trees.LogViewer}/overview"; + root.Icon = Constants.Icons.LogViewer; + root.HasChildren = false; + root.MenuUrl = null; } - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) - { - //We don't have any menu item options (such as create/delete/reload) & only use the root node to load a custom UI - return _menuItemCollectionFactory.Create(); - } - - /// - /// Helper method to create a root model for a tree - /// - /// - protected override ActionResult CreateRootNode(FormCollection queryStrings) - { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; - - if (root is not null) - { - // This will load in a custom UI instead of the dashboard for the root node - root.RoutePath = $"{Constants.Applications.Settings}/{Constants.Trees.LogViewer}/overview"; - root.Icon = Constants.Icons.LogViewer; - root.HasChildren = false; - root.MenuUrl = null; - } - - return root; - } + return root; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/MacrosTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/MacrosTreeController.cs index 4edff226f6..6cb716a93f 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MacrosTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MacrosTreeController.cs @@ -1,96 +1,103 @@ using System.Globalization; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Trees; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessMacros)] +[Tree(Constants.Applications.Settings, Constants.Trees.Macros, TreeTitle = "Macros", SortOrder = 4, + TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class MacrosTreeController : TreeController { - [Authorize(Policy = AuthorizationPolicies.TreeAccessMacros)] - [Tree(Constants.Applications.Settings, Constants.Trees.Macros, TreeTitle = "Macros", SortOrder = 4, TreeGroup = Constants.Trees.Groups.Settings)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class MacrosTreeController : TreeController + private readonly IMacroService _macroService; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + + public MacrosTreeController(ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, IMacroService macroService, + IEventAggregator eventAggregator) : base(localizedTextService, umbracoApiControllerTypeCollection, + eventAggregator) { - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly IMacroService _macroService; + _menuItemCollectionFactory = menuItemCollectionFactory; + _macroService = macroService; + } - public MacrosTreeController(ILocalizedTextService localizedTextService, UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, IMenuItemCollectionFactory menuItemCollectionFactory, IMacroService macroService, IEventAggregator eventAggregator) : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - _menuItemCollectionFactory = menuItemCollectionFactory; - _macroService = macroService; + return rootResult; } - protected override ActionResult CreateRootNode(FormCollection queryStrings) + TreeNode? root = rootResult.Value; + + if (root is not null) { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; - - if (root is not null) - { - // Check if there are any macros - root.HasChildren = _macroService.GetAll().Any(); - } - - return root; + // Check if there are any macros + root.HasChildren = _macroService.GetAll().Any(); } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + return root; + } + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + var nodes = new TreeNodeCollection(); + + if (id == Constants.System.RootString) { - var nodes = new TreeNodeCollection(); - - if (id == Constants.System.RootString) + foreach (IMacro macro in _macroService.GetAll().OrderBy(m => m.Name)) { - foreach (var macro in _macroService.GetAll().OrderBy(m => m.Name)) - { - nodes.Add(CreateTreeNode( - macro.Id.ToString(), - id, - queryStrings, - macro.Name, - Constants.Icons.Macro, - false)); - } + nodes.Add(CreateTreeNode( + macro.Id.ToString(), + id, + queryStrings, + macro.Name, + Constants.Icons.Macro, + false)); } - - return nodes; } - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + return nodes; + } + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + + if (id == Constants.System.RootString) { - var menu = _menuItemCollectionFactory.Create(); + //Create the normal create action + menu.Items.Add(LocalizedTextService); - if (id == Constants.System.RootString) - { - //Create the normal create action - menu.Items.Add(LocalizedTextService); - - //refresh action - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - return menu; - } - - var macro = _macroService.GetById(int.Parse(id, CultureInfo.InvariantCulture)); - if (macro == null) return menu; - - //add delete option for all macros - menu.Items.Add(LocalizedTextService, opensDialog: true); + //refresh action + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } + + IMacro? macro = _macroService.GetById(int.Parse(id, CultureInfo.InvariantCulture)); + if (macro == null) + { + return menu; + } + + //add delete option for all macros + menu.Items.Add(LocalizedTextService, opensDialog: true); + + return menu; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/MediaTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/MediaTreeController.cs index 2bd171b7c7..9e5221aa97 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MediaTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MediaTreeController.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -22,173 +18,181 @@ using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.SectionAccessForMediaTree)] +[Tree(Constants.Applications.Media, Constants.Trees.Media)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +[SearchableTree("searchResultFormatter", "configureMediaResult", 20)] +public class MediaTreeController : ContentTreeControllerBase, ISearchableTree, ITreeNodeController { - [Authorize(Policy = AuthorizationPolicies.SectionAccessForMediaTree)] - [Tree(Constants.Applications.Media, Constants.Trees.Media)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - [SearchableTree("searchResultFormatter", "configureMediaResult", 20)] - public class MediaTreeController : ContentTreeControllerBase, ISearchableTree, ITreeNodeController + private readonly AppCaches _appCaches; + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IEntityService _entityService; + private readonly IMediaService _mediaService; + private readonly UmbracoTreeSearcher _treeSearcher; + + private int[]? _userStartNodes; + + public MediaTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IEntityService entityService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILogger logger, + ActionCollection actionCollection, + IUserService userService, + IDataTypeService dataTypeService, + UmbracoTreeSearcher treeSearcher, + IMediaService mediaService, + IEventAggregator eventAggregator, + AppCaches appCaches) + : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, entityService, + backofficeSecurityAccessor, logger, actionCollection, userService, dataTypeService, eventAggregator, + appCaches) { - private readonly UmbracoTreeSearcher _treeSearcher; - private readonly IMediaService _mediaService; - private readonly AppCaches _appCaches; - private readonly IEntityService _entityService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + _treeSearcher = treeSearcher; + _mediaService = mediaService; + _appCaches = appCaches; + _entityService = entityService; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + } - public MediaTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IEntityService entityService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - ILogger logger, - ActionCollection actionCollection, - IUserService userService, - IDataTypeService dataTypeService, - UmbracoTreeSearcher treeSearcher, - IMediaService mediaService, - IEventAggregator eventAggregator, - AppCaches appCaches) - : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, entityService, backofficeSecurityAccessor, logger, actionCollection, userService, dataTypeService, eventAggregator, appCaches) + protected override int RecycleBinId => Constants.System.RecycleBinMedia; + + protected override bool RecycleBinSmells => _mediaService.RecycleBinSmells(); + + protected override int[] UserStartNodes + => _userStartNodes ?? + (_userStartNodes = + _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds(_entityService, + _appCaches)) ?? Array.Empty(); + + protected override UmbracoObjectTypes UmbracoObjectType => UmbracoObjectTypes.Media; + + public async Task SearchAsync(string query, int pageSize, long pageIndex, + string? searchFrom = null) + { + IEnumerable results = _treeSearcher.ExamineSearch(query, UmbracoEntityTypes.Media, pageSize, + pageIndex, out var totalFound, searchFrom); + return new EntitySearchResults(results, totalFound); + } + + /// + /// Creates a tree node for a content item based on an UmbracoEntity + /// + /// + /// + /// + /// + protected override TreeNode GetSingleTreeNode(IEntitySlim entity, string parentId, FormCollection? queryStrings) + { + TreeNode node = CreateTreeNode( + entity, + Constants.ObjectTypes.Media, + parentId, + queryStrings, + entity.HasChildren); + + // entity is either a container, or a media + if (entity.IsContainer) { - _treeSearcher = treeSearcher; - _mediaService = mediaService; - _appCaches = appCaches; - _entityService = entityService; - _backofficeSecurityAccessor = backofficeSecurityAccessor; + node.SetContainerStyle(); + node.AdditionalData.Add("isContainer", true); + } + else + { + var contentEntity = (IContentEntitySlim)entity; + node.AdditionalData.Add("contentType", contentEntity.ContentTypeAlias); } - protected override int RecycleBinId => Constants.System.RecycleBinMedia; + return node; + } - protected override bool RecycleBinSmells => _mediaService.RecycleBinSmells(); + protected override ActionResult PerformGetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = MenuItemCollectionFactory.Create(); - private int[]? _userStartNodes; - protected override int[] UserStartNodes - => _userStartNodes ?? (_userStartNodes = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.CalculateMediaStartNodeIds(_entityService, _appCaches)) ?? Array.Empty(); + //set the default + menu.DefaultMenuAlias = ActionNew.ActionAlias; - /// - /// Creates a tree node for a content item based on an UmbracoEntity - /// - /// - /// - /// - /// - protected override TreeNode GetSingleTreeNode(IEntitySlim entity, string parentId, FormCollection? queryStrings) + if (id == Constants.System.RootString) { - var node = CreateTreeNode( - entity, - Constants.ObjectTypes.Media, - parentId, - queryStrings, - entity.HasChildren); - - // entity is either a container, or a media - if (entity.IsContainer) - { - node.SetContainerStyle(); - node.AdditionalData.Add("isContainer", true); - } - else - { - var contentEntity = (IContentEntitySlim) entity; - node.AdditionalData.Add("contentType", contentEntity.ContentTypeAlias); - } - - return node; - } - - protected override ActionResult PerformGetMenuForNode(string id, FormCollection queryStrings) - { - var menu = MenuItemCollectionFactory.Create(); - - //set the default - menu.DefaultMenuAlias = ActionNew.ActionAlias; - - if (id == Constants.System.RootString) - { - // if the user's start node is not the root then the only menu item to display is refresh - if (UserStartNodes.Contains(Constants.System.Root) == false) - { - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - return menu; - } - - // root actions - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(LocalizedTextService, true, opensDialog: true); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - return menu; - } - - if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iid) == false) - { - return NotFound(); - } - var item = _entityService.Get(iid, UmbracoObjectTypes.Media); - if (item == null) - { - return NotFound(); - } - - //if the user has no path access for this node, all they can do is refresh - if (!_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasMediaPathAccess(item, _entityService, _appCaches) ?? false) + // if the user's start node is not the root then the only menu item to display is refresh + if (UserStartNodes.Contains(Constants.System.Root) == false) { menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } - - //if the media item is in the recycle bin, we don't have a default menu and we need to show a limited menu - if (item.Path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Contains(RecycleBinId.ToInvariantString())) - { - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - menu.DefaultMenuAlias = null; - - } - else - { - //return a normal node menu: - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(LocalizedTextService); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - //set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - } - + // root actions + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(LocalizedTextService, true, true); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } - protected override UmbracoObjectTypes UmbracoObjectType => UmbracoObjectTypes.Media; - - /// - /// Returns true or false if the current user has access to the node based on the user's allowed start node (path) access - /// - /// - /// - /// - protected override bool HasPathAccess(string id, FormCollection queryStrings) + if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iid) == false) { - var entity = GetEntityFromId(id); - - return HasPathAccess(entity, queryStrings); + return NotFound(); } - public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + IEntitySlim? item = _entityService.Get(iid, UmbracoObjectTypes.Media); + if (item == null) { - var results = _treeSearcher.ExamineSearch(query, UmbracoEntityTypes.Media, pageSize, pageIndex, out long totalFound, searchFrom); - return new EntitySearchResults(results, totalFound); + return NotFound(); } + //if the user has no path access for this node, all they can do is refresh + if (!_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasMediaPathAccess(item, _entityService, + _appCaches) ?? false) + { + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + return menu; + } + + + //if the media item is in the recycle bin, we don't have a default menu and we need to show a limited menu + if (item.Path.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries) + .Contains(RecycleBinId.ToInvariantString())) + { + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + + menu.DefaultMenuAlias = null; + } + else + { + //return a normal node menu: + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(LocalizedTextService); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + + //set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; + } + + return menu; + } + + /// + /// Returns true or false if the current user has access to the node based on the user's allowed start node (path) + /// access + /// + /// + /// + /// + protected override bool HasPathAccess(string id, FormCollection queryStrings) + { + IEntitySlim? entity = GetEntityFromId(id); + + return HasPathAccess(entity, queryStrings); } } diff --git a/src/Umbraco.Web.BackOffice/Trees/MediaTypeTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/MediaTypeTreeController.cs index 94b0ae76a5..1513a04a89 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MediaTypeTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MediaTypeTreeController.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -11,6 +7,7 @@ using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Trees; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; @@ -18,138 +15,151 @@ using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] +[Tree(Constants.Applications.Settings, Constants.Trees.MediaTypes, SortOrder = 1, + TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class MediaTypeTreeController : TreeController, ISearchableTree { - [Authorize(Policy = AuthorizationPolicies.TreeAccessMediaTypes)] - [Tree(Constants.Applications.Settings, Constants.Trees.MediaTypes, SortOrder = 1, TreeGroup = Constants.Trees.Groups.Settings)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class MediaTypeTreeController : TreeController, ISearchableTree - { - private readonly UmbracoTreeSearcher _treeSearcher; - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly IMediaTypeService _mediaTypeService; - private readonly IEntityService _entityService; + private readonly IEntityService _entityService; + private readonly IMediaTypeService _mediaTypeService; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + private readonly UmbracoTreeSearcher _treeSearcher; - public MediaTypeTreeController(ILocalizedTextService localizedTextService, UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, UmbracoTreeSearcher treeSearcher, IMenuItemCollectionFactory menuItemCollectionFactory, IMediaTypeService mediaTypeService, IEntityService entityService, IEventAggregator eventAggregator) : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + public MediaTypeTreeController(ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, UmbracoTreeSearcher treeSearcher, + IMenuItemCollectionFactory menuItemCollectionFactory, IMediaTypeService mediaTypeService, + IEntityService entityService, IEventAggregator eventAggregator) : base(localizedTextService, + umbracoApiControllerTypeCollection, eventAggregator) + { + _treeSearcher = treeSearcher; + _menuItemCollectionFactory = menuItemCollectionFactory; + _mediaTypeService = mediaTypeService; + _entityService = entityService; + } + + public async Task SearchAsync(string query, int pageSize, long pageIndex, + string? searchFrom = null) + { + IEnumerable results = _treeSearcher.EntitySearch(UmbracoObjectTypes.MediaType, query, + pageSize, pageIndex, out var totalFound, searchFrom); + return new EntitySearchResults(results, totalFound); + } + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) { - _treeSearcher = treeSearcher; - _menuItemCollectionFactory = menuItemCollectionFactory; - _mediaTypeService = mediaTypeService; - _entityService = entityService; + throw new InvalidOperationException("Id must be an integer"); } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + var nodes = new TreeNodeCollection(); + + nodes.AddRange( + _entityService.GetChildren(intId, UmbracoObjectTypes.MediaTypeContainer) + .OrderBy(entity => entity.Name) + .Select(dt => + { + TreeNode node = CreateTreeNode(dt.Id.ToString(), id, queryStrings, dt.Name, Constants.Icons.Folder, + dt.HasChildren, ""); + node.Path = dt.Path; + node.NodeType = "container"; + // TODO: This isn't the best way to ensure a no operation process for clicking a node but it works for now. + node.AdditionalData["jsClickCallback"] = "javascript:void(0);"; + return node; + })); + + // if the request is for folders only then just return + if (queryStrings["foldersonly"].ToString().IsNullOrWhiteSpace() == false && + queryStrings["foldersonly"].ToString() == "1") { - if (!int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId)) - { - throw new InvalidOperationException("Id must be an integer"); - } - - var nodes = new TreeNodeCollection(); - - nodes.AddRange( - _entityService.GetChildren(intId, UmbracoObjectTypes.MediaTypeContainer) - .OrderBy(entity => entity.Name) - .Select(dt => - { - var node = CreateTreeNode(dt.Id.ToString(), id, queryStrings, dt.Name, Constants.Icons.Folder, dt.HasChildren, ""); - node.Path = dt.Path; - node.NodeType = "container"; - // TODO: This isn't the best way to ensure a no operation process for clicking a node but it works for now. - node.AdditionalData["jsClickCallback"] = "javascript:void(0);"; - return node; - })); - - // if the request is for folders only then just return - if (queryStrings["foldersonly"].ToString().IsNullOrWhiteSpace() == false && queryStrings["foldersonly"].ToString() == "1") return nodes; - - var mediaTypes = _mediaTypeService.GetAll(); - - nodes.AddRange( - _entityService.GetChildren(intId, UmbracoObjectTypes.MediaType) - .OrderBy(entity => entity.Name) - .Select(dt => - { - // since 7.4+ child type creation is enabled by a config option. It defaults to on, but can be disabled if we decide to. - // need this check to keep supporting sites where children have already been created. - var hasChildren = dt.HasChildren; - var mt = mediaTypes.FirstOrDefault(x => x.Id == dt.Id); - var node = CreateTreeNode(dt, Constants.ObjectTypes.MediaType, id, queryStrings, mt?.Icon ?? Constants.Icons.MediaType, hasChildren); - - node.Path = dt.Path; - return node; - })); - return nodes; } - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + IEnumerable mediaTypes = _mediaTypeService.GetAll(); + + nodes.AddRange( + _entityService.GetChildren(intId, UmbracoObjectTypes.MediaType) + .OrderBy(entity => entity.Name) + .Select(dt => + { + // since 7.4+ child type creation is enabled by a config option. It defaults to on, but can be disabled if we decide to. + // need this check to keep supporting sites where children have already been created. + var hasChildren = dt.HasChildren; + IMediaType? mt = mediaTypes.FirstOrDefault(x => x.Id == dt.Id); + TreeNode node = CreateTreeNode(dt, Constants.ObjectTypes.MediaType, id, queryStrings, + mt?.Icon ?? Constants.Icons.MediaType, hasChildren); + + node.Path = dt.Path; + return node; + })); + + return nodes; + } + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + + if (id == Constants.System.RootString) { - var menu = _menuItemCollectionFactory.Create(); - - if (id == Constants.System.RootString) - { - // set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - - // root actions - menu.Items.Add(LocalizedTextService, opensDialog: true); - menu.Items.Add(new RefreshNode(LocalizedTextService)); - return menu; - } - - var container = _entityService.Get(int.Parse(id, CultureInfo.InvariantCulture), UmbracoObjectTypes.MediaTypeContainer); - if (container != null) - { - // set the default to create - menu.DefaultMenuAlias = ActionNew.ActionAlias; - - menu.Items.Add(LocalizedTextService, opensDialog: true); - - menu.Items.Add(new MenuItem("rename", LocalizedTextService.Localize("actions", "rename")) - { - Icon = "icon icon-edit" - }); - - if (container.HasChildren == false) - { - // can delete doc type - menu.Items.Add(LocalizedTextService, opensDialog: true); - } - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - } - else - { - var ct = _mediaTypeService.Get(int.Parse(id, CultureInfo.InvariantCulture)); - var parent = ct == null ? null : _mediaTypeService.Get(ct.ParentId); - - menu.Items.Add(LocalizedTextService, opensDialog: true); - - // no move action if this is a child doc type - if (parent == null) - { - menu.Items.Add(LocalizedTextService, true, opensDialog: true); - } - - menu.Items.Add(LocalizedTextService, opensDialog: true); - if(ct?.IsSystemMediaType() == false) - { - menu.Items.Add(LocalizedTextService, opensDialog: true); - } - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - } + // set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; + // root actions + menu.Items.Add(LocalizedTextService, opensDialog: true); + menu.Items.Add(new RefreshNode(LocalizedTextService)); return menu; } - public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + IEntitySlim? container = _entityService.Get(int.Parse(id, CultureInfo.InvariantCulture), + UmbracoObjectTypes.MediaTypeContainer); + if (container != null) { - var results = _treeSearcher.EntitySearch(UmbracoObjectTypes.MediaType, query, pageSize, pageIndex, out long totalFound, searchFrom); - return new EntitySearchResults(results, totalFound); + // set the default to create + menu.DefaultMenuAlias = ActionNew.ActionAlias; + + menu.Items.Add(LocalizedTextService, opensDialog: true); + + menu.Items.Add(new MenuItem("rename", LocalizedTextService.Localize("actions", "rename")) + { + Icon = "icon icon-edit" + }); + + if (container.HasChildren == false) + { + // can delete doc type + menu.Items.Add(LocalizedTextService, opensDialog: true); + } + + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); } + else + { + IMediaType? ct = _mediaTypeService.Get(int.Parse(id, CultureInfo.InvariantCulture)); + IMediaType? parent = ct == null ? null : _mediaTypeService.Get(ct.ParentId); + + menu.Items.Add(LocalizedTextService, opensDialog: true); + + // no move action if this is a child doc type + if (parent == null) + { + menu.Items.Add(LocalizedTextService, true, true); + } + + menu.Items.Add(LocalizedTextService, opensDialog: true); + if (ct?.IsSystemMediaType() == false) + { + menu.Items.Add(LocalizedTextService, opensDialog: true); + } + + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + } + + return menu; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/MemberGroupTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/MemberGroupTreeController.cs index bd5c22b147..f57675b888 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MemberGroupTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MemberGroupTreeController.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -12,69 +9,69 @@ using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.DependencyInjection; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessMemberGroups)] +[Tree(Constants.Applications.Members, Constants.Trees.MemberGroups, SortOrder = 1)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class MemberGroupTreeController : MemberTypeAndGroupTreeControllerBase { - [Authorize(Policy = AuthorizationPolicies.TreeAccessMemberGroups)] - [Tree(Constants.Applications.Members, Constants.Trees.MemberGroups, SortOrder = 1)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class MemberGroupTreeController : MemberTypeAndGroupTreeControllerBase + private readonly IMemberGroupService _memberGroupService; + + [ + ActivatorUtilitiesConstructor] + public MemberGroupTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IMemberGroupService memberGroupService, + IEventAggregator eventAggregator, + IMemberTypeService memberTypeService) + : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator, + memberTypeService) + => _memberGroupService = memberGroupService; + + [Obsolete("Use ctor with all params")] + public MemberGroupTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IMemberGroupService memberGroupService, + IEventAggregator eventAggregator) + : this(localizedTextService, + umbracoApiControllerTypeCollection, + menuItemCollectionFactory, + memberGroupService, + eventAggregator, + StaticServiceProvider.Instance.GetRequiredService()) { - private readonly IMemberGroupService _memberGroupService; + } - [ - ActivatorUtilitiesConstructor] - public MemberGroupTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IMemberGroupService memberGroupService, - IEventAggregator eventAggregator, - IMemberTypeService memberTypeService) - : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator, memberTypeService) - => _memberGroupService = memberGroupService; - [Obsolete("Use ctor with all params")] - public MemberGroupTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IMemberGroupService memberGroupService, - IEventAggregator eventAggregator) - : this(localizedTextService, - umbracoApiControllerTypeCollection, - menuItemCollectionFactory, - memberGroupService, - eventAggregator, - StaticServiceProvider.Instance.GetRequiredService()) + protected override IEnumerable GetTreeNodesFromService(string id, FormCollection queryStrings) + => _memberGroupService.GetAll() + .OrderBy(x => x.Name) + .Select(dt => + CreateTreeNode(dt.Id.ToString(), id, queryStrings, dt.Name, Constants.Icons.MemberGroup, false)); + + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - + return rootResult.Result; } + TreeNode? root = rootResult.Value; - protected override IEnumerable GetTreeNodesFromService(string id, FormCollection queryStrings) - => _memberGroupService.GetAll() - .OrderBy(x => x.Name) - .Select(dt => CreateTreeNode(dt.Id.ToString(), id, queryStrings, dt.Name, Constants.Icons.MemberGroup, false)); - - protected override ActionResult CreateRootNode(FormCollection queryStrings) + if (root is not null) { - ActionResult rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult.Result; - } - TreeNode? root = rootResult.Value; - - if (root is not null) - { - // Check if there are any groups - root.HasChildren = _memberGroupService.GetAll().Any(); - } - - return root; + // Check if there are any groups + root.HasChildren = _memberGroupService.GetAll().Any(); } + + return root; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/MemberTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/MemberTreeController.cs index 5cf469ded5..088ef8c33e 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MemberTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MemberTreeController.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -19,148 +15,161 @@ using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.ModelBinders; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.SectionAccessForMemberTree)] +[Tree(Constants.Applications.Members, Constants.Trees.Members, SortOrder = 0)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +[SearchableTree("searchResultFormatter", "configureMemberResult")] +public class MemberTreeController : TreeController, ISearchableTree, ITreeNodeController { - [Authorize(Policy = AuthorizationPolicies.SectionAccessForMemberTree)] - [Tree(Constants.Applications.Members, Constants.Trees.Members, SortOrder = 0)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - [SearchableTree("searchResultFormatter", "configureMemberResult")] - public class MemberTreeController : TreeController, ISearchableTree, ITreeNodeController + private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + private readonly IMemberService _memberService; + private readonly IMemberTypeService _memberTypeService; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + private readonly UmbracoTreeSearcher _treeSearcher; + + public MemberTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + UmbracoTreeSearcher treeSearcher, + IMenuItemCollectionFactory menuItemCollectionFactory, + IMemberService memberService, + IMemberTypeService memberTypeService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) { - private readonly UmbracoTreeSearcher _treeSearcher; - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly IMemberService _memberService; - private readonly IMemberTypeService _memberTypeService; - private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; + _treeSearcher = treeSearcher; + _menuItemCollectionFactory = menuItemCollectionFactory; + _memberService = memberService; + _memberTypeService = memberTypeService; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + } - public MemberTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - UmbracoTreeSearcher treeSearcher, - IMenuItemCollectionFactory menuItemCollectionFactory, - IMemberService memberService, - IMemberTypeService memberTypeService, - IBackOfficeSecurityAccessor backofficeSecurityAccessor, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + { + IEnumerable results = _treeSearcher.ExamineSearch(query, UmbracoEntityTypes.Member, pageSize, pageIndex, out var totalFound, searchFrom); + return new EntitySearchResults(results, totalFound); + } + + /// + /// Gets an individual tree node + /// + public ActionResult GetTreeNode([FromRoute] string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection? queryStrings) + { + ActionResult node = GetSingleTreeNode(id, queryStrings); + + if (!(node.Result is null)) { - _treeSearcher = treeSearcher; - _menuItemCollectionFactory = menuItemCollectionFactory; - _memberService = memberService; - _memberTypeService = memberTypeService; - _backofficeSecurityAccessor = backofficeSecurityAccessor; + return node.Result; } - /// - /// Gets an individual tree node - /// - public ActionResult GetTreeNode([FromRoute]string id, [ModelBinder(typeof(HttpQueryStringModelBinder))]FormCollection? queryStrings) + if (node.Value is not null) { - ActionResult node = GetSingleTreeNode(id, queryStrings); - - if (!(node.Result is null)) - { - return node.Result; - } - - if (node.Value is not null) - { - // Add the tree alias to the node since it is standalone (has no root for which this normally belongs) - node.Value.AdditionalData["treeAlias"] = TreeAlias; - } - - return node; + // Add the tree alias to the node since it is standalone (has no root for which this normally belongs) + node.Value.AdditionalData["treeAlias"] = TreeAlias; } - protected ActionResult GetSingleTreeNode(string id, FormCollection? queryStrings) + return node; + } + + protected ActionResult GetSingleTreeNode(string id, FormCollection? queryStrings) + { + if (Guid.TryParse(id, out Guid asGuid) == false) { - Guid asGuid; - if (Guid.TryParse(id, out asGuid) == false) - { - return NotFound(); - } - - var member = _memberService.GetByKey(asGuid); - if (member == null) - { - return NotFound(); - } - - var node = CreateTreeNode( - member.Key.ToString("N"), - "-1", - queryStrings, - member.Name, - Constants.Icons.Member, - false, - "", - Udi.Create(ObjectTypes.GetUdiType(Constants.ObjectTypes.Member), member.Key)); - - node.AdditionalData.Add("contentType", member.ContentTypeAlias); - node.AdditionalData.Add("isContainer", true); - - return node; + return NotFound(); } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + IMember? member = _memberService.GetByKey(asGuid); + if (member == null) { - var nodes = new TreeNodeCollection(); - - if (id == Constants.System.RootString) - { - nodes.Add( - CreateTreeNode(Constants.Conventions.MemberTypes.AllMembersListId, id, queryStrings, LocalizedTextService.Localize("member","allMembers"), Constants.Icons.MemberType, true, - queryStrings.GetRequiredValue("application") + TreeAlias.EnsureStartsWith('/') + "/list/" + Constants.Conventions.MemberTypes.AllMembersListId)); - - nodes.AddRange(_memberTypeService.GetAll() - .Select(memberType => - CreateTreeNode(memberType.Alias, id, queryStrings, memberType.Name, memberType.Icon.IfNullOrWhiteSpace(Constants.Icons.Member), true, - queryStrings.GetRequiredValue("application") + TreeAlias.EnsureStartsWith('/') + "/list/" + memberType.Alias))); - } - - //There is no menu for any of these nodes - nodes.ForEach(x => x.MenuUrl = null); - //All nodes are containers - nodes.ForEach(x => x.AdditionalData.Add("isContainer", true)); - - return nodes; + return NotFound(); } - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + TreeNode node = CreateTreeNode( + member.Key.ToString("N"), + "-1", + queryStrings, + member.Name, + Constants.Icons.Member, + false, + string.Empty, + Udi.Create(ObjectTypes.GetUdiType(Constants.ObjectTypes.Member), member.Key)); + + node.AdditionalData.Add("contentType", member.ContentTypeAlias); + node.AdditionalData.Add("isContainer", true); + + return node; + } + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + var nodes = new TreeNodeCollection(); + + if (id == Constants.System.RootString) { - var menu = _menuItemCollectionFactory.Create(); + nodes.Add( + CreateTreeNode( + Constants.Conventions.MemberTypes.AllMembersListId, + id, + queryStrings, + LocalizedTextService.Localize("member", "allMembers"), + Constants.Icons.MemberType, + true, + queryStrings.GetRequiredValue("application") + + TreeAlias.EnsureStartsWith('/') + + "/list/" + + Constants.Conventions.MemberTypes.AllMembersListId)); - if (id == Constants.System.RootString) - { - // root actions - //set default - menu.DefaultMenuAlias = ActionNew.ActionAlias; + nodes.AddRange(_memberTypeService.GetAll() + .Select(memberType => + CreateTreeNode( + memberType.Alias, + id, + queryStrings, + memberType.Name, + memberType.Icon.IfNullOrWhiteSpace(Constants.Icons.Member), + true, + queryStrings.GetRequiredValue("application") + TreeAlias.EnsureStartsWith('/') + + "/list/" + memberType.Alias))); + } - //Create the normal create action - menu.Items.Add(LocalizedTextService, opensDialog: true); + //There is no menu for any of these nodes + nodes.ForEach(x => x.MenuUrl = null); + //All nodes are containers + nodes.ForEach(x => x.AdditionalData.Add("isContainer", true)); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - return menu; - } + return nodes; + } - //add delete option for all members - menu.Items.Add(LocalizedTextService, opensDialog: true); + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); - if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasAccessToSensitiveData() ?? false) - { - menu.Items.Add(new ExportMember(LocalizedTextService)); - } + if (id == Constants.System.RootString) + { + // root actions + //set default + menu.DefaultMenuAlias = ActionNew.ActionAlias; + //Create the normal create action + menu.Items.Add(LocalizedTextService, opensDialog: true); + + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } - public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + //add delete option for all members + menu.Items.Add(LocalizedTextService, opensDialog: true); + + if (_backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.HasAccessToSensitiveData() ?? false) { - var results = _treeSearcher.ExamineSearch(query, UmbracoEntityTypes.Member, pageSize, pageIndex, out long totalFound, searchFrom); - return new EntitySearchResults(results, totalFound); + menu.Items.Add(new ExportMember(LocalizedTextService)); } + + return menu; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/MemberTypeAndGroupTreeControllerBase.cs b/src/Umbraco.Web.BackOffice/Trees/MemberTypeAndGroupTreeControllerBase.cs index 64e8081dec..d935fe6d31 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MemberTypeAndGroupTreeControllerBase.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MemberTypeAndGroupTreeControllerBase.cs @@ -1,95 +1,93 @@ -using System; -using System.Collections.Generic; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Trees; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public abstract class MemberTypeAndGroupTreeControllerBase : TreeController { - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public abstract class MemberTypeAndGroupTreeControllerBase : TreeController + private readonly IMemberTypeService _memberTypeService; + + protected MemberTypeAndGroupTreeControllerBase( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IEventAggregator eventAggregator, + IMemberTypeService memberTypeService) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) { - private readonly IMemberTypeService _memberTypeService; + MenuItemCollectionFactory = menuItemCollectionFactory; - public IMenuItemCollectionFactory MenuItemCollectionFactory { get; } + _memberTypeService = memberTypeService; + } - protected MemberTypeAndGroupTreeControllerBase( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IEventAggregator eventAggregator, - IMemberTypeService memberTypeService) - : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + [Obsolete("Use ctor injecting IMemberTypeService")] + protected MemberTypeAndGroupTreeControllerBase( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IEventAggregator eventAggregator) + : this( + localizedTextService, + umbracoApiControllerTypeCollection, + menuItemCollectionFactory, + eventAggregator, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public IMenuItemCollectionFactory MenuItemCollectionFactory { get; } + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + var nodes = new TreeNodeCollection(); + + // if the request is for folders only then just return + if (queryStrings["foldersonly"].ToString().IsNullOrWhiteSpace() == false && + queryStrings["foldersonly"].ToString() == "1") { - MenuItemCollectionFactory = menuItemCollectionFactory; - - _memberTypeService = memberTypeService; - } - - [Obsolete("Use ctor injecting IMemberTypeService")] - protected MemberTypeAndGroupTreeControllerBase( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IEventAggregator eventAggregator) - : this( - localizedTextService, - umbracoApiControllerTypeCollection, - menuItemCollectionFactory, - eventAggregator, - StaticServiceProvider.Instance.GetRequiredService()) - { - } - - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) - { - var nodes = new TreeNodeCollection(); - - // if the request is for folders only then just return - if (queryStrings["foldersonly"].ToString().IsNullOrWhiteSpace() == false && queryStrings["foldersonly"].ToString() == "1") - return nodes; - - nodes.AddRange(GetTreeNodesFromService(id, queryStrings)); return nodes; } - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + nodes.AddRange(GetTreeNodesFromService(id, queryStrings)); + return nodes; + } + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = MenuItemCollectionFactory.Create(); + + if (id == Constants.System.RootString) { - var menu = MenuItemCollectionFactory.Create(); - - if (id == Constants.System.RootString) - { - // root actions - menu.Items.Add(new CreateChildEntity(LocalizedTextService)); - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - return menu; - } - else - { - var memberType = _memberTypeService.Get(int.Parse(id)); - if (memberType != null) - { - menu.Items.Add(LocalizedTextService, opensDialog: true); - } - - // delete member type/group - menu.Items.Add(LocalizedTextService, opensDialog: true); - } - + // root actions + menu.Items.Add(new CreateChildEntity(LocalizedTextService)); + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } - protected abstract IEnumerable GetTreeNodesFromService(string id, FormCollection queryStrings); + IMemberType? memberType = _memberTypeService.Get(int.Parse(id)); + if (memberType != null) + { + menu.Items.Add(LocalizedTextService, opensDialog: true); + } + + // delete member type/group + menu.Items.Add(LocalizedTextService, opensDialog: true); + + return menu; } + + protected abstract IEnumerable GetTreeNodesFromService(string id, FormCollection queryStrings); } diff --git a/src/Umbraco.Web.BackOffice/Trees/MemberTypeTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/MemberTypeTreeController.cs index 22d110b4fc..5325e7728e 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MemberTypeTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MemberTypeTreeController.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -13,61 +10,63 @@ using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[CoreTree] +[Authorize(Policy = AuthorizationPolicies.TreeAccessMemberTypes)] +[Tree(Constants.Applications.Settings, Constants.Trees.MemberTypes, SortOrder = 2, + TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +public class MemberTypeTreeController : MemberTypeAndGroupTreeControllerBase, ISearchableTree { - [CoreTree] - [Authorize(Policy = AuthorizationPolicies.TreeAccessMemberTypes)] - [Tree(Constants.Applications.Settings, Constants.Trees.MemberTypes, SortOrder = 2, TreeGroup = Constants.Trees.Groups.Settings)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - public class MemberTypeTreeController : MemberTypeAndGroupTreeControllerBase, ISearchableTree + private readonly IMemberTypeService _memberTypeService; + private readonly UmbracoTreeSearcher _treeSearcher; + + public MemberTypeTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + UmbracoTreeSearcher treeSearcher, + IMemberTypeService memberTypeService, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator, + memberTypeService) { - private readonly UmbracoTreeSearcher _treeSearcher; - private readonly IMemberTypeService _memberTypeService; - - public MemberTypeTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - UmbracoTreeSearcher treeSearcher, - IMemberTypeService memberTypeService, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator, memberTypeService) - { - _treeSearcher = treeSearcher; - _memberTypeService = memberTypeService; - } - - protected override ActionResult CreateRootNode(FormCollection queryStrings) - { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; - - if (root is not null) - { - // Check if there are any member types - root.HasChildren = _memberTypeService.GetAll().Any(); - } - - return root; - } - - protected override IEnumerable GetTreeNodesFromService(string id, FormCollection queryStrings) - { - return _memberTypeService.GetAll() - .OrderBy(x => x.Name) - .Select(dt => CreateTreeNode(dt, Constants.ObjectTypes.MemberType, id, queryStrings, dt?.Icon ?? Constants.Icons.MemberType, false)); - } - - public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) - { - var results = _treeSearcher.EntitySearch(UmbracoObjectTypes.MemberType, query, pageSize, pageIndex, out long totalFound, searchFrom); - return new EntitySearchResults(results, totalFound); - } + _treeSearcher = treeSearcher; + _memberTypeService = memberTypeService; } + + public async Task SearchAsync(string query, int pageSize, long pageIndex, + string? searchFrom = null) + { + IEnumerable results = _treeSearcher.EntitySearch(UmbracoObjectTypes.MemberType, query, + pageSize, pageIndex, out var totalFound, searchFrom); + return new EntitySearchResults(results, totalFound); + } + + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) + { + return rootResult; + } + + TreeNode? root = rootResult.Value; + + if (root is not null) + { + // Check if there are any member types + root.HasChildren = _memberTypeService.GetAll().Any(); + } + + return root; + } + + protected override IEnumerable GetTreeNodesFromService(string id, FormCollection queryStrings) => + _memberTypeService.GetAll() + .OrderBy(x => x.Name) + .Select(dt => CreateTreeNode(dt, Constants.ObjectTypes.MemberType, id, queryStrings, + dt?.Icon ?? Constants.Icons.MemberType, false)); } diff --git a/src/Umbraco.Web.BackOffice/Trees/MenuRenderingNotification.cs b/src/Umbraco.Web.BackOffice/Trees/MenuRenderingNotification.cs index 71987360f4..b46278be4a 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MenuRenderingNotification.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MenuRenderingNotification.cs @@ -1,42 +1,42 @@ using Microsoft.AspNetCore.Http; using Umbraco.Cms.Core.Trees; -namespace Umbraco.Cms.Core.Notifications +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that allows developers to modify the menu that is being rendered +/// +/// +/// Developers can add/remove/replace/insert/update/etc... any of the tree items in the collection. +/// +public class MenuRenderingNotification : INotification { - /// - /// A notification that allows developers to modify the menu that is being rendered - /// - /// - /// Developers can add/remove/replace/insert/update/etc... any of the tree items in the collection. - /// - public class MenuRenderingNotification : INotification + public MenuRenderingNotification(string nodeId, MenuItemCollection menu, FormCollection queryString, + string treeAlias) { - /// - /// The tree node id that the menu is rendering for - /// - public string NodeId { get; } - - /// - /// The alias of the tree the menu is rendering for - /// - public string TreeAlias { get; } - - /// - /// The menu being rendered - /// - public MenuItemCollection Menu { get; } - - /// - /// The query string of the current request - /// - public FormCollection QueryString { get; } - - public MenuRenderingNotification(string nodeId, MenuItemCollection menu, FormCollection queryString, string treeAlias) - { - NodeId = nodeId; - Menu = menu; - QueryString = queryString; - TreeAlias = treeAlias; - } + NodeId = nodeId; + Menu = menu; + QueryString = queryString; + TreeAlias = treeAlias; } + + /// + /// The tree node id that the menu is rendering for + /// + public string NodeId { get; } + + /// + /// The alias of the tree the menu is rendering for + /// + public string TreeAlias { get; } + + /// + /// The menu being rendered + /// + public MenuItemCollection Menu { get; } + + /// + /// The query string of the current request + /// + public FormCollection QueryString { get; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/PackagesTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/PackagesTreeController.cs index f9b0adca15..88f04ba823 100644 --- a/src/Umbraco.Web.BackOffice/Trees/PackagesTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/PackagesTreeController.cs @@ -7,65 +7,58 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessPackages)] +[Tree(Constants.Applications.Packages, Constants.Trees.Packages, SortOrder = 0, IsSingleNodeTree = true)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class PackagesTreeController : TreeController { - [Authorize(Policy = AuthorizationPolicies.TreeAccessPackages)] - [Tree(Constants.Applications.Packages, Constants.Trees.Packages, SortOrder = 0, IsSingleNodeTree = true)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class PackagesTreeController : TreeController + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + + public PackagesTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) => + _menuItemCollectionFactory = menuItemCollectionFactory; + + + /// + /// Helper method to create a root model for a tree + /// + /// + protected override ActionResult CreateRootNode(FormCollection queryStrings) { - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - - public PackagesTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - _menuItemCollectionFactory = menuItemCollectionFactory; + return rootResult; } + TreeNode? root = rootResult.Value; - /// - /// Helper method to create a root model for a tree - /// - /// - protected override ActionResult CreateRootNode(FormCollection queryStrings) + if (root is not null) { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; + // This will load in a custom UI instead of the dashboard for the root node + root.RoutePath = $"{Constants.Applications.Packages}/{Constants.Trees.Packages}/repo"; + root.Icon = Constants.Icons.Packages; - if (root is not null) - { - // This will load in a custom UI instead of the dashboard for the root node - root.RoutePath = $"{Constants.Applications.Packages}/{Constants.Trees.Packages}/repo"; - root.Icon = Constants.Icons.Packages; - - root.HasChildren = false; - } - - return root; + root.HasChildren = false; } - - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) - { - //full screen app without tree nodes - return TreeNodeCollection.Empty; - } - - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) - { - //doesn't have a menu, this is a full screen app without tree nodes - return _menuItemCollectionFactory.Create(); - } + return root; } + + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) => + //full screen app without tree nodes + TreeNodeCollection.Empty; + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) => + //doesn't have a menu, this is a full screen app without tree nodes + _menuItemCollectionFactory.Create(); } diff --git a/src/Umbraco.Web.BackOffice/Trees/PartialViewMacrosTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/PartialViewMacrosTreeController.cs index 2a27273f8c..6f27c2bc60 100644 --- a/src/Umbraco.Web.BackOffice/Trees/PartialViewMacrosTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/PartialViewMacrosTreeController.cs @@ -6,36 +6,32 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// Tree for displaying partial view macros in the developer app +/// +[Tree(Constants.Applications.Settings, Constants.Trees.PartialViewMacros, SortOrder = 8, TreeGroup = Constants.Trees.Groups.Templating)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessPartialViewMacros)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class PartialViewMacrosTreeController : PartialViewsTreeController { - /// - /// Tree for displaying partial view macros in the developer app - /// - [Tree(Constants.Applications.Settings, Constants.Trees.PartialViewMacros, SortOrder = 8, TreeGroup = Constants.Trees.Groups.Templating)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessPartialViewMacros)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class PartialViewMacrosTreeController : PartialViewsTreeController - { - protected override IFileSystem? FileSystem { get; } + private static readonly string[] ExtensionsStatic = { "cshtml" }; - private static readonly string[] ExtensionsStatic = {"cshtml"}; + public PartialViewMacrosTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + FileSystems fileSystems, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, fileSystems, eventAggregator) => + FileSystem = fileSystems.MacroPartialsFileSystem; - protected override string[] Extensions => ExtensionsStatic; + protected override IFileSystem? FileSystem { get; } - protected override string FileIcon => "icon-article"; + protected override string[] Extensions => ExtensionsStatic; - public PartialViewMacrosTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - FileSystems fileSystems, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, fileSystems, eventAggregator) - { - FileSystem = fileSystems.MacroPartialsFileSystem; - } - } + protected override string FileIcon => "icon-article"; } diff --git a/src/Umbraco.Web.BackOffice/Trees/PartialViewsTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/PartialViewsTreeController.cs index 78cecea307..d18beb6047 100644 --- a/src/Umbraco.Web.BackOffice/Trees/PartialViewsTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/PartialViewsTreeController.cs @@ -6,36 +6,32 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// Tree for displaying partial views in the settings app +/// +[Tree(Constants.Applications.Settings, Constants.Trees.PartialViews, SortOrder = 7, TreeGroup = Constants.Trees.Groups.Templating)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessPartialViews)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class PartialViewsTreeController : FileSystemTreeController { - /// - /// Tree for displaying partial views in the settings app - /// - [Tree(Constants.Applications.Settings, Constants.Trees.PartialViews, SortOrder = 7, TreeGroup = Constants.Trees.Groups.Templating)] - [Authorize(Policy = AuthorizationPolicies.TreeAccessPartialViews)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class PartialViewsTreeController : FileSystemTreeController - { - protected override IFileSystem? FileSystem { get; } + private static readonly string[] ExtensionsStatic = { "cshtml" }; - private static readonly string[] ExtensionsStatic = {"cshtml"}; + public PartialViewsTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + FileSystems fileSystems, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator) => + FileSystem = fileSystems.PartialViewsFileSystem; - protected override string[] Extensions => ExtensionsStatic; + protected override IFileSystem? FileSystem { get; } - protected override string FileIcon => "icon-article"; + protected override string[] Extensions => ExtensionsStatic; - public PartialViewsTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - FileSystems fileSystems, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator) - { - FileSystem = fileSystems.PartialViewsFileSystem; - } - } + protected override string FileIcon => "icon-article"; } diff --git a/src/Umbraco.Web.BackOffice/Trees/RelationTypeTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/RelationTypeTreeController.cs index 22f96fa7c8..495d7fef07 100644 --- a/src/Umbraco.Web.BackOffice/Trees/RelationTypeTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/RelationTypeTreeController.cs @@ -1,80 +1,82 @@ using System.Globalization; -using System.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Trees; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessRelationTypes)] +[Tree(Constants.Applications.Settings, Constants.Trees.RelationTypes, SortOrder = 5, + TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class RelationTypeTreeController : TreeController { - [Authorize(Policy = AuthorizationPolicies.TreeAccessRelationTypes)] - [Tree(Constants.Applications.Settings, Constants.Trees.RelationTypes, SortOrder = 5, TreeGroup = Constants.Trees.Groups.Settings)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class RelationTypeTreeController : TreeController + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + private readonly IRelationService _relationService; + + public RelationTypeTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + IRelationService relationService, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) { - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly IRelationService _relationService; + _menuItemCollectionFactory = menuItemCollectionFactory; + _relationService = relationService; + } - public RelationTypeTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - IRelationService relationService, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + + if (id == Constants.System.RootString) { - _menuItemCollectionFactory = menuItemCollectionFactory; - _relationService = relationService; - } + //Create the normal create action + menu.Items.Add(LocalizedTextService); - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) - { - var menu = _menuItemCollectionFactory.Create(); - - if (id == Constants.System.RootString) - { - //Create the normal create action - menu.Items.Add(LocalizedTextService); - - //refresh action - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - return menu; - } - - var relationType = _relationService.GetRelationTypeById(int.Parse(id, CultureInfo.InvariantCulture)); - if (relationType == null) return menu; - - if (relationType.IsSystemRelationType() == false) - { - menu.Items.Add(LocalizedTextService); - } + //refresh action + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); return menu; } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + IRelationType? relationType = _relationService.GetRelationTypeById(int.Parse(id, CultureInfo.InvariantCulture)); + if (relationType == null) { - var nodes = new TreeNodeCollection(); - - if (id == Constants.System.RootString) - { - nodes.AddRange(_relationService.GetAllRelationTypes() - .Select(rt => CreateTreeNode(rt.Id.ToString(), id, queryStrings, rt.Name, - "icon-trafic", false))); - } - - return nodes; + return menu; } + + if (relationType.IsSystemRelationType() == false) + { + menu.Items.Add(LocalizedTextService); + } + + return menu; + } + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + var nodes = new TreeNodeCollection(); + + if (id == Constants.System.RootString) + { + nodes.AddRange(_relationService.GetAllRelationTypes() + .Select(rt => CreateTreeNode(rt.Id.ToString(), id, queryStrings, rt.Name, + "icon-trafic", false))); + } + + return nodes; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/RootNodeRenderingNotification.cs b/src/Umbraco.Web.BackOffice/Trees/RootNodeRenderingNotification.cs index 86d03c0d86..3348bdc7ff 100644 --- a/src/Umbraco.Web.BackOffice/Trees/RootNodeRenderingNotification.cs +++ b/src/Umbraco.Web.BackOffice/Trees/RootNodeRenderingNotification.cs @@ -1,33 +1,32 @@ using Microsoft.AspNetCore.Http; using Umbraco.Cms.Core.Trees; -namespace Umbraco.Cms.Core.Notifications +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that allows developer to modify the root tree node that is being rendered +/// +public class RootNodeRenderingNotification : INotification { - /// - /// A notification that allows developer to modify the root tree node that is being rendered - /// - public class RootNodeRenderingNotification : INotification + public RootNodeRenderingNotification(TreeNode node, FormCollection queryString, string treeAlias) { - /// - /// The root node being rendered - /// - public TreeNode Node { get; } - - /// - /// The alias of the tree the menu is rendering for - /// - public string TreeAlias { get; } - - /// - /// The query string of the current request - /// - public FormCollection QueryString { get; } - - public RootNodeRenderingNotification(TreeNode node, FormCollection queryString, string treeAlias) - { - Node = node; - QueryString = queryString; - TreeAlias = treeAlias; - } + Node = node; + QueryString = queryString; + TreeAlias = treeAlias; } + + /// + /// The root node being rendered + /// + public TreeNode Node { get; } + + /// + /// The alias of the tree the menu is rendering for + /// + public string TreeAlias { get; } + + /// + /// The query string of the current request + /// + public FormCollection QueryString { get; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/ScriptsTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ScriptsTreeController.cs index 7d87b5cca9..630584a839 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ScriptsTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ScriptsTreeController.cs @@ -3,31 +3,27 @@ using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[CoreTree] +[Tree(Constants.Applications.Settings, Constants.Trees.Scripts, TreeTitle = "Scripts", SortOrder = 10, TreeGroup = Constants.Trees.Groups.Templating)] +public class ScriptsTreeController : FileSystemTreeController { - [CoreTree] - [Tree(Constants.Applications.Settings, Constants.Trees.Scripts, TreeTitle = "Scripts", SortOrder = 10, TreeGroup = Constants.Trees.Groups.Templating)] - public class ScriptsTreeController : FileSystemTreeController - { - protected override IFileSystem? FileSystem { get; } + private static readonly string[] ExtensionsStatic = { "js" }; - private static readonly string[] ExtensionsStatic = { "js" }; + public ScriptsTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + FileSystems fileSystems, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator) => + FileSystem = fileSystems.ScriptsFileSystem; - protected override string[] Extensions => ExtensionsStatic; + protected override IFileSystem? FileSystem { get; } - protected override string FileIcon => "icon-script"; + protected override string[] Extensions => ExtensionsStatic; - public ScriptsTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - FileSystems fileSystems, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator) - { - FileSystem = fileSystems.ScriptsFileSystem; - } - } + protected override string FileIcon => "icon-script"; } diff --git a/src/Umbraco.Web.BackOffice/Trees/StaticFilesTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/StaticFilesTreeController.cs index 55b67f18fe..7e2b21f9c6 100644 --- a/src/Umbraco.Web.BackOffice/Trees/StaticFilesTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/StaticFilesTreeController.cs @@ -1,5 +1,3 @@ -using System.IO; -using System.Linq; using System.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -10,75 +8,77 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Tree(Constants.Applications.Settings, "staticFiles", TreeTitle = "Static Files", TreeUse = TreeUse.Dialog)] +public class StaticFilesTreeController : TreeController { - [Tree(Constants.Applications.Settings, "staticFiles", TreeTitle = "Static Files", TreeUse = TreeUse.Dialog)] - public class StaticFilesTreeController : TreeController + private const string AppPlugins = "App_Plugins"; + private const string Webroot = "wwwroot"; + private readonly IFileSystem _fileSystem; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + + public StaticFilesTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IEventAggregator eventAggregator, + IPhysicalFileSystem fileSystem, + IMenuItemCollectionFactory menuItemCollectionFactory) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) { - private readonly IFileSystem _fileSystem; - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - - private const string AppPlugins = "App_Plugins"; - private const string Webroot = "wwwroot"; - - public StaticFilesTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IEventAggregator eventAggregator, - IPhysicalFileSystem fileSystem, - IMenuItemCollectionFactory menuItemCollectionFactory) - : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) - { - _fileSystem = fileSystem; - _menuItemCollectionFactory = menuItemCollectionFactory; - } - - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) - { - var path = string.IsNullOrEmpty(id) == false && id != Constants.System.RootString - ? WebUtility.UrlDecode(id).TrimStart("/") - : ""; - - var nodes = new TreeNodeCollection(); - var directories = _fileSystem.GetDirectories(path); - - foreach (var directory in directories) - { - // We don't want any other directories under the root node other than the ones serving static files - App_Plugins and wwwroot - if (id == Constants.System.RootString && directory != AppPlugins && directory != Webroot) - { - continue; - } - - var hasChildren = _fileSystem.GetFiles(directory).Any() || _fileSystem.GetDirectories(directory).Any(); - - var name = Path.GetFileName(directory); - var node = CreateTreeNode(WebUtility.UrlEncode(directory), path, queryStrings, name, Constants.Icons.Folder, hasChildren); - - if (node != null) - { - nodes.Add(node); - } - } - - // Only get the files inside App_Plugins and wwwroot - var files = _fileSystem.GetFiles(path).Where(x => x.StartsWith(AppPlugins) || x.StartsWith(Webroot)); - - foreach (var file in files) - { - var name = Path.GetFileName(file); - var node = CreateTreeNode(WebUtility.UrlEncode(file), path, queryStrings, name, Constants.Icons.DefaultIcon, false); - - if (node != null) - { - nodes.Add(node); - } - } - - return nodes; - } - - // We don't have any menu item options (such as create/delete/reload) & only use the root node to load a custom UI - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) => _menuItemCollectionFactory.Create(); + _fileSystem = fileSystem; + _menuItemCollectionFactory = menuItemCollectionFactory; } + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + var path = string.IsNullOrEmpty(id) == false && id != Constants.System.RootString + ? WebUtility.UrlDecode(id).TrimStart("/") + : ""; + + var nodes = new TreeNodeCollection(); + IEnumerable directories = _fileSystem.GetDirectories(path); + + foreach (var directory in directories) + { + // We don't want any other directories under the root node other than the ones serving static files - App_Plugins and wwwroot + if (id == Constants.System.RootString && directory != AppPlugins && directory != Webroot) + { + continue; + } + + var hasChildren = _fileSystem.GetFiles(directory).Any() || _fileSystem.GetDirectories(directory).Any(); + + var name = Path.GetFileName(directory); + TreeNode? node = CreateTreeNode(WebUtility.UrlEncode(directory), path, queryStrings, name, + Constants.Icons.Folder, hasChildren); + + if (node != null) + { + nodes.Add(node); + } + } + + // Only get the files inside App_Plugins and wwwroot + IEnumerable files = _fileSystem.GetFiles(path) + .Where(x => x.StartsWith(AppPlugins) || x.StartsWith(Webroot)); + + foreach (var file in files) + { + var name = Path.GetFileName(file); + TreeNode? node = CreateTreeNode(WebUtility.UrlEncode(file), path, queryStrings, name, + Constants.Icons.DefaultIcon, false); + + if (node != null) + { + nodes.Add(node); + } + } + + return nodes; + } + + // We don't have any menu item options (such as create/delete/reload) & only use the root node to load a custom UI + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) => + _menuItemCollectionFactory.Create(); } diff --git a/src/Umbraco.Web.BackOffice/Trees/StylesheetsTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/StylesheetsTreeController.cs index e1a89d6dce..3ff7a7ecfc 100644 --- a/src/Umbraco.Web.BackOffice/Trees/StylesheetsTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/StylesheetsTreeController.cs @@ -3,31 +3,27 @@ using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[CoreTree] +[Tree(Constants.Applications.Settings, Constants.Trees.Stylesheets, TreeTitle = "Stylesheets", SortOrder = 9, TreeGroup = Constants.Trees.Groups.Templating)] +public class StylesheetsTreeController : FileSystemTreeController { - [CoreTree] - [Tree(Constants.Applications.Settings, Constants.Trees.Stylesheets, TreeTitle = "Stylesheets", SortOrder = 9, TreeGroup = Constants.Trees.Groups.Templating)] - public class StylesheetsTreeController : FileSystemTreeController - { - protected override IFileSystem? FileSystem { get; } + private static readonly string[] ExtensionsStatic = { "css" }; - private static readonly string[] ExtensionsStatic = { "css" }; + public StylesheetsTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IMenuItemCollectionFactory menuItemCollectionFactory, + FileSystems fileSystems, + IEventAggregator eventAggregator) + : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator) => + FileSystem = fileSystems.StylesheetsFileSystem; - protected override string[] Extensions => ExtensionsStatic; + protected override IFileSystem? FileSystem { get; } - protected override string FileIcon => "icon-brackets"; + protected override string[] Extensions => ExtensionsStatic; - public StylesheetsTreeController( - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IMenuItemCollectionFactory menuItemCollectionFactory, - FileSystems fileSystems, - IEventAggregator eventAggregator) - : base(localizedTextService, umbracoApiControllerTypeCollection, menuItemCollectionFactory, eventAggregator) - { - FileSystem = fileSystems.StylesheetsFileSystem; - } - } + protected override string FileIcon => "icon-brackets"; } diff --git a/src/Umbraco.Web.BackOffice/Trees/TemplatesTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/TemplatesTreeController.cs index caf976e7f2..f0cd207f26 100644 --- a/src/Umbraco.Web.BackOffice/Trees/TemplatesTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/TemplatesTreeController.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -18,149 +15,155 @@ using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessTemplates)] +[Tree(Constants.Applications.Settings, Constants.Trees.Templates, SortOrder = 6, + TreeGroup = Constants.Trees.Groups.Templating)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class TemplatesTreeController : TreeController, ISearchableTree { - [Authorize(Policy = AuthorizationPolicies.TreeAccessTemplates)] - [Tree(Constants.Applications.Settings, Constants.Trees.Templates, SortOrder = 6, TreeGroup = Constants.Trees.Groups.Templating)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class TemplatesTreeController : TreeController, ISearchableTree + private readonly IFileService _fileService; + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + private readonly UmbracoTreeSearcher _treeSearcher; + + public TemplatesTreeController( + UmbracoTreeSearcher treeSearcher, + IMenuItemCollectionFactory menuItemCollectionFactory, + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IFileService fileService, + IEventAggregator eventAggregator + ) : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) { - private readonly UmbracoTreeSearcher _treeSearcher; - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - private readonly IFileService _fileService; + _treeSearcher = treeSearcher; + _menuItemCollectionFactory = menuItemCollectionFactory; + _fileService = fileService; + } - public TemplatesTreeController( - UmbracoTreeSearcher treeSearcher, - IMenuItemCollectionFactory menuItemCollectionFactory, - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IFileService fileService, - IEventAggregator eventAggregator - ) : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + public async Task SearchAsync(string query, int pageSize, long pageIndex, + string? searchFrom = null) + { + IEnumerable results = _treeSearcher.EntitySearch(UmbracoObjectTypes.Template, query, + pageSize, pageIndex, out var totalFound, searchFrom); + return new EntitySearchResults(results, totalFound); + } + + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - _treeSearcher = treeSearcher; - _menuItemCollectionFactory = menuItemCollectionFactory; - _fileService = fileService; + return rootResult; } - protected override ActionResult CreateRootNode(FormCollection queryStrings) + TreeNode? root = rootResult.Value; + + if (root is not null) { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult; - } - var root = rootResult.Value; - - if (root is not null) - { - //check if there are any templates - root.HasChildren = _fileService.GetTemplates(-1)?.Any() ?? false; - } - - return root; + //check if there are any templates + root.HasChildren = _fileService.GetTemplates(-1)?.Any() ?? false; } - /// - /// The method called to render the contents of the tree structure - /// - /// - /// - /// All of the query string parameters passed from jsTree - /// - /// - /// We are allowing an arbitrary number of query strings to be pased in so that developers are able to persist custom data from the front-end - /// to the back end to be used in the query for model data. - /// - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + return root; + } + + /// + /// The method called to render the contents of the tree structure + /// + /// + /// + /// All of the query string parameters passed from jsTree + /// + /// + /// We are allowing an arbitrary number of query strings to be pased in so that developers are able to persist custom + /// data from the front-end + /// to the back end to be used in the query for model data. + /// + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) + { + var nodes = new TreeNodeCollection(); + + IEnumerable? found = id == Constants.System.RootString + ? _fileService.GetTemplates(-1) + : _fileService.GetTemplates(int.Parse(id, CultureInfo.InvariantCulture)); + + if (found is not null) { - var nodes = new TreeNodeCollection(); - - var found = id == Constants.System.RootString - ? _fileService.GetTemplates(-1) - : _fileService.GetTemplates(int.Parse(id, CultureInfo.InvariantCulture)); - - if (found is not null) - { - nodes.AddRange(found.Select(template => CreateTreeNode( - template.Id.ToString(CultureInfo.InvariantCulture), - // TODO: Fix parent ID stuff for templates - "-1", - queryStrings, - template.Name, - template.IsMasterTemplate ? "icon-newspaper" : "icon-newspaper-alt", - template.IsMasterTemplate, - null, - Udi.Create(ObjectTypes.GetUdiType(Constants.ObjectTypes.TemplateType), template.Key) - ))); - } - - return nodes; + nodes.AddRange(found.Select(template => CreateTreeNode( + template.Id.ToString(CultureInfo.InvariantCulture), + // TODO: Fix parent ID stuff for templates + "-1", + queryStrings, + template.Name, + template.IsMasterTemplate ? "icon-newspaper" : "icon-newspaper-alt", + template.IsMasterTemplate, + null, + Udi.Create(ObjectTypes.GetUdiType(Constants.ObjectTypes.TemplateType), template.Key) + ))); } - /// - /// Returns the menu structure for the node - /// - /// - /// - /// - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + return nodes; + } + + /// + /// Returns the menu structure for the node + /// + /// + /// + /// + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) + { + MenuItemCollection menu = _menuItemCollectionFactory.Create(); + + //Create the normal create action + MenuItem? item = menu.Items.Add(LocalizedTextService, opensDialog: true); + item?.NavigateToRoute( + $"{queryStrings.GetRequiredValue("application")}/templates/edit/{id}?create=true"); + + if (id == Constants.System.RootString) { - var menu = _menuItemCollectionFactory.Create(); - - //Create the normal create action - var item = menu.Items.Add(LocalizedTextService, opensDialog: true); - item?.NavigateToRoute($"{queryStrings.GetRequiredValue("application")}/templates/edit/{id}?create=true"); - - if (id == Constants.System.RootString) - { - //refresh action - menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - - return menu; - } - - var template = _fileService.GetTemplate(int.Parse(id, CultureInfo.InvariantCulture)); - if (template == null) return menu; - var entity = FromTemplate(template); - - //don't allow delete if it has child layouts - if (template.IsMasterTemplate == false) - { - //add delete option if it doesn't have children - menu.Items.Add(LocalizedTextService, true, opensDialog: true); - } - - //add refresh + //refresh action menu.Items.Add(new RefreshNode(LocalizedTextService, true)); - return menu; } - private EntitySlim FromTemplate(ITemplate template) + ITemplate? template = _fileService.GetTemplate(int.Parse(id, CultureInfo.InvariantCulture)); + if (template == null) { - return new EntitySlim - { - CreateDate = template.CreateDate, - Id = template.Id, - Key = template.Key, - Name = template.Name, - NodeObjectType = Constants.ObjectTypes.Template, - // TODO: Fix parent/paths on templates - ParentId = -1, - Path = template.Path, - UpdateDate = template.UpdateDate - }; + return menu; } - public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null) + EntitySlim entity = FromTemplate(template); + + //don't allow delete if it has child layouts + if (template.IsMasterTemplate == false) { - var results = _treeSearcher.EntitySearch(UmbracoObjectTypes.Template, query, pageSize, pageIndex, out long totalFound, searchFrom); - return new EntitySearchResults(results, totalFound); + //add delete option if it doesn't have children + menu.Items.Add(LocalizedTextService, true, true); } + + //add refresh + menu.Items.Add(new RefreshNode(LocalizedTextService, true)); + + + return menu; } + + private EntitySlim FromTemplate(ITemplate template) => + new() + { + CreateDate = template.CreateDate, + Id = template.Id, + Key = template.Key, + Name = template.Name, + NodeObjectType = Constants.ObjectTypes.Template, + // TODO: Fix parent/paths on templates + ParentId = -1, + Path = template.Path, + UpdateDate = template.UpdateDate + }; } diff --git a/src/Umbraco.Web.BackOffice/Trees/TreeAttribute.cs b/src/Umbraco.Web.BackOffice/Trees/TreeAttribute.cs index bfd3b115ff..4428886312 100644 --- a/src/Umbraco.Web.BackOffice/Trees/TreeAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Trees/TreeAttribute.cs @@ -1,56 +1,54 @@ -using System; using Umbraco.Cms.Core.Trees; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// Identifies a section tree. +/// +[AttributeUsage(AttributeTargets.Class)] +public class TreeAttribute : Attribute, ITree { /// - /// Identifies a section tree. + /// Initializes a new instance of the class. /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public class TreeAttribute : Attribute, ITree + public TreeAttribute(string sectionAlias, string treeAlias) { - /// - /// Initializes a new instance of the class. - /// - public TreeAttribute(string sectionAlias, string treeAlias) - { - SectionAlias = sectionAlias; - TreeAlias = treeAlias; - } - - /// - /// Gets the section alias. - /// - public string SectionAlias { get; } - - /// - /// Gets the tree alias. - /// - public string TreeAlias { get; } - - /// - /// Gets or sets the tree title. - /// - public string? TreeTitle { get; set; } - - /// - /// Gets or sets the group of the tree. - /// - public string? TreeGroup { get; set; } - - /// - /// Gets the usage of the tree. - /// - public TreeUse TreeUse { get; set; } = TreeUse.Main | TreeUse.Dialog; - - /// - /// Gets or sets the tree sort order. - /// - public int SortOrder { get; set; } - - /// - /// Gets or sets a value indicating whether the tree is a single-node tree (no child nodes, full screen app). - /// - public bool IsSingleNodeTree { get; set; } + SectionAlias = sectionAlias; + TreeAlias = treeAlias; } + + /// + /// Gets the section alias. + /// + public string SectionAlias { get; } + + /// + /// Gets the tree alias. + /// + public string TreeAlias { get; } + + /// + /// Gets or sets the tree title. + /// + public string? TreeTitle { get; set; } + + /// + /// Gets or sets the group of the tree. + /// + public string? TreeGroup { get; set; } + + /// + /// Gets the usage of the tree. + /// + public TreeUse TreeUse { get; set; } = TreeUse.Main | TreeUse.Dialog; + + /// + /// Gets or sets the tree sort order. + /// + public int SortOrder { get; set; } + + /// + /// Gets or sets a value indicating whether the tree is a single-node tree (no child nodes, full screen app). + /// + public bool IsSingleNodeTree { get; set; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs b/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs index 42a15cccc8..9934138d3c 100644 --- a/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs +++ b/src/Umbraco.Web.BackOffice/Trees/TreeCollectionBuilder.cs @@ -1,97 +1,122 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.Trees; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// Builds a . +/// +public class TreeCollectionBuilder : ICollectionBuilder { + private readonly List _trees = new(); + + public TreeCollection CreateCollection(IServiceProvider factory) => new(() => _trees); + + public void RegisterWith(IServiceCollection services) => + services.Add(new ServiceDescriptor(typeof(TreeCollection), CreateCollection, ServiceLifetime.Singleton)); + + /// - /// Builds a . + /// Registers a custom tree definition /// - public class TreeCollectionBuilder : ICollectionBuilder + /// + /// + /// This is useful if a developer wishes to have a single tree controller for different tree aliases. In this case the + /// tree controller + /// cannot be decorated with the TreeAttribute (since then it will be auto-registered). + /// + public void AddTree(Tree treeDefinition) { - private readonly List _trees = new List(); - - public TreeCollection CreateCollection(IServiceProvider factory) => new TreeCollection(() => _trees); - - public void RegisterWith(IServiceCollection services) => services.Add(new ServiceDescriptor(typeof(TreeCollection), CreateCollection, ServiceLifetime.Singleton)); - - - /// - /// Registers a custom tree definition - /// - /// - /// - /// This is useful if a developer wishes to have a single tree controller for different tree aliases. In this case the tree controller - /// cannot be decorated with the TreeAttribute (since then it will be auto-registered). - /// - public void AddTree(Tree treeDefinition) + if (treeDefinition == null) { - if (treeDefinition == null) throw new ArgumentNullException(nameof(treeDefinition)); - _trees.Add(treeDefinition); + throw new ArgumentNullException(nameof(treeDefinition)); } - public void AddTreeController() - where TController : TreeControllerBase - => AddTreeController(typeof(TController)); + _trees.Add(treeDefinition); + } - public void AddTreeController(Type controllerType) + public void AddTreeController() + where TController : TreeControllerBase + => AddTreeController(typeof(TController)); + + public void AddTreeController(Type controllerType) + { + if (!typeof(TreeControllerBase).IsAssignableFrom(controllerType)) { - if (!typeof(TreeControllerBase).IsAssignableFrom(controllerType)) - throw new ArgumentException($"Type {controllerType} does not inherit from {typeof(TreeControllerBase).FullName}."); - - // not all TreeControllerBase are meant to be used here, - // ignore those that don't have the attribute - - var attribute = controllerType.GetCustomAttribute(false); - if (attribute == null) return; - - bool isCoreTree = controllerType.HasCustomAttribute(false); - - // Use section as tree group if core tree, so it isn't grouped by empty key and thus end up in "Third Party" tree group if adding custom tree nodes in other groups, e.g. "Settings" tree group. - attribute.TreeGroup = attribute.TreeGroup ?? (isCoreTree ? attribute.SectionAlias : attribute.TreeGroup); - - var tree = new Tree(attribute.SortOrder, attribute.SectionAlias, attribute.TreeGroup, attribute.TreeAlias, attribute.TreeTitle, attribute.TreeUse, controllerType, attribute.IsSingleNodeTree); - _trees.Add(tree); + throw new ArgumentException( + $"Type {controllerType} does not inherit from {typeof(TreeControllerBase).FullName}."); } - public void AddTreeControllers(IEnumerable controllerTypes) + // not all TreeControllerBase are meant to be used here, + // ignore those that don't have the attribute + + TreeAttribute? attribute = controllerType.GetCustomAttribute(false); + if (attribute == null) { - foreach (var controllerType in controllerTypes) - AddTreeController(controllerType); + return; } - public void RemoveTree(Tree treeDefinition) + var isCoreTree = controllerType.HasCustomAttribute(false); + + // Use section as tree group if core tree, so it isn't grouped by empty key and thus end up in "Third Party" tree group if adding custom tree nodes in other groups, e.g. "Settings" tree group. + attribute.TreeGroup ??= isCoreTree ? attribute.SectionAlias : attribute.TreeGroup; + + var tree = new Tree( + attribute.SortOrder, + attribute.SectionAlias, + attribute.TreeGroup, + attribute.TreeAlias, + attribute.TreeTitle, + attribute.TreeUse, + controllerType, + attribute.IsSingleNodeTree); + _trees.Add(tree); + } + + public void AddTreeControllers(IEnumerable controllerTypes) + { + foreach (Type controllerType in controllerTypes) { - if (treeDefinition == null) - throw new ArgumentNullException(nameof(treeDefinition)); - _trees.Remove(treeDefinition); + AddTreeController(controllerType); + } + } + + public void RemoveTree(Tree treeDefinition) + { + if (treeDefinition == null) + { + throw new ArgumentNullException(nameof(treeDefinition)); } - public void RemoveTreeController() - where T : TreeControllerBase - => RemoveTreeController(typeof(T)); + _trees.Remove(treeDefinition); + } - // TODO: Change parameter name to "controllerType" in a major version to make it consistent with AddTreeController method. - public void RemoveTreeController(Type type) + public void RemoveTreeController() + where T : TreeControllerBase + => RemoveTreeController(typeof(T)); + + // TODO: Change parameter name to "controllerType" in a major version to make it consistent with AddTreeController method. + public void RemoveTreeController(Type type) + { + if (!typeof(TreeControllerBase).IsAssignableFrom(type)) { - if (!typeof(TreeControllerBase).IsAssignableFrom(type)) - throw new ArgumentException($"Type {type} does not inherit from {typeof(TreeControllerBase).FullName}."); - - var tree = _trees.FirstOrDefault(x => x.TreeControllerType == type); - if (tree != null) - { - _trees.Remove(tree); - } + throw new ArgumentException($"Type {type} does not inherit from {typeof(TreeControllerBase).FullName}."); } - public void RemoveTreeControllers(IEnumerable controllerTypes) + Tree? tree = _trees.FirstOrDefault(x => x.TreeControllerType == type); + if (tree != null) { - foreach (var controllerType in controllerTypes) - RemoveTreeController(controllerType); + _trees.Remove(tree); + } + } + + public void RemoveTreeControllers(IEnumerable controllerTypes) + { + foreach (Type controllerType in controllerTypes) + { + RemoveTreeController(controllerType); } } } diff --git a/src/Umbraco.Web.BackOffice/Trees/TreeController.cs b/src/Umbraco.Web.BackOffice/Trees/TreeController.cs index d2d051515c..068daaaa25 100644 --- a/src/Umbraco.Web.BackOffice/Trees/TreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/TreeController.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Concurrent; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Events; @@ -6,59 +5,61 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// The base controller for all tree requests +/// +public abstract class TreeController : TreeControllerBase { - /// - /// The base controller for all tree requests - /// - public abstract class TreeController : TreeControllerBase + private static readonly ConcurrentDictionary _treeAttributeCache = new(); + + private readonly TreeAttribute _treeAttribute; + + protected TreeController(ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, IEventAggregator eventAggregator) + : base(umbracoApiControllerTypeCollection, eventAggregator) { - private static readonly ConcurrentDictionary _treeAttributeCache = new ConcurrentDictionary(); - - private readonly TreeAttribute _treeAttribute; - - protected ILocalizedTextService LocalizedTextService { get; } - - protected TreeController(ILocalizedTextService localizedTextService, UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, IEventAggregator eventAggregator) - : base(umbracoApiControllerTypeCollection, eventAggregator) - { - LocalizedTextService = localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); - _treeAttribute = GetTreeAttribute(); - } - - /// - public override string? RootNodeDisplayName => Tree.GetRootNodeDisplayName(this, LocalizedTextService); - - /// - public override string? TreeGroup => _treeAttribute.TreeGroup; - - /// - public override string TreeAlias => _treeAttribute.TreeAlias; - - /// - public override string? TreeTitle => _treeAttribute.TreeTitle; - - /// - public override TreeUse TreeUse => _treeAttribute.TreeUse; - - /// - public override string SectionAlias => _treeAttribute.SectionAlias; - - /// - public override int SortOrder => _treeAttribute.SortOrder; - - /// - public override bool IsSingleNodeTree => _treeAttribute.IsSingleNodeTree; - - private TreeAttribute GetTreeAttribute() - { - return _treeAttributeCache.GetOrAdd(GetType(), type => - { - var treeAttribute = type.GetCustomAttribute(false); - if (treeAttribute == null) - throw new InvalidOperationException("The Tree controller is missing the " + typeof(TreeAttribute).FullName + " attribute"); - return treeAttribute; - }); - } + LocalizedTextService = localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); + _treeAttribute = GetTreeAttribute(); } + + protected ILocalizedTextService LocalizedTextService { get; } + + /// + public override string? RootNodeDisplayName => Tree.GetRootNodeDisplayName(this, LocalizedTextService); + + /// + public override string? TreeGroup => _treeAttribute.TreeGroup; + + /// + public override string TreeAlias => _treeAttribute.TreeAlias; + + /// + public override string? TreeTitle => _treeAttribute.TreeTitle; + + /// + public override TreeUse TreeUse => _treeAttribute.TreeUse; + + /// + public override string SectionAlias => _treeAttribute.SectionAlias; + + /// + public override int SortOrder => _treeAttribute.SortOrder; + + /// + public override bool IsSingleNodeTree => _treeAttribute.IsSingleNodeTree; + + private TreeAttribute GetTreeAttribute() => + _treeAttributeCache.GetOrAdd(GetType(), type => + { + TreeAttribute? treeAttribute = type.GetCustomAttribute(false); + if (treeAttribute == null) + { + throw new InvalidOperationException("The Tree controller is missing the " + + typeof(TreeAttribute).FullName + " attribute"); + } + + return treeAttribute; + }); } diff --git a/src/Umbraco.Web.BackOffice/Trees/TreeControllerBase.cs b/src/Umbraco.Web.BackOffice/Trees/TreeControllerBase.cs index fb30d7c147..33b4c47fda 100644 --- a/src/Umbraco.Web.BackOffice/Trees/TreeControllerBase.cs +++ b/src/Umbraco.Web.BackOffice/Trees/TreeControllerBase.cs @@ -1,8 +1,6 @@ -using System; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; @@ -14,415 +12,429 @@ using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.ModelBinders; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// A base controller reference for non-attributed trees (un-registered). +/// +/// +/// Developers should generally inherit from TreeController. +/// +[AngularJsonOnlyConfiguration] +public abstract class TreeControllerBase : UmbracoAuthorizedApiController, ITree { - /// - /// A base controller reference for non-attributed trees (un-registered). - /// - /// - /// Developers should generally inherit from TreeController. - /// - [AngularJsonOnlyConfiguration] - public abstract class TreeControllerBase : UmbracoAuthorizedApiController, ITree + // TODO: Need to set this, but from where? + // Presumably not injecting as this will be a base controller for package/solution developers. + private readonly UmbracoApiControllerTypeCollection _apiControllers; + private readonly IEventAggregator _eventAggregator; + + protected TreeControllerBase(UmbracoApiControllerTypeCollection apiControllers, IEventAggregator eventAggregator) { - // TODO: Need to set this, but from where? - // Presumably not injecting as this will be a base controller for package/solution developers. - private readonly UmbracoApiControllerTypeCollection _apiControllers; - private readonly IEventAggregator _eventAggregator; + _apiControllers = apiControllers; + _eventAggregator = eventAggregator; + } - protected TreeControllerBase(UmbracoApiControllerTypeCollection apiControllers, IEventAggregator eventAggregator) + /// + /// The name to display on the root node + /// + public abstract string? RootNodeDisplayName { get; } + + /// + public abstract string? TreeGroup { get; } + + /// + public abstract string TreeAlias { get; } + + /// + public abstract string? TreeTitle { get; } + + /// + public abstract TreeUse TreeUse { get; } + + /// + public abstract string SectionAlias { get; } + + /// + public abstract int SortOrder { get; } + + /// + public abstract bool IsSingleNodeTree { get; } + + /// + /// The method called to render the contents of the tree structure + /// + /// + /// + /// All of the query string parameters passed from jsTree + /// + /// + /// We are allowing an arbitrary number of query strings to be passed in so that developers are able to persist custom + /// data from the front-end + /// to the back end to be used in the query for model data. + /// + protected abstract ActionResult GetTreeNodes(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings); + + /// + /// Returns the menu structure for the node + /// + /// + /// + /// + protected abstract ActionResult GetMenuForNode(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings); + + /// + /// The method called to render the contents of the tree structure + /// + /// + /// + /// All of the query string parameters passed from jsTree + /// + /// + /// If overriden, GetTreeNodes will not be called + /// We are allowing an arbitrary number of query strings to be passed in so that developers are able to persist custom + /// data from the front-end + /// to the back end to be used in the query for model data. + /// + protected virtual async Task> GetTreeNodesAsync( + string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings) => + GetTreeNodes(id, queryStrings); + + /// + /// Returns the menu structure for the node + /// + /// + /// + /// + /// + /// If overriden, GetMenuForNode will not be called + /// + protected virtual async Task> GetMenuForNodeAsync(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings) => + GetMenuForNode(id, queryStrings); + + /// + /// Returns the root node for the tree + /// + /// + /// + public async Task> GetRootNode( + [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection? queryStrings) + { + if (queryStrings == null) { - _apiControllers = apiControllers; - _eventAggregator = eventAggregator; + queryStrings = FormCollection.Empty; } - /// - /// The method called to render the contents of the tree structure - /// - /// - /// - /// All of the query string parameters passed from jsTree - /// - /// - /// We are allowing an arbitrary number of query strings to be passed in so that developers are able to persist custom data from the front-end - /// to the back end to be used in the query for model data. - /// - protected abstract ActionResult GetTreeNodes(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))]FormCollection queryStrings); - - /// - /// Returns the menu structure for the node - /// - /// - /// - /// - protected abstract ActionResult GetMenuForNode(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))]FormCollection queryStrings); - - /// - /// The method called to render the contents of the tree structure - /// - /// - /// - /// All of the query string parameters passed from jsTree - /// - /// - /// If overriden, GetTreeNodes will not be called - /// We are allowing an arbitrary number of query strings to be passed in so that developers are able to persist custom data from the front-end - /// to the back end to be used in the query for model data. - /// - protected virtual async Task> GetTreeNodesAsync(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings) + ActionResult nodeResult = CreateRootNode(queryStrings); + if (!(nodeResult.Result is null)) { - return GetTreeNodes(id, queryStrings); + return nodeResult.Result; } - /// - /// Returns the menu structure for the node - /// - /// - /// - /// - /// - /// If overriden, GetMenuForNode will not be called - /// - protected virtual async Task> GetMenuForNodeAsync(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings) + TreeNode? node = nodeResult.Value; + + if (node is not null) { - return GetMenuForNode(id, queryStrings); - } + // Add the tree alias to the root + node.AdditionalData["treeAlias"] = TreeAlias; + AddQueryStringsToAdditionalData(node, queryStrings); - /// - /// The name to display on the root node - /// - public abstract string? RootNodeDisplayName { get; } - - /// - public abstract string? TreeGroup { get; } - - /// - public abstract string TreeAlias { get; } - - /// - public abstract string? TreeTitle { get; } - - /// - public abstract TreeUse TreeUse { get; } - - /// - public abstract string SectionAlias { get; } - - /// - public abstract int SortOrder { get; } - - /// - public abstract bool IsSingleNodeTree { get; } - - /// - /// Returns the root node for the tree - /// - /// - /// - public async Task> GetRootNode([ModelBinder(typeof(HttpQueryStringModelBinder))]FormCollection? queryStrings) - { - if (queryStrings == null) queryStrings = FormCollection.Empty; - var nodeResult = CreateRootNode(queryStrings); - if (!(nodeResult.Result is null)) + // Check if the tree is searchable and add that to the meta data as well + if (this is ISearchableTree) { - return nodeResult.Result; + node.AdditionalData.Add("searchable", "true"); } - var node = nodeResult.Value; - - if (node is not null) + // Now update all data based on some of the query strings, like if we are running in dialog mode + if (IsDialog(queryStrings)) + { + node.RoutePath = "#"; + } + + await _eventAggregator.PublishAsync(new RootNodeRenderingNotification(node, queryStrings, TreeAlias)); + } + + return node; + } + + /// + /// The action called to render the contents of the tree structure + /// + /// + /// + /// All of the query string parameters passed from jsTree + /// + /// JSON markup for jsTree + /// + /// We are allowing an arbitrary number of query strings to be passed in so that developers are able to persist custom + /// data from the front-end + /// to the back end to be used in the query for model data. + /// + public async Task> GetNodes(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection? queryStrings) + { + if (queryStrings == null) + { + queryStrings = FormCollection.Empty; + } + + ActionResult nodesResult = await GetTreeNodesAsync(id, queryStrings); + + if (!(nodesResult.Result is null)) + { + return nodesResult.Result; + } + + TreeNodeCollection? nodes = nodesResult.Value; + + if (nodes is not null) + { + foreach (TreeNode node in nodes) { - // Add the tree alias to the root - node.AdditionalData["treeAlias"] = TreeAlias; AddQueryStringsToAdditionalData(node, queryStrings); + } - // Check if the tree is searchable and add that to the meta data as well - if (this is ISearchableTree) - node.AdditionalData.Add("searchable", "true"); - - // Now update all data based on some of the query strings, like if we are running in dialog mode - if (IsDialog(queryStrings)) + // Now update all data based on some of the query strings, like if we are running in dialog mode + if (IsDialog(queryStrings)) + { + foreach (TreeNode node in nodes) + { node.RoutePath = "#"; - - await _eventAggregator.PublishAsync(new RootNodeRenderingNotification(node, queryStrings, TreeAlias)); - } - - return node; - } - - /// - /// The action called to render the contents of the tree structure - /// - /// - /// - /// All of the query string parameters passed from jsTree - /// - /// JSON markup for jsTree - /// - /// We are allowing an arbitrary number of query strings to be passed in so that developers are able to persist custom data from the front-end - /// to the back end to be used in the query for model data. - /// - public async Task> GetNodes(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))]FormCollection? queryStrings) - { - if (queryStrings == null) queryStrings = FormCollection.Empty; - var nodesResult = await GetTreeNodesAsync(id, queryStrings); - - if (!(nodesResult.Result is null)) - { - return nodesResult.Result; - } - - var nodes = nodesResult.Value; - - if (nodes is not null) - { - foreach (var node in nodes) - { - AddQueryStringsToAdditionalData(node, queryStrings); } - - // Now update all data based on some of the query strings, like if we are running in dialog mode - if (IsDialog(queryStrings)) - { - foreach (var node in nodes) - { - node.RoutePath = "#"; - } - } - - // Raise the event - await _eventAggregator.PublishAsync(new TreeNodesRenderingNotification(nodes, queryStrings, TreeAlias, id)); } - return nodes; + // Raise the event + await _eventAggregator.PublishAsync(new TreeNodesRenderingNotification(nodes, queryStrings, TreeAlias, id)); } - /// - /// The action called to render the menu for a tree node - /// - /// - /// - /// - public async Task> GetMenu(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))]FormCollection queryStrings) + return nodes; + } + + /// + /// The action called to render the menu for a tree node + /// + /// + /// + /// + public async Task> GetMenu(string id, [ModelBinder(typeof(HttpQueryStringModelBinder))] FormCollection queryStrings) + { + if (queryStrings == null) { - if (queryStrings == null) queryStrings = FormCollection.Empty; - var menuResult = await GetMenuForNodeAsync(id, queryStrings); - if (!(menuResult?.Result is null)) - { - return menuResult.Result; - } - - var menu = menuResult?.Value; - - if (menu is not null) - { - //raise the event - await _eventAggregator.PublishAsync(new MenuRenderingNotification(id, menu, queryStrings, TreeAlias)); - } - - return menu; + queryStrings = FormCollection.Empty; } - /// - /// Helper method to create a root model for a tree - /// - /// - protected virtual ActionResult CreateRootNode(FormCollection queryStrings) + ActionResult? menuResult = await GetMenuForNodeAsync(id, queryStrings); + if (!(menuResult?.Result is null)) { - var rootNodeAsString = Constants.System.RootString; - queryStrings.TryGetValue(TreeQueryStringParameters.Application, out var currApp); - - var node = new TreeNode( - rootNodeAsString, - null, //this is a root node, there is no parent - Url.GetTreeUrl(_apiControllers, GetType(), rootNodeAsString, queryStrings), - Url.GetMenuUrl(_apiControllers, GetType(), rootNodeAsString, queryStrings)) - { - HasChildren = true, - RoutePath = currApp, - Name = RootNodeDisplayName - }; - - return node; + return menuResult.Result; } - #region Create TreeNode methods + MenuItemCollection? menu = menuResult?.Value; - /// - /// Helper method to create tree nodes - /// - /// - /// - /// - /// - /// - public TreeNode CreateTreeNode(string id, string parentId, FormCollection queryStrings, string title) + if (menu is not null) { - var jsonUrl = Url.GetTreeUrl(_apiControllers, GetType(), id, queryStrings); - var menuUrl = Url.GetMenuUrl(_apiControllers, GetType(), id, queryStrings); - var node = new TreeNode(id, parentId, jsonUrl, menuUrl) { Name = title }; - return node; + //raise the event + await _eventAggregator.PublishAsync(new MenuRenderingNotification(id, menu, queryStrings, TreeAlias)); } - /// - /// Helper method to create tree nodes - /// - /// - /// - /// - /// - /// - /// - public TreeNode CreateTreeNode(string id, string parentId, FormCollection? queryStrings, string? title, string? icon) - { - var jsonUrl = Url.GetTreeUrl(_apiControllers, GetType(), id, queryStrings); - var menuUrl = Url.GetMenuUrl(_apiControllers, GetType(), id, queryStrings); - var node = new TreeNode(id, parentId, jsonUrl, menuUrl) - { - Name = title, - Icon = icon, - NodeType = TreeAlias - }; - return node; - } + return menu; + } - /// - /// Helper method to create tree nodes - /// - /// - /// - /// - /// - /// - /// - /// - public TreeNode CreateTreeNode(string id, string parentId, FormCollection queryStrings, string title, string icon, string routePath) - { - var jsonUrl = Url.GetTreeUrl(_apiControllers, GetType(), id, queryStrings); - var menuUrl = Url.GetMenuUrl(_apiControllers, GetType(), id, queryStrings); - var node = new TreeNode(id, parentId, jsonUrl, menuUrl) { Name = title, RoutePath = routePath, Icon = icon }; - return node; - } + /// + /// Helper method to create a root model for a tree + /// + /// + protected virtual ActionResult CreateRootNode(FormCollection queryStrings) + { + var rootNodeAsString = Constants.System.RootString; + queryStrings.TryGetValue(TreeQueryStringParameters.Application, out StringValues currApp); - /// - /// Helper method to create tree nodes and automatically generate the json URL + UDI - /// - /// - /// - /// - /// - /// - /// - public TreeNode CreateTreeNode(IEntitySlim entity, Guid entityObjectType, string parentId, FormCollection? queryStrings, bool hasChildren) + var node = new TreeNode( + rootNodeAsString, + null, //this is a root node, there is no parent + Url.GetTreeUrl(_apiControllers, GetType(), rootNodeAsString, queryStrings), + Url.GetMenuUrl(_apiControllers, GetType(), rootNodeAsString, queryStrings)) { - var contentTypeIcon = entity is IContentEntitySlim contentEntity ? contentEntity.ContentTypeIcon : null; - var treeNode = CreateTreeNode(entity.Id.ToInvariantString(), parentId, queryStrings, entity.Name, contentTypeIcon); - treeNode.Path = entity.Path; - treeNode.Udi = Udi.Create(ObjectTypes.GetUdiType(entityObjectType), entity.Key); - treeNode.HasChildren = hasChildren; - treeNode.Trashed = entity.Trashed; - return treeNode; - } + HasChildren = true, + RoutePath = currApp, + Name = RootNodeDisplayName + }; - /// - /// Helper method to create tree nodes and automatically generate the json URL + UDI - /// - /// - /// - /// - /// - /// - /// - /// - public TreeNode CreateTreeNode(IUmbracoEntity entity, Guid entityObjectType, string parentId, FormCollection queryStrings, string icon, bool hasChildren) + return node; + } + + /// + /// The AdditionalData of a node is always populated with the query string data, this method performs this + /// operation and ensures that special values are not inserted or that duplicate keys are not added. + /// + /// + /// + protected void AddQueryStringsToAdditionalData(TreeNode node, FormCollection queryStrings) + { + foreach (KeyValuePair q in queryStrings.Where(x => + node.AdditionalData.ContainsKey(x.Key) == false)) { - var treeNode = CreateTreeNode(entity.Id.ToInvariantString(), parentId, queryStrings, entity.Name, icon); - treeNode.Udi = Udi.Create(ObjectTypes.GetUdiType(entityObjectType), entity.Key); - treeNode.Path = entity.Path; - treeNode.HasChildren = hasChildren; - return treeNode; - } - - /// - /// Helper method to create tree nodes and automatically generate the json URL - /// - /// - /// - /// - /// - /// - /// - /// - public TreeNode CreateTreeNode(string id, string parentId, FormCollection queryStrings, string? title, string? icon, bool hasChildren) - { - var treeNode = CreateTreeNode(id, parentId, queryStrings, title, icon); - treeNode.HasChildren = hasChildren; - return treeNode; - } - - /// - /// Helper method to create tree nodes and automatically generate the json URL - /// - /// - /// - /// - /// - /// - /// - /// - /// - public TreeNode CreateTreeNode(string id, string parentId, FormCollection queryStrings, string? title, string? icon, bool hasChildren, string routePath) - { - var treeNode = CreateTreeNode(id, parentId, queryStrings, title, icon); - treeNode.HasChildren = hasChildren; - treeNode.RoutePath = routePath; - return treeNode; - } - - /// - /// Helper method to create tree nodes and automatically generate the json URL + UDI - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - public TreeNode CreateTreeNode(string id, string parentId, FormCollection? queryStrings, string? title, string icon, bool hasChildren, string? routePath, Udi udi) - { - var treeNode = CreateTreeNode(id, parentId, queryStrings, title, icon); - treeNode.HasChildren = hasChildren; - treeNode.RoutePath = routePath; - treeNode.Udi = udi; - return treeNode; - } - - #endregion - - /// - /// The AdditionalData of a node is always populated with the query string data, this method performs this - /// operation and ensures that special values are not inserted or that duplicate keys are not added. - /// - /// - /// - protected void AddQueryStringsToAdditionalData(TreeNode node, FormCollection queryStrings) - { - foreach (var q in queryStrings.Where(x => node.AdditionalData.ContainsKey(x.Key) == false)) - node.AdditionalData.Add(q.Key, q.Value); - } - - /// - /// If the request is for a dialog mode tree - /// - /// - /// - protected bool IsDialog(FormCollection queryStrings) - { - queryStrings.TryGetValue(TreeQueryStringParameters.Use, out var use); - return use == "dialog"; + node.AdditionalData.Add(q.Key, q.Value); } } + + /// + /// If the request is for a dialog mode tree + /// + /// + /// + protected bool IsDialog(FormCollection queryStrings) + { + queryStrings.TryGetValue(TreeQueryStringParameters.Use, out StringValues use); + return use == "dialog"; + } + + #region Create TreeNode methods + + /// + /// Helper method to create tree nodes + /// + /// + /// + /// + /// + /// + public TreeNode CreateTreeNode(string id, string parentId, FormCollection queryStrings, string title) + { + var jsonUrl = Url.GetTreeUrl(_apiControllers, GetType(), id, queryStrings); + var menuUrl = Url.GetMenuUrl(_apiControllers, GetType(), id, queryStrings); + var node = new TreeNode(id, parentId, jsonUrl, menuUrl) { Name = title }; + return node; + } + + /// + /// Helper method to create tree nodes + /// + /// + /// + /// + /// + /// + /// + public TreeNode CreateTreeNode(string id, string parentId, FormCollection? queryStrings, string? title, string? icon) + { + var jsonUrl = Url.GetTreeUrl(_apiControllers, GetType(), id, queryStrings); + var menuUrl = Url.GetMenuUrl(_apiControllers, GetType(), id, queryStrings); + var node = new TreeNode(id, parentId, jsonUrl, menuUrl) { Name = title, Icon = icon, NodeType = TreeAlias }; + return node; + } + + /// + /// Helper method to create tree nodes + /// + /// + /// + /// + /// + /// + /// + /// + public TreeNode CreateTreeNode(string id, string parentId, FormCollection queryStrings, string title, string icon, string routePath) + { + var jsonUrl = Url.GetTreeUrl(_apiControllers, GetType(), id, queryStrings); + var menuUrl = Url.GetMenuUrl(_apiControllers, GetType(), id, queryStrings); + var node = new TreeNode(id, parentId, jsonUrl, menuUrl) { Name = title, RoutePath = routePath, Icon = icon }; + return node; + } + + /// + /// Helper method to create tree nodes and automatically generate the json URL + UDI + /// + /// + /// + /// + /// + /// + /// + public TreeNode CreateTreeNode(IEntitySlim entity, Guid entityObjectType, string parentId, FormCollection? queryStrings, bool hasChildren) + { + var contentTypeIcon = entity is IContentEntitySlim contentEntity ? contentEntity.ContentTypeIcon : null; + TreeNode treeNode = CreateTreeNode(entity.Id.ToInvariantString(), parentId, queryStrings, entity.Name, contentTypeIcon); + treeNode.Path = entity.Path; + treeNode.Udi = Udi.Create(ObjectTypes.GetUdiType(entityObjectType), entity.Key); + treeNode.HasChildren = hasChildren; + treeNode.Trashed = entity.Trashed; + return treeNode; + } + + /// + /// Helper method to create tree nodes and automatically generate the json URL + UDI + /// + /// + /// + /// + /// + /// + /// + /// + public TreeNode CreateTreeNode(IUmbracoEntity entity, Guid entityObjectType, string parentId, FormCollection queryStrings, string icon, bool hasChildren) + { + TreeNode treeNode = CreateTreeNode(entity.Id.ToInvariantString(), parentId, queryStrings, entity.Name, icon); + treeNode.Udi = Udi.Create(ObjectTypes.GetUdiType(entityObjectType), entity.Key); + treeNode.Path = entity.Path; + treeNode.HasChildren = hasChildren; + return treeNode; + } + + /// + /// Helper method to create tree nodes and automatically generate the json URL + /// + /// + /// + /// + /// + /// + /// + /// + public TreeNode CreateTreeNode(string id, string parentId, FormCollection queryStrings, string? title, string? icon, bool hasChildren) + { + TreeNode treeNode = CreateTreeNode(id, parentId, queryStrings, title, icon); + treeNode.HasChildren = hasChildren; + return treeNode; + } + + /// + /// Helper method to create tree nodes and automatically generate the json URL + /// + /// + /// + /// + /// + /// + /// + /// + /// + public TreeNode CreateTreeNode(string id, string parentId, FormCollection queryStrings, string? title, string? icon, bool hasChildren, string routePath) + { + TreeNode treeNode = CreateTreeNode(id, parentId, queryStrings, title, icon); + treeNode.HasChildren = hasChildren; + treeNode.RoutePath = routePath; + return treeNode; + } + + /// + /// Helper method to create tree nodes and automatically generate the json URL + UDI + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public TreeNode CreateTreeNode(string id, string parentId, FormCollection? queryStrings, string? title, string icon, bool hasChildren, string? routePath, Udi udi) + { + TreeNode treeNode = CreateTreeNode(id, parentId, queryStrings, title, icon); + treeNode.HasChildren = hasChildren; + treeNode.RoutePath = routePath; + treeNode.Udi = udi; + return treeNode; + } + + #endregion } diff --git a/src/Umbraco.Web.BackOffice/Trees/TreeNodesRenderingNotification.cs b/src/Umbraco.Web.BackOffice/Trees/TreeNodesRenderingNotification.cs index 5621bc5756..18a48919a8 100644 --- a/src/Umbraco.Web.BackOffice/Trees/TreeNodesRenderingNotification.cs +++ b/src/Umbraco.Web.BackOffice/Trees/TreeNodesRenderingNotification.cs @@ -1,68 +1,65 @@ -using System; using Microsoft.AspNetCore.Http; using Umbraco.Cms.Core.Trees; -namespace Umbraco.Cms.Core.Notifications +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that allows developers to modify the tree node collection that is being rendered +/// +/// +/// Developers can add/remove/replace/insert/update/etc... any of the tree items in the collection. +/// +public class TreeNodesRenderingNotification : INotification { /// - /// A notification that allows developers to modify the tree node collection that is being rendered + /// Initializes a new instance of the class. /// - /// - /// Developers can add/remove/replace/insert/update/etc... any of the tree items in the collection. - /// - public class TreeNodesRenderingNotification : INotification + /// The tree nodes being rendered + /// The query string of the current request + /// The alias of the tree rendered + /// The id of the node rendered + public TreeNodesRenderingNotification(TreeNodeCollection nodes, FormCollection queryString, string treeAlias, + string id) { - - /// - /// Initializes a new instance of the class. - /// - /// The tree nodes being rendered - /// The query string of the current request - /// The alias of the tree rendered - /// The id of the node rendered - public TreeNodesRenderingNotification(TreeNodeCollection nodes, FormCollection queryString, string treeAlias, string id) - { - Nodes = nodes; - QueryString = queryString; - TreeAlias = treeAlias; - Id = id; - } - - /// - /// Initializes a new instance of the class. - /// Constructor - /// - /// The tree nodes being rendered - /// The query string of the current request - /// The alias of the tree rendered - [Obsolete("Use ctor with all parameters")] - public TreeNodesRenderingNotification(TreeNodeCollection nodes, FormCollection queryString, string treeAlias) - { - Nodes = nodes; - QueryString = queryString; - TreeAlias = treeAlias; - Id = default; - } - - /// - /// Gets the tree nodes being rendered - /// - public TreeNodeCollection Nodes { get; } - - /// - /// Gets the query string of the current request - /// - public FormCollection QueryString { get; } - - /// - /// Gets the alias of the tree rendered - /// - public string TreeAlias { get; } - - /// - /// Gets the id of the node rendered - /// - public string? Id { get; } - + Nodes = nodes; + QueryString = queryString; + TreeAlias = treeAlias; + Id = id; } + + /// + /// Initializes a new instance of the class. + /// Constructor + /// + /// The tree nodes being rendered + /// The query string of the current request + /// The alias of the tree rendered + [Obsolete("Use ctor with all parameters")] + public TreeNodesRenderingNotification(TreeNodeCollection nodes, FormCollection queryString, string treeAlias) + { + Nodes = nodes; + QueryString = queryString; + TreeAlias = treeAlias; + Id = default; + } + + /// + /// Gets the tree nodes being rendered + /// + public TreeNodeCollection Nodes { get; } + + /// + /// Gets the query string of the current request + /// + public FormCollection QueryString { get; } + + /// + /// Gets the alias of the tree rendered + /// + public string TreeAlias { get; } + + /// + /// Gets the id of the node rendered + /// + public string? Id { get; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/TreeQueryStringParameters.cs b/src/Umbraco.Web.BackOffice/Trees/TreeQueryStringParameters.cs index 9497d69dab..56abf2cdba 100644 --- a/src/Umbraco.Web.BackOffice/Trees/TreeQueryStringParameters.cs +++ b/src/Umbraco.Web.BackOffice/Trees/TreeQueryStringParameters.cs @@ -1,13 +1,12 @@ -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +/// +/// Common query string parameters used for tree query strings +/// +internal struct TreeQueryStringParameters { - /// - /// Common query string parameters used for tree query strings - /// - internal struct TreeQueryStringParameters - { - public const string Use = "use"; - public const string Application = "application"; - public const string StartNodeId = "startNodeId"; - public const string DataTypeKey = "dataTypeKey"; - } + public const string Use = "use"; + public const string Application = "application"; + public const string StartNodeId = "startNodeId"; + public const string DataTypeKey = "dataTypeKey"; } diff --git a/src/Umbraco.Web.BackOffice/Trees/UrlHelperExtensions.cs b/src/Umbraco.Web.BackOffice/Trees/UrlHelperExtensions.cs index a80ea0e3c8..1688a99ec2 100644 --- a/src/Umbraco.Web.BackOffice/Trees/UrlHelperExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Trees/UrlHelperExtensions.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using System.Net; using System.Text; using Microsoft.AspNetCore.Http; @@ -7,61 +5,71 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; using Umbraco.Extensions; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +public static class UrlHelperExtensions { - public static class UrlHelperExtensions + internal static string GetTreePathFromFilePath(this IUrlHelper urlHelper, string? virtualPath, string basePath = "") { - internal static string GetTreePathFromFilePath(this IUrlHelper urlHelper, string? virtualPath, string basePath = "") + //This reuses the Logic from umbraco.cms.helpers.DeepLink class + //to convert a filepath to a tree syncing path string. + + //removes the basepath from the path + //and normalizes paths - / is used consistently between trees and editors + basePath = basePath.TrimStart("~"); + virtualPath = virtualPath?.TrimStart("~"); + virtualPath = virtualPath?.Substring(basePath.Length); + virtualPath = virtualPath?.Replace('\\', '/'); + + //-1 is the default root id for trees + var sb = new StringBuilder("-1"); + + //split the virtual path and iterate through it + var pathPaths = virtualPath?.Split(Constants.CharArrays.ForwardSlash); + + for (var p = 0; p < pathPaths?.Length; p++) { - //This reuses the Logic from umbraco.cms.helpers.DeepLink class - //to convert a filepath to a tree syncing path string. - - //removes the basepath from the path - //and normalizes paths - / is used consistently between trees and editors - basePath = basePath.TrimStart("~"); - virtualPath = virtualPath?.TrimStart("~"); - virtualPath = virtualPath?.Substring(basePath.Length); - virtualPath = virtualPath?.Replace('\\', '/'); - - //-1 is the default root id for trees - var sb = new StringBuilder("-1"); - - //split the virtual path and iterate through it - var pathPaths = virtualPath?.Split(Constants.CharArrays.ForwardSlash); - - for (var p = 0; p < pathPaths?.Length; p++) + var path = WebUtility.UrlEncode(string.Join("/", pathPaths.Take(p + 1))); + if (string.IsNullOrEmpty(path) == false) { - var path = WebUtility.UrlEncode(string.Join("/", pathPaths.Take(p + 1))); - if (string.IsNullOrEmpty(path) == false) - { - sb.Append(","); - sb.Append(path); - } + sb.Append(","); + sb.Append(path); } - return sb.ToString().TrimEnd(","); } - public static string GetTreeUrl(this IUrlHelper urlHelper, UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, Type treeType, string nodeId, FormCollection? queryStrings) - { - var actionUrl = urlHelper.GetUmbracoApiService(umbracoApiControllerTypeCollection, "GetNodes", treeType)? - .EnsureEndsWith('?'); + return sb.ToString().TrimEnd(","); + } - //now we need to append the query strings - actionUrl += "id=" + nodeId.EnsureEndsWith('&') + queryStrings?.ToQueryString("id", - //Always ignore the custom start node id when generating URLs for tree nodes since this is a custom once-only parameter - // that should only ever be used when requesting a tree to render (root), not a tree node - TreeQueryStringParameters.StartNodeId); - return actionUrl; - } + public static string GetTreeUrl( + this IUrlHelper urlHelper, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + Type treeType, + string nodeId, + FormCollection? queryStrings) + { + var actionUrl = urlHelper.GetUmbracoApiService(umbracoApiControllerTypeCollection, "GetNodes", treeType)? + .EnsureEndsWith('?'); - public static string GetMenuUrl(this IUrlHelper urlHelper, UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, Type treeType, string nodeId, FormCollection? queryStrings) - { - var actionUrl = urlHelper.GetUmbracoApiService(umbracoApiControllerTypeCollection, "GetMenu", treeType)? - .EnsureEndsWith('?'); + //now we need to append the query strings + actionUrl += "id=" + nodeId.EnsureEndsWith('&') + queryStrings?.ToQueryString("id", + //Always ignore the custom start node id when generating URLs for tree nodes since this is a custom once-only parameter + // that should only ever be used when requesting a tree to render (root), not a tree node + TreeQueryStringParameters.StartNodeId); + return actionUrl; + } - //now we need to append the query strings - actionUrl += "id=" + nodeId.EnsureEndsWith('&') + queryStrings?.ToQueryString("id"); - return actionUrl; - } + public static string GetMenuUrl( + this IUrlHelper urlHelper, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + Type treeType, + string nodeId, + FormCollection? queryStrings) + { + var actionUrl = urlHelper.GetUmbracoApiService(umbracoApiControllerTypeCollection, "GetMenu", treeType)? + .EnsureEndsWith('?'); + + //now we need to append the query strings + actionUrl += "id=" + nodeId.EnsureEndsWith('&') + queryStrings?.ToQueryString("id"); + return actionUrl; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/UserTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/UserTreeController.cs index 823f9b0c04..f02420c971 100644 --- a/src/Umbraco.Web.BackOffice/Trees/UserTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/UserTreeController.cs @@ -7,62 +7,55 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Constants = Umbraco.Cms.Core.Constants; -namespace Umbraco.Cms.Web.BackOffice.Trees +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessUsers)] +[Tree(Constants.Applications.Users, Constants.Trees.Users, SortOrder = 0, IsSingleNodeTree = true)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class UserTreeController : TreeController { - [Authorize(Policy = AuthorizationPolicies.TreeAccessUsers)] - [Tree(Constants.Applications.Users, Constants.Trees.Users, SortOrder = 0, IsSingleNodeTree = true)] - [PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] - [CoreTree] - public class UserTreeController : TreeController + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + + public UserTreeController( + IMenuItemCollectionFactory menuItemCollectionFactory, + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IEventAggregator eventAggregator + ) : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) => + _menuItemCollectionFactory = menuItemCollectionFactory; + + /// + /// Helper method to create a root model for a tree + /// + /// + protected override ActionResult CreateRootNode(FormCollection queryStrings) { - private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; - - public UserTreeController( - IMenuItemCollectionFactory menuItemCollectionFactory, - ILocalizedTextService localizedTextService, - UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, - IEventAggregator eventAggregator - ) : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) { - _menuItemCollectionFactory = menuItemCollectionFactory; + return rootResult.Result; } - /// - /// Helper method to create a root model for a tree - /// - /// - protected override ActionResult CreateRootNode(FormCollection queryStrings) + TreeNode? root = rootResult.Value; + + if (root is not null) { - var rootResult = base.CreateRootNode(queryStrings); - if (!(rootResult.Result is null)) - { - return rootResult.Result; - } - var root = rootResult.Value; - - if (root is not null) - { - // this will load in a custom UI instead of the dashboard for the root node - root.RoutePath = $"{Constants.Applications.Users}/{Constants.Trees.Users}/users"; - root.Icon = Constants.Icons.UserGroup; - root.HasChildren = false; - } - - return root; + // this will load in a custom UI instead of the dashboard for the root node + root.RoutePath = $"{Constants.Applications.Users}/{Constants.Trees.Users}/users"; + root.Icon = Constants.Icons.UserGroup; + root.HasChildren = false; } - protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) - { - //full screen app without tree nodes - return TreeNodeCollection.Empty; - } - - protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) - { - //doesn't have a menu, this is a full screen app without tree nodes - return _menuItemCollectionFactory.Create(); - } + return root; } + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) => + //full screen app without tree nodes + TreeNodeCollection.Empty; + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) => + //doesn't have a menu, this is a full screen app without tree nodes + _menuItemCollectionFactory.Create(); }