using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Web.Http; using System.Web.Http.Controllers; using System.Web.Http.ModelBinding; using AutoMapper; using Umbraco.Core; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Models.Mapping; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; using Umbraco.Web.WebApi.Filters; using Umbraco.Core.Persistence.Querying; using Umbraco.Web.PublishedCache; using Umbraco.Core.Events; using Umbraco.Core.Models.Validation; using Umbraco.Web.Composing; using Umbraco.Web.Models; using Umbraco.Web.WebServices; using Umbraco.Web._Legacy.Actions; using Constants = Umbraco.Core.Constants; using Language = Umbraco.Web.Models.ContentEditing.Language; using Umbraco.Core.PropertyEditors; using Umbraco.Web.Editors.Binders; using Umbraco.Web.Editors.Filters; namespace Umbraco.Web.Editors { /// /// The API controller used for editing content /// /// /// 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 content application. /// [PluginController("UmbracoApi")] [UmbracoApplicationAuthorize(Constants.Applications.Content)] [ContentControllerConfiguration] public class ContentController : ContentControllerBase { private readonly IPublishedSnapshotService _publishedSnapshotService; private readonly PropertyEditorCollection _propertyEditors; private readonly Lazy> _allLangs; public ContentController(IPublishedSnapshotService publishedSnapshotService, PropertyEditorCollection propertyEditors) { if (publishedSnapshotService == null) throw new ArgumentNullException(nameof(publishedSnapshotService)); _publishedSnapshotService = publishedSnapshotService; _propertyEditors = propertyEditors ?? throw new ArgumentNullException(nameof(propertyEditors)); _allLangs = new Lazy>(() => Services.LocalizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase)); } /// /// Configures this controller with a custom action selector /// private class ContentControllerConfigurationAttribute : Attribute, IControllerConfiguration { public void Initialize(HttpControllerSettings controllerSettings, HttpControllerDescriptor controllerDescriptor) { controllerSettings.Services.Replace(typeof(IHttpActionSelector), new ParameterSwapControllerActionSelector( new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetNiceUrl", "id", typeof(int), typeof(Guid), typeof(Udi)), new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetById", "id", typeof(int), typeof(Guid), typeof(Udi)) )); } } /// /// Returns true if any content types have culture variation enabled /// /// [HttpGet] [WebApi.UmbracoAuthorize, OverrideAuthorization] public bool AllowsCultureVariation() { var contentTypes = Services.ContentTypeService.GetAll(); return contentTypes.Any(contentType => contentType.VariesByCulture()); } /// /// Return content for the specified ids /// /// /// [FilterAllowedOutgoingContent(typeof(IEnumerable))] public IEnumerable GetByIds([FromUri]int[] ids) { //fixme what about cultures? var foundContent = Services.ContentService.GetByIds(ids); return foundContent.Select(x => MapToDisplay(x)); } /// /// 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 to update /// [EnsureUserPermissionForContent("saveModel.ContentId", 'R')] public IEnumerable PostSaveUserGroupPermissions(UserGroupPermissionsSave saveModel) { if (saveModel.ContentId <= 0) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); //TODO: Should non-admins be alowed to set granular permissions? var content = Services.ContentService.GetById(saveModel.ContentId); if (content == null) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); //current permissions explicitly assigned to this content item var contentPermissions = Services.ContentService.GetPermissions(content) .ToDictionary(x => x.UserGroupId, x => x); var allUserGroups = Services.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)) { //create a string collection of the assigned letters var groupPermissionCodes = groupPermissions.ToArray(); //check if there are no permissions assigned for this group save model, if that is the case we want to reset the permissions //for this group/node which will go back to the defaults if (groupPermissionCodes.Length == 0) { Services.UserService.RemoveUserGroupPermissions(userGroup.Id, content.Id); } //check if they are the defaults, if so we should just remove them if they exist since it's more overhead having them stored else if (userGroup.Permissions.UnsortedSequenceEqual(groupPermissionCodes)) { //only remove them if they are actually currently assigned if (contentPermissions.ContainsKey(userGroup.Id)) { //remove these permissions from this node for this group since the ones being assigned are the same as the defaults Services.UserService.RemoveUserGroupPermissions(userGroup.Id, content.Id); } } //if they are different we need to update, otherwise there's nothing to update else if (contentPermissions.ContainsKey(userGroup.Id) == false || contentPermissions[userGroup.Id].AssignedPermissions.UnsortedSequenceEqual(groupPermissionCodes) == false) { Services.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 to view /// [EnsureUserPermissionForContent("contentId", 'R')] public IEnumerable GetDetailedPermissions(int contentId) { if (contentId <= 0) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); var content = Services.ContentService.GetById(contentId); if (content == null) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); //TODO: Should non-admins be able to see detailed permissions? var allUserGroups = Services.UserService.GetAllUserGroups(); return GetDetailedPermissions(content, allUserGroups); } private IEnumerable 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 = Mapper.Map>(allUserGroups).ToArray(); var defaultPermissionsAsDictionary = defaultPermissionsByGroup .ToDictionary(x => Convert.ToInt32(x.Id), x => x); //get the actual assigned permissions var assignedPermissionsByGroup = Services.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 ContentItemDisplay GetRecycleBin() { var apps = new List(); apps.AppendListViewApp(Services.DataTypeService, _propertyEditors, "recycleBin", "content"); 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 = Services.TextService.Localize("general/recycleBin") } }, ContentApps = apps }; return display; } //fixme what about cultures? public ContentItemDisplay GetBlueprintById(int id) { var foundContent = Services.ContentService.GetBlueprintById(id); if (foundContent == null) { HandleContentNotFound(id); } var content = MapToDisplay(foundContent); 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; //fixme - 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] [EnsureUserPermissionForContent("id")] public ContentItemDisplay GetById(int id) { var foundContent = GetObjectFromRequest(() => Services.ContentService.GetById(id)); if (foundContent == null) { HandleContentNotFound(id); return null;//irrelevant since the above throws } var content = MapToDisplay(foundContent); return content; } /// /// Gets the content json for the content id /// /// /// [OutgoingEditorModelEvent] [EnsureUserPermissionForContent("id")] public ContentItemDisplay GetById(Guid id) { var foundContent = GetObjectFromRequest(() => Services.ContentService.GetById(id)); if (foundContent == null) { HandleContentNotFound(id); return null;//irrelevant since the above throws } var content = MapToDisplay(foundContent); return content; } /// /// Gets the content json for the content id /// /// /// [OutgoingEditorModelEvent] [EnsureUserPermissionForContent("id")] public ContentItemDisplay GetById(Udi id) { var guidUdi = id as GuidUdi; if (guidUdi != null) { return GetById(guidUdi.Guid); } throw new HttpResponseException(HttpStatusCode.NotFound); } /// /// Gets an empty content item for the /// /// /// [OutgoingEditorModelEvent] public ContentItemDisplay GetEmpty(string contentTypeAlias, int parentId) { var contentType = Services.ContentTypeService.Get(contentTypeAlias); if (contentType == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } var emptyContent = Services.ContentService.Create("", parentId, contentType.Alias, Security.GetUserId().ResultOr(0)); var mapped = MapToDisplay(emptyContent); //remove the listview app if it exists mapped.ContentApps = mapped.ContentApps.Where(x => x.Alias != "childItems").ToList(); return mapped; } [OutgoingEditorModelEvent] public ContentItemDisplay GetEmpty(int blueprintId, int parentId) { var blueprint = Services.ContentService.GetBlueprintById(blueprintId); if (blueprint == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } blueprint.Id = 0; blueprint.Name = string.Empty; blueprint.ParentId = parentId; var mapped = Mapper.Map(blueprint); //remove the listview app if it exists mapped.ContentApps = mapped.ContentApps.Where(x => x.Alias != "childItems").ToList(); return mapped; } /// /// Gets the Url for a given node ID /// /// /// public HttpResponseMessage GetNiceUrl(int id) { var url = Umbraco.Url(id); var response = Request.CreateResponse(HttpStatusCode.OK); response.Content = new StringContent(url, Encoding.UTF8, "text/plain"); return response; } /// /// Gets the Url for a given node ID /// /// /// public HttpResponseMessage GetNiceUrl(Guid id) { var url = Umbraco.UrlProvider.GetUrl(id); var response = Request.CreateResponse(HttpStatusCode.OK); response.Content = new StringContent(url, Encoding.UTF8, "text/plain"); return response; } /// /// Gets the Url for a given node ID /// /// /// public HttpResponseMessage GetNiceUrl(Udi id) { var guidUdi = id as GuidUdi; if (guidUdi != null) { return GetNiceUrl(guidUdi.Guid); } throw new HttpResponseException(HttpStatusCode.NotFound); } /// /// Gets the children for the content id passed in /// /// [FilterAllowedOutgoingContent(typeof(IEnumerable>), "Items")] public PagedResult> GetChildren( int id, int pageNumber = 0, //TODO: This should be '1' as it's not the index int pageSize = 0, string orderBy = "SortOrder", Direction orderDirection = Direction.Ascending, bool orderBySystemField = true, string filter = "") { return GetChildren(id, null, pageNumber, pageSize, orderBy, orderDirection, orderBySystemField, filter); } /// /// Gets the children for the content id passed in /// /// [FilterAllowedOutgoingContent(typeof(IEnumerable>), "Items")] public PagedResult> GetChildren( int id, string includeProperties, int pageNumber = 0, //TODO: This should be '1' as it's not the index int pageSize = 0, string orderBy = "SortOrder", Direction orderDirection = Direction.Ascending, bool orderBySystemField = true, string filter = "") { long totalChildren; IContent[] 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.Contains(filter)); } children = Services.ContentService .GetPagedChildren( id, (pageNumber - 1), pageSize, out totalChildren, orderBy, orderDirection, orderBySystemField, queryFilter).ToArray(); } else { children = Services.ContentService.GetChildren(id).ToArray(); totalChildren = children.Length; } if (totalChildren == 0) { return new PagedResult>(0, 0, 0); } var pagedResult = new PagedResult>(totalChildren, pageNumber, pageSize); pagedResult.Items = children.Select(content => Mapper.Map>(content, opts => { // if there's a list of property aliases to map - we will make sure to store this in the mapping context. if (String.IsNullOrWhiteSpace(includeProperties) == false) { opts.Items["IncludeProperties"] = includeProperties.Split(new[] { ", ", "," }, StringSplitOptions.RemoveEmptyEntries); } })); return pagedResult; } /// /// Creates a blueprint from a content item /// /// The content id to copy /// The name of the blueprint /// [HttpPost] public SimpleNotificationModel CreateBlueprintFromContent([FromUri]int contentId, [FromUri]string name) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value cannot be null or whitespace.", "name"); var content = Services.ContentService.GetById(contentId); if (content == null) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); EnsureUniqueName(name, content, "name"); var blueprint = Services.ContentService.CreateContentFromBlueprint(content, name, Security.GetUserId().ResultOr(0)); Services.ContentService.SaveBlueprint(blueprint, Security.GetUserId().ResultOr(0)); var notificationModel = new SimpleNotificationModel(); notificationModel.AddSuccessNotification( Services.TextService.Localize("blueprints/createdBlueprintHeading"), Services.TextService.Localize("blueprints/createdBlueprintMessage", new[] { content.Name }) ); return notificationModel; } private void EnsureUniqueName(string name, IContent content, string modelName) { var existing = Services.ContentService.GetBlueprintsForContentTypes(content.ContentTypeId); if (existing.Any(x => x.Name == name && x.Id != content.Id)) { ModelState.AddModelError(modelName, Services.TextService.Localize("blueprints/duplicateBlueprintMessage")); throw new HttpResponseException(Request.CreateValidationErrorResponse(ModelState)); } } /// /// Saves content /// /// [FileUploadCleanupFilter] [ContentSaveValidation] public ContentItemDisplay PostSaveBlueprint([ModelBinder(typeof(ContentItemBinder))] ContentItemSave contentItem) { var contentItemDisplay = PostSaveInternal(contentItem, content => { EnsureUniqueName(content.Name, content, "Name"); Services.ContentService.SaveBlueprint(contentItem.PersistedContent, Security.CurrentUser.Id); //we need to reuse the underlying logic so return the result that it wants return OperationResult.Succeed(new EventMessages()); }); SetupBlueprint(contentItemDisplay, contentItemDisplay.PersistedContent); return contentItemDisplay; } /// /// Saves content /// /// [FileUploadCleanupFilter] [ContentSaveValidation] [OutgoingEditorModelEvent] public ContentItemDisplay PostSave([ModelBinder(typeof(ContentItemBinder))] ContentItemSave contentItem) { var contentItemDisplay = PostSaveInternal(contentItem, content => Services.ContentService.Save(contentItem.PersistedContent, Security.CurrentUser.Id)); return contentItemDisplay; } private ContentItemDisplay PostSaveInternal(ContentItemSave contentItem, Func saveMethod) { //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); //this a custom check for any variants not being flagged for Saving since we'll need to manually //remove the ModelState validation for the Name var variantCount = 0; foreach (var variant in contentItem.Variants) { if (!variant.Save) { ModelState.Remove($"Variants[{variantCount}].Name"); } variantCount++; } //We need to manually check the validation results here because: // * We still need to save the entity even if there are validation value errors // * Depending on if the entity is new, and if there are non property validation errors (i.e. the name is null) // then we cannot continue saving, we can only display errors // * If there are validation errors and they were attempting to publish, we can only save, NOT publish and display // a message indicating this if (ModelState.IsValid == false) { if (IsCreatingAction(contentItem.Action)) { if (!RequiredForPersistenceAttribute.HasRequiredValuesForPersistence(contentItem) || contentItem.Variants .Where(x => x.Save) .Select(RequiredForPersistenceAttribute.HasRequiredValuesForPersistence) .Any(x => x == false)) { //ok, so the absolute mandatory data is invalid and it's new, we cannot actually continue! // add the modelstate to the outgoing object and throw a validation message var forDisplay = MapToDisplay(contentItem.PersistedContent); forDisplay.Errors = ModelState.ToErrorDictionary(); throw new HttpResponseException(Request.CreateValidationErrorResponse(forDisplay)); } } //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: contentItem.Action = ContentSaveAction.Save; break; case ContentSaveAction.PublishNew: contentItem.Action = ContentSaveAction.SaveNew; break; } } } //initialize this to successful var publishStatus = new PublishResult(null, contentItem.PersistedContent); bool wasCancelled; //used to track successful notifications var notifications = new SimpleNotificationModel(); switch (contentItem.Action) { case ContentSaveAction.Save: case ContentSaveAction.SaveNew: var saveResult = saveMethod(contentItem.PersistedContent); wasCancelled = saveResult.Success == false && saveResult.Result == OperationResultType.FailedCancelledByEvent; if (saveResult.Success) { if (variantCount > 1) { var cultureErrors = ModelState.GetCulturesWithPropertyErrors(); foreach (var c in contentItem.Variants.Where(x => x.Save && !cultureErrors.Contains(x.Culture)).Select(x => x.Culture).ToArray()) { notifications.AddSuccessNotification( Services.TextService.Localize("speechBubbles/editVariantSavedHeader", new[] {_allLangs.Value[c].CultureName}), Services.TextService.Localize("speechBubbles/editContentSavedText")); } } else if (ModelState.IsValid) { notifications.AddSuccessNotification( Services.TextService.Localize("speechBubbles/editContentSavedHeader"), Services.TextService.Localize("speechBubbles/editContentSavedText")); } } break; case ContentSaveAction.SendPublish: case ContentSaveAction.SendPublishNew: var sendResult = Services.ContentService.SendToPublication(contentItem.PersistedContent, Security.CurrentUser.Id); wasCancelled = sendResult == false; if (sendResult) { if (variantCount > 1) { var cultureErrors = ModelState.GetCulturesWithPropertyErrors(); foreach (var c in contentItem.Variants.Where(x => x.Save && !cultureErrors.Contains(x.Culture)).Select(x => x.Culture).ToArray()) { notifications.AddSuccessNotification( Services.TextService.Localize("speechBubbles/editContentSendToPublish"), Services.TextService.Localize("speechBubbles/editVariantSendToPublishText", new[] { _allLangs.Value[c].CultureName })); } } else if (ModelState.IsValid) { notifications.AddSuccessNotification( Services.TextService.Localize("speechBubbles/editContentSendToPublish"), Services.TextService.Localize("speechBubbles/editContentSendToPublishText")); } } break; case ContentSaveAction.Publish: case ContentSaveAction.PublishNew: PublishInternal(contentItem, ref publishStatus, out wasCancelled, out var successfulCultures); AddMessageForPublishStatus(publishStatus, notifications, successfulCultures); break; default: throw new ArgumentOutOfRangeException(); } //get the updated model var display = MapToDisplay(contentItem.PersistedContent); //merge the tracked success messages with the outgoing model display.Notifications.AddRange(notifications.Notifications); //lasty, if it is not valid, add the modelstate to the outgoing object and throw a 403 HandleInvalidModelState(display); 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! throw new HttpResponseException(Request.CreateValidationErrorResponse(display)); } } display.PersistedContent = contentItem.PersistedContent; return display; } /// /// 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 void PublishInternal(ContentItemSave contentItem, ref PublishResult publishStatus, out bool wasCancelled, out string[] successfulCultures) { if (publishStatus == null) throw new ArgumentNullException(nameof(publishStatus)); if (!contentItem.PersistedContent.ContentType.VariesByCulture()) { //its invariant, proceed normally publishStatus = Services.ContentService.SaveAndPublish(contentItem.PersistedContent, userId: Security.CurrentUser.Id); wasCancelled = publishStatus.Result == PublishResultType.FailedCancelledByEvent; successfulCultures = Array.Empty(); } else { //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(); //validate if we can publish based on the mandatory language requirements var canPublish = ValidatePublishingMandatoryLanguages(contentItem, cultureVariants); //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. var cultureErrors = ModelState.GetCulturesWithPropertyErrors(); foreach (var variant in contentItem.Variants) { if (cultureErrors.Contains(variant.Culture)) variant.Publish = false; } if (canPublish) { //try to publish all the values on the model canPublish = PublishCulture(contentItem.PersistedContent, cultureVariants); } if (canPublish) { //proceed to publish if all validation still succeeds publishStatus = Services.ContentService.SavePublishing(contentItem.PersistedContent, Security.CurrentUser.Id); wasCancelled = publishStatus.Result == PublishResultType.FailedCancelledByEvent; successfulCultures = contentItem.Variants.Where(x => x.Publish).Select(x => x.Culture).ToArray(); } else { //can only save var saveResult = Services.ContentService.Save(contentItem.PersistedContent, Security.CurrentUser.Id); publishStatus = new PublishResult(PublishResultType.FailedCannotPublish, null, contentItem.PersistedContent); wasCancelled = saveResult.Result == OperationResultType.FailedCancelledByEvent; successfulCultures = Array.Empty(); } } } /// /// Validate if publishing is possible based on the mandatory language requirements /// /// /// /// private bool ValidatePublishingMandatoryLanguages(ContentItemSave contentItem, IReadOnlyCollection cultureVariants) { var canPublish = true; //validate any mandatory variants that are not in the list var mandatoryLangs = Mapper.Map, IEnumerable>(_allLangs.Value.Values).Where(x => x.Mandatory); foreach (var lang in mandatoryLangs) { //Check if a mandatory language is missing from being published var variant = cultureVariants.First(x => x.Culture == lang.IsoCode); var isPublished = contentItem.PersistedContent.IsCulturePublished(lang.IsoCode); var isPublishing = variant.Publish; if (isPublished || isPublishing) continue; //cannot continue publishing since a required language that is not currently being published isn't published AddCultureValidationError(lang.IsoCode, "speechBubbles/contentReqCulturePublishError"); canPublish = false; } return canPublish; } /// /// This will call PublishCulture on the content item for each culture that needs to be published including the invariant culture /// /// /// /// private bool PublishCulture(IContent persistentContent, IEnumerable cultureVariants) { foreach(var variant in cultureVariants.Where(x => x.Publish)) { // publishing any culture, implies the invariant culture var valid = persistentContent.PublishCulture(variant.Culture); if (!valid) { AddCultureValidationError(variant.Culture, "speechBubbles/contentCultureValidationError"); return false; } } return true; } /// /// Adds a generic culture error for use in displaying the culture validation error in the save/publish dialogs /// /// /// private void AddCultureValidationError(string culture, string localizationKey) { var key = "_content_variant_" + culture + "_"; if (ModelState.ContainsKey(key)) return; var errMsg = Services.TextService.Localize(localizationKey, new[] { _allLangs.Value[culture].CultureName }); ModelState.AddModelError(key, errMsg); } /// /// Publishes a document with a given ID /// /// /// /// /// The CanAccessContentAuthorize attribute will deny access to this method if the current user /// does not have Publish access to this node. /// /// [EnsureUserPermissionForContent("id", 'U')] public HttpResponseMessage PostPublishById(int id) { var foundContent = GetObjectFromRequest(() => Services.ContentService.GetById(id)); if (foundContent == null) { return HandleContentNotFound(id, false); } var publishResult = Services.ContentService.SavePublishing(foundContent, Security.GetUserId().ResultOr(0)); if (publishResult.Success == false) { var notificationModel = new SimpleNotificationModel(); AddMessageForPublishStatus(publishResult, notificationModel); return Request.CreateValidationErrorResponse(notificationModel); } //return ok return Request.CreateResponse(HttpStatusCode.OK); } [HttpDelete] [HttpPost] public HttpResponseMessage DeleteBlueprint(int id) { var found = Services.ContentService.GetBlueprintById(id); if (found == null) { return HandleContentNotFound(id, false); } Services.ContentService.DeleteBlueprint(found); return Request.CreateResponse(HttpStatusCode.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. /// [EnsureUserPermissionForContent("id", 'D')] [HttpDelete] [HttpPost] public HttpResponseMessage DeleteById(int id) { var foundContent = GetObjectFromRequest(() => Services.ContentService.GetById(id)); if (foundContent == null) { return HandleContentNotFound(id, false); } //if the current item is in the recycle bin if (foundContent.Trashed == false) { var moveResult = Services.ContentService.MoveToRecycleBin(foundContent, Security.GetUserId().ResultOr(0)); if (moveResult.Success == false) { //returning an object of INotificationModel will ensure that any pending // notification messages are added to the response. return Request.CreateValidationErrorResponse(new SimpleNotificationModel()); } } else { var deleteResult = Services.ContentService.Delete(foundContent, Security.GetUserId().ResultOr(0)); if (deleteResult.Success == false) { //returning an object of INotificationModel will ensure that any pending // notification messages are added to the response. return Request.CreateValidationErrorResponse(new SimpleNotificationModel()); } } return Request.CreateResponse(HttpStatusCode.OK); } /// /// Empties the recycle bin /// /// /// /// attributed with EnsureUserPermissionForContent to verify the user has access to the recycle bin /// [HttpDelete] [HttpPost] [EnsureUserPermissionForContent(Constants.System.RecycleBinContent)] public HttpResponseMessage EmptyRecycleBin() { Services.ContentService.EmptyRecycleBin(); return Request.CreateNotificationSuccessResponse(Services.TextService.Localize("defaultdialogs/recycleBinIsEmpty")); } /// /// Change the sort order for content /// /// /// [EnsureUserPermissionForContent("sorted.ParentId", 'S')] public HttpResponseMessage PostSort(ContentSortOrder sorted) { if (sorted == null) { return Request.CreateResponse(HttpStatusCode.NotFound); } //if there's nothing to sort just return ok if (sorted.IdSortOrder.Length == 0) { return Request.CreateResponse(HttpStatusCode.OK); } try { var contentService = Services.ContentService; // Save content with new sort order and update content xml in db accordingly if (contentService.Sort(sorted.IdSortOrder) == false) { Logger.Warn("Content sorting failed, this was probably caused by an event being cancelled"); return Request.CreateValidationErrorResponse("Content sorting failed, this was probably caused by an event being cancelled"); } if (sorted.ParentId > 0) { Services.NotificationService.SendNotification(contentService.GetById(sorted.ParentId), ActionSort.Instance, UmbracoContext, Services.TextService, GlobalSettings); } return Request.CreateResponse(HttpStatusCode.OK); } catch (Exception ex) { Logger.Error(ex, "Could not update content sort order"); throw; } } /// /// Change the sort order for media /// /// /// [EnsureUserPermissionForContent("move.ParentId", 'M')] public HttpResponseMessage PostMove(MoveOrCopy move) { var toMove = ValidateMoveOrCopy(move); Services.ContentService.Move(toMove, move.ParentId, Security.GetUserId().ResultOr(0)); var response = Request.CreateResponse(HttpStatusCode.OK); response.Content = new StringContent(toMove.Path, Encoding.UTF8, "text/plain"); return response; } /// /// Copies a content item and places the copy as a child of a given parent Id /// /// /// [EnsureUserPermissionForContent("copy.ParentId", 'C')] public HttpResponseMessage PostCopy(MoveOrCopy copy) { var toCopy = ValidateMoveOrCopy(copy); var c = Services.ContentService.Copy(toCopy, copy.ParentId, copy.RelateToOriginal, copy.Recursive, Security.GetUserId().ResultOr(0)); var response = Request.CreateResponse(HttpStatusCode.OK); response.Content = new StringContent(c.Path, Encoding.UTF8, "text/plain"); return response; } /// /// Unpublishes a node with a given Id and returns the unpublished entity /// /// The content id to unpublish /// The culture variant for the content id to unpublish, if none specified will unpublish all variants of the content /// [EnsureUserPermissionForContent("id", 'U')] [OutgoingEditorModelEvent] public ContentItemDisplay PostUnPublish(int id, string culture = null) { var foundContent = GetObjectFromRequest(() => Services.ContentService.GetById(id)); if (foundContent == null) HandleContentNotFound(id); var unpublishResult = Services.ContentService.Unpublish(foundContent, culture: culture, userId: Security.GetUserId().ResultOr(0)); var content = MapToDisplay(foundContent); if (!unpublishResult.Success) { AddCancelMessage(content); throw new HttpResponseException(Request.CreateValidationErrorResponse(content)); } else { //fixme should have a better localized method for when we have the UnpublishResultType.SuccessMandatoryCulture status content.AddSuccessNotification( Services.TextService.Localize("content/unPublish"), unpublishResult.Result == UnpublishResultType.SuccessCulture ? Services.TextService.Localize("speechBubbles/contentVariationUnpublished", new[] { culture }) : Services.TextService.Localize("speechBubbles/contentUnpublished")); return content; } } [HttpPost] public DomainSave PostSaveLanguageAndDomains(DomainSave model) { var node = Services.ContentService.GetById(model.NodeId); if (node == null) { var response = Request.CreateResponse(HttpStatusCode.BadRequest); response.Content = new StringContent($"There is no content node with id {model.NodeId}."); response.ReasonPhrase = "Node Not Found."; throw new HttpResponseException(response); } var permission = Services.UserService.GetPermissions(Security.CurrentUser, node.Path); if (permission.AssignedPermissions.Contains(ActionAssignDomain.Instance.Letter.ToString(), StringComparer.Ordinal) == false) { var response = Request.CreateResponse(HttpStatusCode.BadRequest); response.Content = new StringContent("You do not have permission to assign domains on that node."); response.ReasonPhrase = "Permission Denied."; throw new HttpResponseException(response); } model.Valid = true; var domains = Services.DomainService.GetAssignedDomains(model.NodeId, true).ToArray(); var languages = Services.LocalizationService.GetAllLanguages().ToArray(); var language = model.Language > 0 ? languages.FirstOrDefault(l => l.Id == model.Language) : null; // process wildcard if (language != null) { // 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 = Services.DomainService.Save(wildcard); if (saveAttempt == false) { var response = Request.CreateResponse(HttpStatusCode.BadRequest); response.Content = new StringContent("Saving domain failed"); response.ReasonPhrase = saveAttempt.Result.Result.ToString(); throw new HttpResponseException(response); } } else { var wildcard = domains.FirstOrDefault(d => d.IsWildcard); if (wildcard != null) { Services.DomainService.Delete(wildcard); } } // 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))) { Services.DomainService.Delete(domain); } var names = new List(); // create or update domains in the model foreach (var domainModel in model.Domains.Where(m => string.IsNullOrWhiteSpace(m.Name) == false)) { 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); var domain = domains.FirstOrDefault(d => d.DomainName.InvariantEquals(domainModel.Name)); if (domain != null) { domain.LanguageId = language.Id; Services.DomainService.Save(domain); } else if (Services.DomainService.Exists(domainModel.Name)) { domainModel.Duplicate = true; var xdomain = Services.DomainService.GetByName(domainModel.Name); var xrcid = xdomain.RootContentId; if (xrcid.HasValue) { var xcontent = Services.ContentService.GetById(xrcid.Value); var xnames = new List(); while (xcontent != null) { xnames.Add(xcontent.Name); if (xcontent.ParentId < -1) xnames.Add("Recycle Bin"); xcontent = xcontent.Parent(Services.ContentService); } xnames.Reverse(); domainModel.Other = "/" + string.Join("/", xnames); } } else { // yet there is a race condition here... var newDomain = new UmbracoDomain(name) { LanguageId = domainModel.Lang, RootContentId = model.NodeId }; var saveAttempt = Services.DomainService.Save(newDomain); if (saveAttempt == false) { var response = Request.CreateResponse(HttpStatusCode.BadRequest); response.Content = new StringContent("Saving new domain failed"); response.ReasonPhrase = saveAttempt.Result.Result.ToString(); throw new HttpResponseException(response); } } } model.Valid = model.Domains.All(m => m.Duplicate == false); return model; } /// /// Override to ensure there is culture specific errors in the result if any errors are for culture properties /// /// /// /// This is required to wire up the validation in the save/publish dialog /// protected override void HandleInvalidModelState(IErrorModel display) { if (!ModelState.IsValid) { //Add any culture specific errors here var cultureErrors = ModelState.GetCulturesWithPropertyErrors(); foreach (var cultureError in cultureErrors) { AddCultureValidationError(cultureError, "speechBubbles/contentCultureValidationError"); } } base.HandleInvalidModelState(display); } /// /// Maps the dto property values and names to the persisted model /// /// private void MapValuesForPersistence(ContentItemSave contentSave) { //inline method to determine if a property type varies bool Varies(Property property) => property.PropertyType.VariesByCulture(); var variantIndex = 0; //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()) { if (variant.Culture.IsNullOrWhiteSpace()) throw new InvalidOperationException($"Cannot set culture name without a culture."); contentSave.PersistedContent.SetCultureName(variant.Name, variant.Culture); } else { 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()) }; //for each variant, map the property values MapPropertyValuesForPersistence( contentSave, propertyCollection, (save, property) => Varies(property) ? property.GetValue(variant.Culture) : property.GetValue(), //get prop val (save, property, v) => { if (Varies(property)) property.SetValue(v, variant.Culture); else property.SetValue(v); }); //set prop val variantIndex++; } //TODO: We need to support 'send to publish' contentSave.PersistedContent.ExpireDate = contentSave.ExpireDate; contentSave.PersistedContent.ReleaseDate = contentSave.ReleaseDate; //only set the template if it didn't change var templateChanged = (contentSave.PersistedContent.Template == null && contentSave.TemplateAlias.IsNullOrWhiteSpace() == false) || (contentSave.PersistedContent.Template != null && contentSave.PersistedContent.Template.Alias != contentSave.TemplateAlias) || (contentSave.PersistedContent.Template != null && contentSave.TemplateAlias.IsNullOrWhiteSpace()); if (templateChanged) { var template = Services.FileService.GetTemplate(contentSave.TemplateAlias); if (template == null && contentSave.TemplateAlias.IsNullOrWhiteSpace() == false) { //ModelState.AddModelError("Template", "No template exists with the specified alias: " + contentItem.TemplateAlias); Logger.Warn("No template exists with the specified alias: " + contentSave.TemplateAlias); } else { //NOTE: this could be null if there was a template and the posted template is null, this should remove the assigned template contentSave.PersistedContent.Template = template; } } } /// /// Ensures the item can be moved/copied to the new location /// /// /// private IContent ValidateMoveOrCopy(MoveOrCopy model) { if (model == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } var contentService = Services.ContentService; var toMove = contentService.GetById(model.Id); if (toMove == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } if (model.ParentId < 0) { //cannot move if the content item is not allowed at the root if (toMove.ContentType.AllowedAsRoot == false) { throw new HttpResponseException( Request.CreateNotificationValidationErrorResponse( Services.TextService.Localize("moveOrCopy/notAllowedAtRoot"))); } } else { var parent = contentService.GetById(model.ParentId); if (parent == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } //check if the item is allowed under this one if (parent.ContentType.AllowedContentTypes.Select(x => x.Id).ToArray() .Any(x => x.Value == toMove.ContentType.Id) == false) { throw new HttpResponseException( Request.CreateNotificationValidationErrorResponse( Services.TextService.Localize("moveOrCopy/notAllowedByContentType"))); } // Check on paths if ((string.Format(",{0},", parent.Path)).IndexOf(string.Format(",{0},", toMove.Id), StringComparison.Ordinal) > -1) { throw new HttpResponseException( Request.CreateNotificationValidationErrorResponse( Services.TextService.Localize("moveOrCopy/notAllowedByPath"))); } } return 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 succesfully published /// private void AddMessageForPublishStatus(PublishResult status, INotificationModel display, string[] successfulCultures = null) { switch (status.Result) { case PublishResultType.Success: case PublishResultType.SuccessAlready: if (successfulCultures == null) { display.AddSuccessNotification( Services.TextService.Localize("speechBubbles/editContentPublishedHeader"), Services.TextService.Localize("speechBubbles/editContentPublishedText")); } else { foreach (var c in successfulCultures) { display.AddSuccessNotification( Services.TextService.Localize("speechBubbles/editVariantContentPublishedHeader", new[]{ _allLangs.Value[c].CultureName}), Services.TextService.Localize("speechBubbles/editContentPublishedText")); } } break; case PublishResultType.FailedPathNotPublished: display.AddWarningNotification( Services.TextService.Localize("publish"), Services.TextService.Localize("publish/contentPublishedFailedByParent", new[] { $"{status.Content.Name} ({status.Content.Id})" }).Trim()); break; case PublishResultType.FailedCancelledByEvent: AddCancelMessage(display, "publish", "speechBubbles/contentPublishedFailedByEvent"); break; case PublishResultType.FailedAwaitingRelease: //TODO: We'll need to deal with variants here eventually display.AddWarningNotification( Services.TextService.Localize("publish"), Services.TextService.Localize("publish/contentPublishedFailedAwaitingRelease", new[] { $"{status.Content.Name} ({status.Content.Id})" }).Trim()); break; case PublishResultType.FailedHasExpired: //TODO: We'll need to deal with variants here eventually display.AddWarningNotification( Services.TextService.Localize("publish"), Services.TextService.Localize("publish/contentPublishedFailedExpired", new[] { $"{status.Content.Name} ({status.Content.Id})", }).Trim()); break; case PublishResultType.FailedIsTrashed: display.AddWarningNotification( Services.TextService.Localize("publish"), "publish/contentPublishedFailedIsTrashed"); // fixme properly localize, these keys are missing from lang files! break; case PublishResultType.FailedContentInvalid: display.AddWarningNotification( Services.TextService.Localize("publish"), Services.TextService.Localize("publish/contentPublishedFailedInvalid", new[] { $"{status.Content.Name} ({status.Content.Id})", string.Join(",", status.InvalidProperties.Select(x => x.Alias)) }).Trim()); break; case PublishResultType.FailedByCulture: display.AddWarningNotification( Services.TextService.Localize("publish"), "publish/contentPublishedFailedByCulture"); // fixme properly localize, these keys are missing from lang files! break; default: throw new IndexOutOfRangeException($"PublishedResultType \"{status.Result}\" was not expected."); } } /// /// Performs a permissions check for the user to check if it has access to the node based on /// start node and/or permissions for the node /// /// The storage to add the content item to so it can be reused /// /// /// /// /// The content to lookup, if the contentItem is not specified /// /// Specifies the already resolved content item to check against /// internal static bool CheckPermissions( IDictionary storage, IUser user, IUserService userService, IContentService contentService, IEntityService entityService, int nodeId, char[] permissionsToCheck = null, IContent contentItem = null) { if (storage == null) throw new ArgumentNullException("storage"); if (user == null) throw new ArgumentNullException("user"); if (userService == null) throw new ArgumentNullException("userService"); if (contentService == null) throw new ArgumentNullException("contentService"); if (entityService == null) throw new ArgumentNullException("entityService"); if (contentItem == null && nodeId != Constants.System.Root && nodeId != Constants.System.RecycleBinContent) { contentItem = contentService.GetById(nodeId); //put the content item into storage so it can be retreived // in the controller (saves a lookup) storage[typeof(IContent).ToString()] = contentItem; } if (contentItem == null && nodeId != Constants.System.Root && nodeId != Constants.System.RecycleBinContent) { throw new HttpResponseException(HttpStatusCode.NotFound); } var hasPathAccess = (nodeId == Constants.System.Root) ? user.HasContentRootAccess(entityService) : (nodeId == Constants.System.RecycleBinContent) ? user.HasContentBinAccess(entityService) : user.HasPathAccess(contentItem, entityService); if (hasPathAccess == false) { return false; } if (permissionsToCheck == null || permissionsToCheck.Length == 0) { return true; } //get the implicit/inherited permissions for the user for this path, //if there is no content item for this id, than just use the id as the path (i.e. -1 or -20) var path = contentItem != null ? contentItem.Path : nodeId.ToString(); var permission = userService.GetPermissionsForPath(user, path); var allowed = true; foreach (var p in permissionsToCheck) { if (permission == null || permission.GetAllPermissions().Contains(p.ToString(CultureInfo.InvariantCulture)) == false) { allowed = false; } } return allowed; } /// /// Used to map an instance to a and ensuring a language is present if required /// /// /// private ContentItemDisplay MapToDisplay(IContent content) { var display = Mapper.Map(content); return display; } [EnsureUserPermissionForContent("contentId", 'R')] public IEnumerable GetNotificationOptions(int contentId) { var notifications = new List(); if (contentId <= 0) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); var content = Services.ContentService.GetById(contentId); if (content == null) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); var userNotifications = Services.NotificationService.GetUserNotifications(Security.CurrentUser, content.Path).ToList(); foreach (var a in Current.Actions.Where(x => x.ShowInNotifier)) { var n = new NotifySetting { Name = Services.TextService.Localize("actions", a.Alias), Checked = userNotifications.FirstOrDefault(x=> x.Action == a.Letter.ToString()) != null, NotifyCode = a.Letter.ToString() }; notifications.Add(n); } return notifications; } public void PostNotificationOptions(int contentId, [FromUri] string[] notifyOptions) { if (contentId <= 0) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); var content = Services.ContentService.GetById(contentId); if (content == null) throw new HttpResponseException(Request.CreateResponse(HttpStatusCode.NotFound)); Services.NotificationService.SetNotifications(Security.CurrentUser, content, notifyOptions); } } }