using System.Globalization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Events; 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; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Infrastructure.Search; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; 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, ISearchableTreeWithCulture { 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) { _treeSearcher = treeSearcher; _actions = actions; _menuItemCollectionFactory = menuItemCollectionFactory; _backofficeSecurityAccessor = backofficeSecurityAccessor; _contentService = contentService; _entityService = entityService; _publicAccessService = publicAccessService; _userService = userService; _localizationService = localizationService; _emailSender = emailSender; _appCaches = 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(); if(culture.IsNullOrWhiteSpace()) { culture = _localizationService.GetDefaultLanguageIsoCode(); } IEnumerable allowedUserOptions = GetAllowedUserMenuItemsForNode(entity); if (CanUserAccessNode(entity, allowedUserOptions, culture)) { //Special check to see if it is a container, if so then we'll hide children. var isContainer = entity.IsContainer; // && (queryStrings.Get("isDialog") != "true"); TreeNode 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; } 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; } //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 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, useLegacyIcon: false); menu.Items.Add(LocalizedTextService, hasSeparator: true, opensDialog: true, useLegacyIcon: false); //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; } //return a normal node menu: if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out int iid) == false) { 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, useLegacyIcon: false); AddActionNode(item, menu, opensDialog: true, useLegacyIcon: false); AddActionNode(item, menu, opensDialog: true, useLegacyIcon: false); AddActionNode(item, menu, hasSeparator: true, opensDialog: true, useLegacyIcon: false); AddActionNode(item, menu, opensDialog: true, useLegacyIcon: false); AddActionNode(item, menu, hasSeparator: true, opensDialog: true, useLegacyIcon: false); AddActionNode(item, menu, opensDialog: true, useLegacyIcon: false); AddActionNode(item, menu, opensDialog: true, useLegacyIcon: false); AddActionNode(item, menu, hasSeparator: true, opensDialog: true, useLegacyIcon: false); if (_emailSender.CanSendRequiredEmail()) { menu.Items.Add(new MenuItem("notify", LocalizedTextService) { Icon = "icon-megaphone", SeparatorBefore = true, OpensDialog = true, UseLegacyIcon = false }); } if ((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false) { menu.Items.Add(new RefreshNode(LocalizedTextService, true)); } return menu; } /// /// 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, useLegacyIcon: false); menu.Items.Add(LocalizedTextService, opensDialog: true, useLegacyIcon: false); menu.Items.Add(LocalizedTextService, opensDialog: true, useLegacyIcon: false); 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; } 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()}"); } // 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()) { 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, bool useLegacyIcon = true) where TAction : IAction { MenuItem? menuItem = menu.Items.Add(LocalizedTextService, hasSeparator, opensDialog, useLegacyIcon); } public async Task SearchAsync(string query, int pageSize, long pageIndex, string? searchFrom = null, string? culture = null) { var results = _treeSearcher.ExamineSearch(query, UmbracoEntityTypes.Document, pageSize, pageIndex, out long totalFound, culture: culture, searchFrom: searchFrom); return new EntitySearchResults(results, totalFound); } }