diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Controllers/ApiController.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Controllers/ApiController.cs deleted file mode 100644 index 59e07313e6c..00000000000 --- a/src/OrchardCore.Modules/OrchardCore.Contents/Controllers/ApiController.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System.Linq; -using System.Net; -using System.Security.Claims; -using System.Text.Json.Nodes; -using System.Text.Json.Settings; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Localization; -using OrchardCore.ContentManagement; -using OrchardCore.ContentManagement.Handlers; -using OrchardCore.ContentManagement.Metadata; - -namespace OrchardCore.Contents.Controllers -{ - [Route("api/content")] - [ApiController] - [Authorize(AuthenticationSchemes = "Api"), IgnoreAntiforgeryToken, AllowAnonymous] - public class ApiController : Controller - { - private static readonly JsonMergeSettings _updateJsonMergeSettings = new() - { - MergeArrayHandling = MergeArrayHandling.Replace - }; - - private readonly IContentManager _contentManager; - private readonly IContentDefinitionManager _contentDefinitionManager; - private readonly IAuthorizationService _authorizationService; - - protected readonly IStringLocalizer S; - - public ApiController( - IContentManager contentManager, - IContentDefinitionManager contentDefinitionManager, - IAuthorizationService authorizationService, - IStringLocalizer stringLocalizer) - { - _contentManager = contentManager; - _contentDefinitionManager = contentDefinitionManager; - _authorizationService = authorizationService; - S = stringLocalizer; - } - - [HttpGet] - [Route("{contentItemId}")] - public async Task Get(string contentItemId) - { - if (!await _authorizationService.AuthorizeAsync(User, Permissions.AccessContentApi)) - { - return this.ChallengeOrForbid("Api"); - } - - var contentItem = await _contentManager.GetAsync(contentItemId); - - if (contentItem == null) - { - return NotFound(); - } - - if (!await _authorizationService.AuthorizeAsync(User, CommonPermissions.ViewContent, contentItem)) - { - return this.ChallengeOrForbid("Api"); - } - - return Ok(contentItem); - } - - [HttpDelete] - [Route("{contentItemId}")] - public async Task Delete(string contentItemId) - { - if (!await _authorizationService.AuthorizeAsync(User, Permissions.AccessContentApi)) - { - return this.ChallengeOrForbid("Api"); - } - - var contentItem = await _contentManager.GetAsync(contentItemId); - - if (contentItem == null) - { - return NoContent(); - } - - if (!await _authorizationService.AuthorizeAsync(User, CommonPermissions.DeleteContent, contentItem)) - { - return this.ChallengeOrForbid("Api"); - } - - await _contentManager.RemoveAsync(contentItem); - - return Ok(contentItem); - } - - [HttpPost] - public async Task Post(ContentItem model, bool draft = false) - { - if (!await _authorizationService.AuthorizeAsync(User, Permissions.AccessContentApi)) - { - return this.ChallengeOrForbid("Api"); - } - - // 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. - - var contentItem = await _contentManager.GetAsync(model.ContentItemId, VersionOptions.DraftRequired); - - if (contentItem == null) - { - 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); - - if (!await _authorizationService.AuthorizeAsync(User, CommonPermissions.PublishContent, contentItem)) - { - return this.ChallengeOrForbid("Api"); - } - - contentItem.Merge(model); - - var result = await _contentManager.UpdateValidateAndCreateAsync(contentItem, VersionOptions.Draft); - - if (!result.Succeeded) - { - // Add the validation results to the ModelState to present the errors as part of the response. - AddValidationErrorsToModelState(result); - } - - // 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 = S["One or more validation errors occurred."], - Detail = string.Join(", ", ModelState.Values.SelectMany(x => x.Errors.Select(x => x.ErrorMessage))), - Status = (int)HttpStatusCode.BadRequest, - }); - } - } - else - { - 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 (!result.Succeeded) - { - // Add the validation results to the ModelState to present the errors as part of the response. - AddValidationErrorsToModelState(result); - } - - // 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 = S["One or more validation errors occurred."], - Detail = string.Join(", ", ModelState.Values.SelectMany(x => x.Errors.Select(x => x.ErrorMessage))), - Status = (int)HttpStatusCode.BadRequest, - }); - } - } - - if (!draft) - { - await _contentManager.PublishAsync(contentItem); - } - else - { - await _contentManager.SaveDraftAsync(contentItem); - } - - return Ok(contentItem); - } - - private void AddValidationErrorsToModelState(ContentValidateResult result) - { - foreach (var error in result.Errors) - { - if (error.MemberNames != null && error.MemberNames.Any()) - { - foreach (var memberName in error.MemberNames) - { - ModelState.AddModelError(memberName, error.ErrorMessage); - } - } - else - { - ModelState.AddModelError(string.Empty, error.ErrorMessage); - } - } - } - } -} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Endpoints/Api/CreateEndpoint.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Endpoints/Api/CreateEndpoint.cs new file mode 100644 index 00000000000..60ca5cdb575 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Endpoints/Api/CreateEndpoint.cs @@ -0,0 +1,143 @@ +using System.Linq; +using System.Security.Claims; +using System.Text.Json.Settings; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Handlers; +using OrchardCore.ContentManagement.Metadata; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.Modules; + +namespace OrchardCore.Contents.Endpoints.Api; + +public static class CreateEndpoint +{ + public static IEndpointRouteBuilder AddCreateContentEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPost("api/content", ActionAsync) + .AllowAnonymous() + .DisableAntiforgery(); + + return builder; + } + + private static readonly JsonMergeSettings _updateJsonMergeSettings = new() + { + MergeArrayHandling = MergeArrayHandling.Replace, + }; + + [Authorize(AuthenticationSchemes = "Api")] + private static async Task ActionAsync( + ContentItem model, + IContentManager contentManager, + IAuthorizationService authorizationService, + IContentDefinitionManager contentDefinitionManager, + IUpdateModelAccessor updateModelAccessor, + HttpContext httpContext, + bool draft = false) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, Permissions.AccessContentApi)) + { + return httpContext.ChallengeOrForbid("Api"); + } + + var contentItem = await contentManager.GetAsync(model.ContentItemId, VersionOptions.DraftRequired); + var modelState = updateModelAccessor.ModelUpdater.ModelState; + + if (contentItem == null) + { + if (string.IsNullOrEmpty(model?.ContentType) || await contentDefinitionManager.GetTypeDefinitionAsync(model.ContentType) == null) + { + return TypedResults.BadRequest(); + } + + contentItem = await contentManager.NewAsync(model.ContentType); + contentItem.Owner = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (!await authorizationService.AuthorizeAsync(httpContext.User, CommonPermissions.PublishContent, contentItem)) + { + return httpContext.ChallengeOrForbid("Api"); + } + + contentItem.Merge(model); + + var result = await contentManager.UpdateValidateAndCreateAsync(contentItem, VersionOptions.Draft); + + if (!result.Succeeded) + { + // Add the validation results to the ModelState to present the errors as part of the response. + AddValidationErrorsToModelState(result, modelState); + } + + // 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) + { + var errors = modelState.ToDictionary(entry => entry.Key, entry => entry.Value.Errors.Select(x => x.ErrorMessage).ToArray()); + + return TypedResults.ValidationProblem(errors, detail: string.Join(", ", modelState.Values.SelectMany(x => x.Errors.Select(x => x.ErrorMessage)))); + } + } + else + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, CommonPermissions.EditContent, contentItem)) + { + return httpContext.ChallengeOrForbid("Api"); + } + + contentItem.Merge(model, _updateJsonMergeSettings); + + await contentManager.UpdateAsync(contentItem); + var result = await contentManager.ValidateAsync(contentItem); + + if (!result.Succeeded) + { + // Add the validation results to the ModelState to present the errors as part of the response. + AddValidationErrorsToModelState(result, modelState); + } + + // 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) + { + var errors = modelState.ToDictionary(entry => entry.Key, entry => entry.Value.Errors.Select(x => x.ErrorMessage).ToArray()); + + return TypedResults.ValidationProblem(errors, detail: string.Join(", ", modelState.Values.SelectMany(x => x.Errors.Select(x => x.ErrorMessage)))); + } + } + + if (!draft) + { + await contentManager.PublishAsync(contentItem); + } + else + { + await contentManager.SaveDraftAsync(contentItem); + } + + return TypedResults.Ok(contentItem); + } + + private static void AddValidationErrorsToModelState(ContentValidateResult result, ModelStateDictionary modelState) + { + foreach (var error in result.Errors) + { + if (error.MemberNames != null && error.MemberNames.Any()) + { + foreach (var memberName in error.MemberNames) + { + modelState.AddModelError(memberName, error.ErrorMessage); + } + } + else + { + modelState.AddModelError(string.Empty, error.ErrorMessage); + } + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Endpoints/Api/DeleteEndpoint.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Endpoints/Api/DeleteEndpoint.cs new file mode 100644 index 00000000000..e3b2b58505b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Endpoints/Api/DeleteEndpoint.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using OrchardCore.ContentManagement; +using OrchardCore.Modules; + +namespace OrchardCore.Contents.Endpoints.Api; + +public static class DeleteEndpoint +{ + public static IEndpointRouteBuilder AddDeleteContentEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapDelete("api/content/{contentItemId}", ActionAsync) + .AllowAnonymous() + .DisableAntiforgery(); + + return builder; + } + + [Authorize(AuthenticationSchemes = "Api")] + private static async Task ActionAsync( + string contentItemId, + IContentManager contentManager, + IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, Permissions.AccessContentApi)) + { + return httpContext.ChallengeOrForbid("Api"); + } + + var contentItem = await contentManager.GetAsync(contentItemId); + + if (contentItem == null) + { + return TypedResults.NotFound(); + } + + if (!await authorizationService.AuthorizeAsync(httpContext.User, CommonPermissions.DeleteContent, contentItem)) + { + return httpContext.ChallengeOrForbid("Api"); + } + + await contentManager.RemoveAsync(contentItem); + + return TypedResults.Ok(contentItem); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Endpoints/Api/GetEndpoint.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Endpoints/Api/GetEndpoint.cs new file mode 100644 index 00000000000..851ce7a07f3 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Endpoints/Api/GetEndpoint.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using OrchardCore.ContentManagement; +using OrchardCore.Modules; + +namespace OrchardCore.Contents.Endpoints.Api; + +public static class GetEndpoint +{ + public static IEndpointRouteBuilder AddGetContentEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapGet("api/content/{contentItemId}", ActionAsync) + .AllowAnonymous() + .DisableAntiforgery(); + + return builder; + } + + [Authorize(AuthenticationSchemes = "Api")] + private static async Task ActionAsync( + string contentItemId, + IContentManager contentManager, + IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, Permissions.AccessContentApi)) + { + return httpContext.ChallengeOrForbid("Api"); + } + + var contentItem = await contentManager.GetAsync(contentItemId); + + if (contentItem == null) + { + return TypedResults.NotFound(); + } + + if (!await authorizationService.AuthorizeAsync(httpContext.User, CommonPermissions.ViewContent, contentItem)) + { + return httpContext.ChallengeOrForbid("Api"); + } + + return TypedResults.Ok(contentItem); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/OrchardCore.Contents.csproj b/src/OrchardCore.Modules/OrchardCore.Contents/OrchardCore.Contents.csproj index 7f436fa8e27..09d1bbb2447 100644 --- a/src/OrchardCore.Modules/OrchardCore.Contents/OrchardCore.Contents.csproj +++ b/src/OrchardCore.Modules/OrchardCore.Contents/OrchardCore.Contents.csproj @@ -4,9 +4,10 @@ true OrchardCore Contents - $(OCCMSDescription) + + $(OCCMSDescription) - Provides Content Management services. + Provides Content Management services. $(PackageTags) OrchardCoreCMS ContentManagement diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Startup.cs index 67a3ac3d519..7023c50b7f2 100644 --- a/src/OrchardCore.Modules/OrchardCore.Contents/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Startup.cs @@ -18,6 +18,7 @@ using OrchardCore.Contents.Controllers; using OrchardCore.Contents.Deployment; using OrchardCore.Contents.Drivers; +using OrchardCore.Contents.Endpoints.Api; using OrchardCore.Contents.Feeds.Builders; using OrchardCore.Contents.Handlers; using OrchardCore.Contents.Indexing; @@ -212,6 +213,10 @@ static async Task GetContentByHandleAsync(LiquidTemplateContext cont public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) { + routes.AddGetContentEndpoint() + .AddCreateContentEndpoint() + .AddDeleteContentEndpoint(); + var itemControllerName = typeof(ItemController).ControllerName(); routes.MapAreaControllerRoute( diff --git a/src/OrchardCore/OrchardCore.Abstractions/Modules/Extensions/HttpContextExtensions.cs b/src/OrchardCore/OrchardCore.Abstractions/Modules/Extensions/HttpContextExtensions.cs index e89ee753a2c..8a683303cc7 100644 --- a/src/OrchardCore/OrchardCore.Abstractions/Modules/Extensions/HttpContextExtensions.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Modules/Extensions/HttpContextExtensions.cs @@ -1,17 +1,23 @@ using Microsoft.AspNetCore.Http; using OrchardCore.Environment.Shell.Scope; -namespace OrchardCore.Modules +namespace OrchardCore.Modules; + +public static class HttpContextExtensions { - public static class HttpContextExtensions + /// + /// Makes aware of the current . + /// + public static HttpContext UseShellScopeServices(this HttpContext httpContext) + { + httpContext.RequestServices = new ShellScopeServices(httpContext.RequestServices); + return httpContext; + } + + public static IResult ChallengeOrForbid(this HttpContext httpContext, params string[] authenticationSchemes) { - /// - /// Makes aware of the current . - /// - public static HttpContext UseShellScopeServices(this HttpContext httpContext) - { - httpContext.RequestServices = new ShellScopeServices(httpContext.RequestServices); - return httpContext; - } + return httpContext.User?.Identity?.IsAuthenticated == true + ? TypedResults.Forbid(properties: null, authenticationSchemes) + : TypedResults.Challenge(properties: null, authenticationSchemes); } }