diff --git a/Lombiq.JsonEditor/Controllers/AdminController.cs b/Lombiq.JsonEditor/Controllers/AdminController.cs index 509ed55..e803305 100644 --- a/Lombiq.JsonEditor/Controllers/AdminController.cs +++ b/Lombiq.JsonEditor/Controllers/AdminController.cs @@ -1,6 +1,6 @@ -using AngleSharp.Common; using Lombiq.HelpfulLibraries.OrchardCore.Contents; using Lombiq.HelpfulLibraries.OrchardCore.DependencyInjection; +using Lombiq.HelpfulLibraries.OrchardCore.Validation; using Lombiq.JsonEditor.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -9,23 +9,31 @@ using Microsoft.Extensions.Localization; using OrchardCore.Admin; using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Handlers; using OrchardCore.ContentManagement.Metadata; using OrchardCore.Contents; -using OrchardCore.Contents.Controllers; using OrchardCore.DisplayManagement; using OrchardCore.DisplayManagement.Layout; using OrchardCore.DisplayManagement.Notify; using OrchardCore.DisplayManagement.Title; using OrchardCore.Title.ViewModels; using System; +using System.Linq; +using System.Net; using System.Security.Claims; using System.Text.Json; +using System.Text.Json.Settings; using System.Threading.Tasks; namespace Lombiq.JsonEditor.Controllers; public class AdminController : Controller { + private static readonly JsonMergeSettings _updateJsonMergeSettings = new() + { + MergeArrayHandling = MergeArrayHandling.Replace, + }; + private readonly IAuthorizationService _authorizationService; private readonly IContentManager _contentManager; private readonly IContentDefinitionManager _contentDefinitionManager; @@ -33,7 +41,6 @@ public class AdminController : Controller private readonly INotifier _notifier; private readonly IPageTitleBuilder _pageTitleBuilder; private readonly IShapeFactory _shapeFactory; - private readonly Lazy _contentApiControllerLazy; private readonly IStringLocalizer T; private readonly IHtmlLocalizer H; @@ -43,8 +50,7 @@ public AdminController( INotifier notifier, IPageTitleBuilder pageTitleBuilder, IShapeFactory shapeFactory, - IOrchardServices services, - Lazy contentApiControllerLazy) + IOrchardServices services) { _authorizationService = services.AuthorizationService.Value; _contentManager = services.ContentManager.Value; @@ -53,7 +59,6 @@ public AdminController( _notifier = notifier; _pageTitleBuilder = pageTitleBuilder; _shapeFactory = shapeFactory; - _contentApiControllerLazy = contentApiControllerLazy; T = services.StringLocalizer.Value; H = services.HtmlLocalizer.Value; } @@ -132,34 +137,91 @@ public async Task EditPost( private Task CanEditAsync(ContentItem contentItem) => _authorizationService.AuthorizeAsync(User, CommonPermissions.EditContent, contentItem); - private async Task UpdateContentAsync(ContentItem contentItem, bool isDraft) + private Task UpdateContentAsync(ContentItem contentItem, bool isDraft) => + PostContentAsync(contentItem, isDraft); + + private static bool IsContinue(string submitString) => + submitString?.EndsWithOrdinalIgnoreCase("AndContinue") == true; + + private static string GetName(ContentItem contentItem) => + string.IsNullOrWhiteSpace(contentItem.DisplayText) + ? contentItem.ContentType + : $"\"{contentItem.DisplayText}\""; + + // Based on the OrchardCore.Contents.Controllers.ApiController.Post action that was deleted in + // https://github.com/OrchardCMS/OrchardCore/commit/d524386b2f792f35773324ae482247e80a944266 to replace with minimal + // APIs that can't be reused the same way. + private async Task PostContentAsync(ContentItem model, bool draft) { - // The Content API Controller requires the AccessContentApi permission. As this isn't an external API request it - // doesn't make sense to require this permission. So we create a temporary claims principal and explicitly grant - // the permission. - var currentUser = User; - HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(User.Claims.Concat(Permissions.AccessContentApi))); + // It is really important to keep the proper method calls order with the ContentManager + // so that all event handlers gets triggered in the right sequence. + + if (await _contentManager.GetAsync(model.ContentItemId, VersionOptions.DraftRequired) is { } contentItem) + { + if (!await _authorizationService.AuthorizeAsync(User, CommonPermissions.EditContent, contentItem)) + { + return this.ChallengeOrForbid("Api"); + } + + contentItem.Merge(model, _updateJsonMergeSettings); + + await _contentManager.UpdateAsync(contentItem); + var result = await _contentManager.ValidateAsync(contentItem); + if (CheckContentValidationResult(result) is { } problem) return problem; + } + else + { + if (string.IsNullOrEmpty(model.ContentType) || await _contentDefinitionManager.GetTypeDefinitionAsync(model.ContentType) == null) + { + return BadRequest(); + } + + contentItem = await _contentManager.NewAsync(model.ContentType); + contentItem.Owner = User.FindFirstValue(ClaimTypes.NameIdentifier); - try + if (!await _authorizationService.AuthorizeAsync(User, CommonPermissions.PublishContent, contentItem)) + { + return this.ChallengeOrForbid("Api"); + } + + contentItem.Merge(model); + + var result = await _contentManager.UpdateValidateAndCreateAsync(contentItem, VersionOptions.Draft); + if (CheckContentValidationResult(result) is { } problem) return problem; + } + + if (draft) { - // Here the API controller is called directly. The behavior is the same as if we sent a POST request using an - // HTTP client (except the permission bypass above), but it's faster and more resource-efficient. - var contentApiController = _contentApiControllerLazy.Value; - contentApiController.ControllerContext.HttpContext = HttpContext; - return await contentApiController.Post(contentItem, isDraft); + await _contentManager.SaveDraftAsync(contentItem); } - finally + else { - // Ensure that the original claims principal is restored, just in case. - HttpContext.User = currentUser; + await _contentManager.PublishAsync(contentItem); } + + return Ok(contentItem); } - private static bool IsContinue(string submitString) => - submitString?.EndsWithOrdinalIgnoreCase("AndContinue") == true; + private ActionResult CheckContentValidationResult(ContentValidateResult result) + { + if (!result.Succeeded) + { + // Add the validation results to the ModelState to present the errors as part of the response. + result.AddValidationErrorsToModelState(ModelState); + } - private static string GetName(ContentItem contentItem) => - string.IsNullOrWhiteSpace(contentItem.DisplayText) - ? contentItem.ContentType - : $"\"{contentItem.DisplayText}\""; + // We check the model state after calling all handlers because they trigger WF content events so, even they are not + // intended to add model errors (only drivers), a WF content task may be executed inline and add some model errors. + if (!ModelState.IsValid) + { + return ValidationProblem(new ValidationProblemDetails(ModelState) + { + Title = T["One or more validation errors occurred."], + Detail = string.Join(", ", ModelState.Values.SelectMany(x => x.Errors.Select(x => x.ErrorMessage))), + Status = (int)HttpStatusCode.BadRequest, + }); + } + + return null; + } } diff --git a/Lombiq.JsonEditor/Startup.cs b/Lombiq.JsonEditor/Startup.cs index 47c104f..df8df56 100644 --- a/Lombiq.JsonEditor/Startup.cs +++ b/Lombiq.JsonEditor/Startup.cs @@ -40,7 +40,6 @@ public override void ConfigureServices(IServiceCollection services) { services.AddScoped(); services.AddOrchardServices(); - services.AddScoped(); services.AddContentSecurityPolicyProvider(); } }