From c7a38cdac44ef1dc456884cb8575a865cb62c82b Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Wed, 5 Jul 2023 14:56:51 +0200 Subject: [PATCH] V14: Public access controller (#14501) * Implement GetEntryByContentKey * Implement PublicAccessResponseModel * Implement IPublicAccessPresentationFactory * Rename MemberGroupItemReponseModel to MemberGroupItemResponseModel * Refactor PublicAccessResponseModel to use Ids instead of entire content items * Return attempt instead of PresentationModel * Add missing statuses to PublicAccessOperationStatusResult * Implement PublicAccessDocumentController.cs * Refacotr PublicAccessResponseModel to use a base model * Add CreatePublicAccessEntry method * Refactor AccessRequestModel to use names not ids :( * Rename ErrorPageNotFound to ErrorNodeNotFound * Implement new SaveAsync method * Introduce more OperationResults * Implement PublicAccessEntrySlim * Implement SaveAsync * Remove CreatePublicAccessEntry from presentation factory * Rename to CreateAsync * Implement UpdateAsync * Rename to async * Implement CreatePublicAccessEntry * Implement update endpoint * remove PublicAccessEntrySlim mapping * implement CreatePublicAccessEntrySlim method * Refactor UpdateAsync * Remove ContentId from request model as it should be in the request * Use new service layer * Amend method name in update controller * Refactor create public access entry to use async method and return entity * Refactor to use saveAsync method instead of synchronously * Use presentation factory instead of mapping * Implement deleteAsync endpoint * Add produces response type * Refactor mapping to not use UmbracoMapper, as that causes errors * Update OpenApi.json * Refactor out variables to intermediate object * Validate that groups and names are not specified at the same time * Make presentation factory not async * Minor cleanup --------- Co-authored-by: Zeegaan Co-authored-by: Nikolaj --- .../Content/ContentControllerBase.cs | 21 ++ .../CreatePublicAccessDocumentController.cs | 41 ++++ .../DeletePublicAccessDocumentController.cs | 24 ++ .../GetPublicAccessDocumentController.cs | 47 ++++ .../UpdatePublicAccessDocumentController.cs | 41 ++++ .../Item/ItemMemberGroupItemController.cs | 4 +- .../DocumentBuilderExtensions.cs | 1 + .../IPublicAccessPresentationFactory.cs | 13 ++ .../PublicAccessPresentationFactory.cs | 95 ++++++++ .../Mapping/Items/ItemTypeMapDefinition.cs | 4 +- src/Umbraco.Cms.Api.Management/OpenApi.json | 207 ++++++++++++++++- ...del.cs => MemberGroupItemResponseModel.cs} | 2 +- .../PublicAccess/PublicAccessBaseModel.cs | 8 + .../PublicAccess/PublicAccessRequestModel.cs | 8 + .../PublicAccess/PublicAccessResponseModel.cs | 11 + .../Models/PublicAccessEntrySlim.cs | 14 ++ .../PublicAccessNodesValidationResult.cs | 11 + .../Services/IPublicAccessService.cs | 26 +++ .../PublicAccessOperationStatus.cs | 12 + .../Services/PublicAccessService.cs | 216 +++++++++++++++++- 20 files changed, 795 insertions(+), 11 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/CreatePublicAccessDocumentController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/DeletePublicAccessDocumentController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/GetPublicAccessDocumentController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/UpdatePublicAccessDocumentController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/IPublicAccessPresentationFactory.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/PublicAccessPresentationFactory.cs rename src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/Item/{MemberGroupItemReponseModel.cs => MemberGroupItemResponseModel.cs} (65%) create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessBaseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessResponseModel.cs create mode 100644 src/Umbraco.Core/Models/PublicAccessEntrySlim.cs create mode 100644 src/Umbraco.Core/Models/PublicAccessNodesValidationResult.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/PublicAccessOperationStatus.cs diff --git a/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs index 7f36013e08..3533da2a1d 100644 --- a/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs @@ -50,4 +50,25 @@ public class ContentControllerBase : ManagementApiControllerBase ContentCreatingOperationStatus.NotFound => NotFound("The content type could not be found"), _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."), }; + + protected IActionResult PublicAccessOperationStatusResult(PublicAccessOperationStatus status) => + status switch + { + PublicAccessOperationStatus.ContentNotFound => NotFound("The content could not be found"), + PublicAccessOperationStatus.ErrorNodeNotFound => NotFound("The error page could not be found"), + PublicAccessOperationStatus.LoginNodeNotFound => NotFound("The login page could not be found"), + PublicAccessOperationStatus.NoAllowedEntities => BadRequest(new ProblemDetailsBuilder() + .WithTitle("No allowed entities given") + .WithDetail("Both MemberGroups and Members were empty, thus no entities can be allowed.") + .Build()), + PublicAccessOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Request cancelled by notification") + .WithDetail("The request to save a public access entry was cancelled by a notification handler.") + .Build()), + PublicAccessOperationStatus.AmbiguousRule => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Ambiguous Rule") + .WithDetail("The specified rule is ambiguous, because both member groups and member names were given.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."), + }; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreatePublicAccessDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreatePublicAccessDocumentController.cs new file mode 100644 index 0000000000..4c1ca4cb90 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreatePublicAccessDocumentController.cs @@ -0,0 +1,41 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.PublicAccess; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public class CreatePublicAccessDocumentController : DocumentControllerBase +{ + private readonly IPublicAccessService _publicAccessService; + private readonly IPublicAccessPresentationFactory _publicAccessPresentationFactory; + + public CreatePublicAccessDocumentController( + IPublicAccessService publicAccessService, + IPublicAccessPresentationFactory publicAccessPresentationFactory) + { + _publicAccessService = publicAccessService; + _publicAccessPresentationFactory = publicAccessPresentationFactory; + } + + [MapToApiVersion("1.0")] + [HttpPost("{id:guid}/public-access")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Create(Guid id, PublicAccessRequestModel publicAccessRequestModel) + { + PublicAccessEntrySlim publicAccessEntrySlim = _publicAccessPresentationFactory.CreatePublicAccessEntrySlim(publicAccessRequestModel, id); + + Attempt saveAttempt = await _publicAccessService.CreateAsync(publicAccessEntrySlim); + + return saveAttempt.Success + ? CreatedAtAction(controller => nameof(controller.GetPublicAccess), saveAttempt.Result!.Key) + : PublicAccessOperationStatusResult(saveAttempt.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DeletePublicAccessDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DeletePublicAccessDocumentController.cs new file mode 100644 index 0000000000..ffe6aa4de1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DeletePublicAccessDocumentController.cs @@ -0,0 +1,24 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public class DeletePublicAccessDocumentController : DocumentControllerBase +{ + private readonly IPublicAccessService _publicAccessService; + + public DeletePublicAccessDocumentController(IPublicAccessService publicAccessService) => _publicAccessService = publicAccessService; + + [MapToApiVersion("1.0")] + [HttpDelete("{id:guid}/public-access")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(Guid id) + { + await _publicAccessService.DeleteAsync(id); + + return Ok(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/GetPublicAccessDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/GetPublicAccessDocumentController.cs new file mode 100644 index 0000000000..ee34dfd522 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/GetPublicAccessDocumentController.cs @@ -0,0 +1,47 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.PublicAccess; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public class GetPublicAccessDocumentController : DocumentControllerBase +{ + private readonly IPublicAccessService _publicAccessService; + private readonly IPublicAccessPresentationFactory _publicAccessPresentationFactory; + + public GetPublicAccessDocumentController( + IPublicAccessService publicAccessService, + IPublicAccessPresentationFactory publicAccessPresentationFactory) + { + _publicAccessService = publicAccessService; + _publicAccessPresentationFactory = publicAccessPresentationFactory; + } + + [MapToApiVersion("1.0")] + [HttpGet("{id:guid}/public-access")] + public async Task GetPublicAccess(Guid id) + { + Attempt accessAttempt = + await _publicAccessService.GetEntryByContentKeyAsync(id); + + if (accessAttempt.Success is false) + { + return PublicAccessOperationStatusResult(accessAttempt.Status); + } + + Attempt responseModelAttempt = + _publicAccessPresentationFactory.CreatePublicAccessResponseModel(accessAttempt.Result!); + + if (responseModelAttempt.Success is false) + { + return PublicAccessOperationStatusResult(responseModelAttempt.Status); + } + + return Ok(responseModelAttempt.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdatePublicAccessDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdatePublicAccessDocumentController.cs new file mode 100644 index 0000000000..47e66d1eaf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdatePublicAccessDocumentController.cs @@ -0,0 +1,41 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.PublicAccess; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public class UpdatePublicAccessDocumentController : DocumentControllerBase +{ + private readonly IPublicAccessPresentationFactory _publicAccessPresentationFactory; + private readonly IPublicAccessService _publicAccessService; + + public UpdatePublicAccessDocumentController( + IPublicAccessPresentationFactory publicAccessPresentationFactory, + IPublicAccessService publicAccessService) + { + _publicAccessPresentationFactory = publicAccessPresentationFactory; + _publicAccessService = publicAccessService; + } + + [HttpPut("{id:guid}/public-access")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update(Guid id, PublicAccessRequestModel requestModel) + { + PublicAccessEntrySlim publicAccessEntrySlim = _publicAccessPresentationFactory.CreatePublicAccessEntrySlim(requestModel, id); + + Attempt updateAttempt = await _publicAccessService.UpdateAsync(publicAccessEntrySlim); + + return updateAttempt.Success + ? Ok() + : PublicAccessOperationStatusResult(updateAttempt.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/Item/ItemMemberGroupItemController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/Item/ItemMemberGroupItemController.cs index d3cf5dcb59..9e944f7d95 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/Item/ItemMemberGroupItemController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberGroup/Item/ItemMemberGroupItemController.cs @@ -24,11 +24,11 @@ public class ItemMemberGroupItemController : MemberGroupItemControllerBase [HttpGet("item")] [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] public async Task Item([FromQuery(Name = "id")] HashSet ids) { IEnumerable memberGroups = _entityService.GetAll(UmbracoObjectTypes.MemberGroup, ids.ToArray()); - List responseModel = _mapper.MapEnumerable(memberGroups); + List responseModel = _mapper.MapEnumerable(memberGroups); return Ok(responseModel); } } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs index 9b16d23022..c99d4e65a4 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs @@ -14,6 +14,7 @@ internal static class DocumentBuilderExtensions builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder() .Add() diff --git a/src/Umbraco.Cms.Api.Management/Factories/IPublicAccessPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IPublicAccessPresentationFactory.cs new file mode 100644 index 0000000000..3851d4d81a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IPublicAccessPresentationFactory.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Api.Management.ViewModels.PublicAccess; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IPublicAccessPresentationFactory +{ + Attempt CreatePublicAccessResponseModel(PublicAccessEntry entry); + + PublicAccessEntrySlim CreatePublicAccessEntrySlim(PublicAccessRequestModel requestModel, Guid contentKey); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/PublicAccessPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/PublicAccessPresentationFactory.cs new file mode 100644 index 0000000000..4a826ed62e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/PublicAccessPresentationFactory.cs @@ -0,0 +1,95 @@ +using Umbraco.Cms.Api.Management.ViewModels.Member.Item; +using Umbraco.Cms.Api.Management.ViewModels.MemberGroup.Item; +using Umbraco.Cms.Api.Management.ViewModels.PublicAccess; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Factories; + +public class PublicAccessPresentationFactory : IPublicAccessPresentationFactory +{ + private readonly IEntityService _entityService; + private readonly IMemberService _memberService; + private readonly IUmbracoMapper _mapper; + private readonly IMemberRoleManager _memberRoleManager; + + public PublicAccessPresentationFactory( + IEntityService entityService, + IMemberService memberService, + IUmbracoMapper mapper, + IMemberRoleManager memberRoleManager) + { + _entityService = entityService; + _memberService = memberService; + _mapper = mapper; + _memberRoleManager = memberRoleManager; + } + + public Attempt CreatePublicAccessResponseModel(PublicAccessEntry entry) + { + Attempt loginNodeKeyAttempt = _entityService.GetKey(entry.LoginNodeId, UmbracoObjectTypes.Document); + Attempt noAccessNodeKeyAttempt = _entityService.GetKey(entry.NoAccessNodeId, UmbracoObjectTypes.Document); + + if (loginNodeKeyAttempt.Success is false) + { + return Attempt.FailWithStatus(PublicAccessOperationStatus.LoginNodeNotFound, null); + } + + if (noAccessNodeKeyAttempt.Success is false) + { + return Attempt.FailWithStatus(PublicAccessOperationStatus.ErrorNodeNotFound, null); + } + + // 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(); + + MemberItemResponseModel[] members = usernames + .Select(username => _memberService.GetByUsername(username)) + .Select(_mapper.Map) + .WhereNotNull() + .ToArray(); + + var allGroups = _memberRoleManager.Roles.Where(x => x.Name != null).ToDictionary(x => x.Name!); + IEnumerable identityRoles = 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) + .WhereNotNull(); + + IEnumerable groupsEntities = _entityService.GetAll(UmbracoObjectTypes.MemberGroup, identityRoles.Select(x => Convert.ToInt32(x.Id)).ToArray()); + MemberGroupItemResponseModel[] memberGroups = groupsEntities.Select(x => _mapper.Map(x)!).ToArray(); + + var responseModel = new PublicAccessResponseModel + { + Members = members, + Groups = memberGroups, + LoginPageId = loginNodeKeyAttempt.Result, + ErrorPageId = noAccessNodeKeyAttempt.Result, + }; + + return Attempt.SucceedWithStatus(PublicAccessOperationStatus.Success, responseModel); + } + + public PublicAccessEntrySlim CreatePublicAccessEntrySlim(PublicAccessRequestModel requestModel, Guid contentKey) => + new() + { + ContentId = contentKey, + MemberGroupNames = requestModel.MemberGroupNames, + MemberUserNames = requestModel.MemberUserNames, + ErrorPageId = requestModel.ErrorPageId, + LoginPageId = requestModel.LoginPageId, + }; +} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Items/ItemTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Items/ItemTypeMapDefinition.cs index 24f1ca70ff..449e6e1c36 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Items/ItemTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Items/ItemTypeMapDefinition.cs @@ -27,7 +27,7 @@ public class ItemTypeMapDefinition : IMapDefinition mapper.Define((_, _) => new DictionaryItemItemResponseModel(), Map); mapper.Define((_, _) => new DocumentTypeItemResponseModel(), Map); mapper.Define((_, _) => new MediaTypeItemResponseModel(), Map); - mapper.Define((_, _) => new MemberGroupItemReponseModel(), Map); + mapper.Define((_, _) => new MemberGroupItemResponseModel(), Map); mapper.Define((_, _) => new TemplateItemResponseModel { Alias = string.Empty }, Map); mapper.Define((_, _) => new MemberTypeItemResponseModel(), Map); mapper.Define((_, _) => new RelationTypeItemResponseModel(), Map); @@ -77,7 +77,7 @@ public class ItemTypeMapDefinition : IMapDefinition } // Umbraco.Code.MapAll - private static void Map(IEntitySlim source, MemberGroupItemReponseModel target, MapperContext context) + private static void Map(IEntitySlim source, MemberGroupItemResponseModel target, MapperContext context) { target.Name = source.Name ?? string.Empty; target.Id = source.Key; diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 5182d74932..3d82fe5565 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -2885,6 +2885,179 @@ ] } }, + "/umbraco/management/api/v1/document/{id}/public-access": { + "post": { + "tags": [ + "Document" + ], + "operationId": "PostDocumentByIdPublicAccess", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicAccessRequestModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PublicAccessRequestModel" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/PublicAccessRequestModel" + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + }, + "delete": { + "tags": [ + "Document" + ], + "operationId": "DeleteDocumentByIdPublicAccess", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Not Found" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + }, + "get": { + "tags": [ + "Document" + ], + "operationId": "GetDocumentByIdPublicAccess", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + }, + "put": { + "tags": [ + "Document" + ], + "operationId": "PutDocumentByIdPublicAccess", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicAccessRequestModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PublicAccessRequestModel" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/PublicAccessRequestModel" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/document/item": { "get": { "tags": [ @@ -5817,7 +5990,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MemberGroupItemReponseModel" + "$ref": "#/components/schemas/MemberGroupItemResponseModel" } } }, @@ -5825,7 +5998,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MemberGroupItemReponseModel" + "$ref": "#/components/schemas/MemberGroupItemResponseModel" } } }, @@ -5833,7 +6006,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/MemberGroupItemReponseModel" + "$ref": "#/components/schemas/MemberGroupItemResponseModel" } } } @@ -15074,7 +15247,7 @@ }, "additionalProperties": false }, - "MemberGroupItemReponseModel": { + "MemberGroupItemResponseModel": { "type": "object", "properties": { "name": { @@ -16214,6 +16387,32 @@ }, "additionalProperties": false }, + "PublicAccessRequestModel": { + "type": "object", + "properties": { + "loginPageId": { + "type": "string", + "format": "uuid" + }, + "errorPageId": { + "type": "string", + "format": "uuid" + }, + "memberUserNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "memberGroupNames": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "PublishedStateModel": { "enum": [ "Published", diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/Item/MemberGroupItemReponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/Item/MemberGroupItemResponseModel.cs similarity index 65% rename from src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/Item/MemberGroupItemReponseModel.cs rename to src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/Item/MemberGroupItemResponseModel.cs index d1db267f89..118baf082b 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/Item/MemberGroupItemReponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/MemberGroup/Item/MemberGroupItemResponseModel.cs @@ -2,6 +2,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.MemberGroup.Item; -public class MemberGroupItemReponseModel : ItemResponseModelBase +public class MemberGroupItemResponseModel : ItemResponseModelBase { } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessBaseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessBaseModel.cs new file mode 100644 index 0000000000..e329879c68 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessBaseModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.PublicAccess; + +public class PublicAccessBaseModel +{ + public Guid LoginPageId { get; set; } + + public Guid ErrorPageId { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessRequestModel.cs new file mode 100644 index 0000000000..cf84f4f68c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessRequestModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.PublicAccess; + +public class PublicAccessRequestModel : PublicAccessBaseModel +{ + public string[] MemberUserNames { get; set; } = Array.Empty(); + + public string[] MemberGroupNames { get; set; } = Array.Empty(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessResponseModel.cs new file mode 100644 index 0000000000..7401b01e8c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/PublicAccess/PublicAccessResponseModel.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Api.Management.ViewModels.Member.Item; +using Umbraco.Cms.Api.Management.ViewModels.MemberGroup.Item; + +namespace Umbraco.Cms.Api.Management.ViewModels.PublicAccess; + +public class PublicAccessResponseModel : PublicAccessBaseModel +{ + public MemberItemResponseModel[] Members { get; set; } = Array.Empty(); + + public MemberGroupItemResponseModel[] Groups { get; set; } = Array.Empty(); +} diff --git a/src/Umbraco.Core/Models/PublicAccessEntrySlim.cs b/src/Umbraco.Core/Models/PublicAccessEntrySlim.cs new file mode 100644 index 0000000000..3ff92260db --- /dev/null +++ b/src/Umbraco.Core/Models/PublicAccessEntrySlim.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models; + +public class PublicAccessEntrySlim +{ + public Guid ContentId { get; set; } + + public string[] MemberUserNames { get; set; } = Array.Empty(); + + public string[] MemberGroupNames { get; set; } = Array.Empty(); + + public Guid LoginPageId { get; set; } + + public Guid ErrorPageId { get; set; } +} diff --git a/src/Umbraco.Core/Models/PublicAccessNodesValidationResult.cs b/src/Umbraco.Core/Models/PublicAccessNodesValidationResult.cs new file mode 100644 index 0000000000..68166b90bd --- /dev/null +++ b/src/Umbraco.Core/Models/PublicAccessNodesValidationResult.cs @@ -0,0 +1,11 @@ +namespace Umbraco.Cms.Core.Models; + +public class PublicAccessNodesValidationResult +{ + public IContent? ProtectedNode { get; set; } + + public IContent? LoginNode { get; set; } + + public IContent? ErrorNode { get; set; } + +} diff --git a/src/Umbraco.Core/Services/IPublicAccessService.cs b/src/Umbraco.Core/Services/IPublicAccessService.cs index fb4f080e03..fc919baf37 100644 --- a/src/Umbraco.Core/Services/IPublicAccessService.cs +++ b/src/Umbraco.Core/Services/IPublicAccessService.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -61,9 +62,34 @@ public interface IPublicAccessService : IService /// Attempt Save(PublicAccessEntry entry); + /// + /// Saves the entry asynchronously and returns a status result whether the operation succeeded or not + /// + /// + Task> CreateAsync(PublicAccessEntrySlim entry); + + /// + /// Updates the entry asynchronously and returns a status result whether the operation succeeded or not + /// + /// + Task> UpdateAsync(PublicAccessEntrySlim entry); + /// /// Deletes the entry and all associated rules /// /// Attempt Delete(PublicAccessEntry entry); + + /// + /// Gets the entry defined for the content item based on a content key + /// + /// + /// Returns null if no entry is found + Task> GetEntryByContentKeyAsync(Guid key); + + /// + /// Deletes the entry and all associated rules for a given key. + /// + /// + Task> DeleteAsync(Guid key); } diff --git a/src/Umbraco.Core/Services/OperationStatus/PublicAccessOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/PublicAccessOperationStatus.cs new file mode 100644 index 0000000000..3ec29d1feb --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/PublicAccessOperationStatus.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum PublicAccessOperationStatus +{ + Success, + ContentNotFound, + LoginNodeNotFound, + ErrorNodeNotFound, + NoAllowedEntities, + AmbiguousRule, + CancelledByNotification, +} diff --git a/src/Umbraco.Core/Services/PublicAccessService.cs b/src/Umbraco.Core/Services/PublicAccessService.cs index 6f3de02c55..06b6a0fa61 100644 --- a/src/Umbraco.Core/Services/PublicAccessService.cs +++ b/src/Umbraco.Core/Services/PublicAccessService.cs @@ -2,9 +2,11 @@ using System.Globalization; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services; @@ -12,14 +14,22 @@ namespace Umbraco.Cms.Core.Services; internal class PublicAccessService : RepositoryService, IPublicAccessService { private readonly IPublicAccessRepository _publicAccessRepository; + private readonly IEntityService _entityService; + private readonly IContentService _contentService; public PublicAccessService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, - IPublicAccessRepository publicAccessRepository) - : base(provider, loggerFactory, eventMessagesFactory) => + IPublicAccessRepository publicAccessRepository, + IEntityService entityService, + IContentService contentService) + : base(provider, loggerFactory, eventMessagesFactory) + { _publicAccessRepository = publicAccessRepository; + _entityService = entityService; + _contentService = contentService; + } /// /// Gets all defined entries and associated rules @@ -220,6 +230,112 @@ internal class PublicAccessService : RepositoryService, IPublicAccessService return OperationResult.Attempt.Succeed(evtMsgs); } + public async Task> CreateAsync(PublicAccessEntrySlim entry) + { + Attempt validationAttempt = ValidatePublicAccessEntrySlim(entry); + if (validationAttempt.Success is false) + { + return Attempt.FailWithStatus(validationAttempt.Status, null); + } + + IEnumerable publicAccessRules = + entry.MemberUserNames.Any() ? // We only need to check either member usernames or member group names, not both, as we have a check at the top of this method + CreateAccessRuleList(entry.MemberUserNames, Constants.Conventions.PublicAccess.MemberUsernameRuleType) : + CreateAccessRuleList(entry.MemberGroupNames, Constants.Conventions.PublicAccess.MemberRoleRuleType); + + var publicAccessEntry = new PublicAccessEntry(validationAttempt.Result.ProtectedNode!, validationAttempt.Result.LoginNode!, validationAttempt.Result.ErrorNode!, publicAccessRules); + + Attempt attempt = await SaveAsync(publicAccessEntry); + return attempt.Success ? Attempt.SucceedWithStatus(PublicAccessOperationStatus.Success, attempt.Result!) + : Attempt.FailWithStatus(attempt.Status, null); + } + + private async Task> SaveAsync(PublicAccessEntry entry) + { + EventMessages eventMessages = EventMessagesFactory.Get(); + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + var savingNotification = new PublicAccessEntrySavingNotification(entry, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(PublicAccessOperationStatus.CancelledByNotification, null); + } + + _publicAccessRepository.Save(entry); + scope.Complete(); + + scope.Notifications.Publish( + new PublicAccessEntrySavedNotification(entry, eventMessages).WithStateFrom(savingNotification)); + } + + return Attempt.SucceedWithStatus(PublicAccessOperationStatus.Success, entry); + } + + private Attempt ValidatePublicAccessEntrySlim(PublicAccessEntrySlim entry) + { + var result = new PublicAccessNodesValidationResult(); + + if (entry.MemberUserNames.Any() is false && entry.MemberGroupNames.Any() is false) + { + return Attempt.FailWithStatus(PublicAccessOperationStatus.NoAllowedEntities, result); + } + + if(entry.MemberUserNames.Any() && entry.MemberGroupNames.Any()) + { + return Attempt.FailWithStatus(PublicAccessOperationStatus.AmbiguousRule, result); + } + + result.ProtectedNode = _contentService.GetById(entry.ContentId); + + if (result.ProtectedNode is null) + { + return Attempt.FailWithStatus(PublicAccessOperationStatus.ContentNotFound, result); + } + + result.LoginNode = _contentService.GetById(entry.LoginPageId); + + if (result.LoginNode is null) + { + return Attempt.FailWithStatus(PublicAccessOperationStatus.LoginNodeNotFound, result); + } + + result.ErrorNode = _contentService.GetById(entry.ErrorPageId); + + if (result.ErrorNode is null) + { + return Attempt.FailWithStatus(PublicAccessOperationStatus.ErrorNodeNotFound, result); + } + + return Attempt.SucceedWithStatus(PublicAccessOperationStatus.Success, result); + } + + public async Task> UpdateAsync(PublicAccessEntrySlim entry) + { + Attempt validationAttempt = ValidatePublicAccessEntrySlim(entry); + + if (validationAttempt.Success is false) + { + return Attempt.FailWithStatus(validationAttempt.Status, null); + } + + Attempt currentPublicAccessEntryAttempt = await GetEntryByContentKeyAsync(entry.ContentId); + + if (currentPublicAccessEntryAttempt.Success is false) + { + return currentPublicAccessEntryAttempt; + } + + PublicAccessEntry mappedEntry = MapToUpdatedEntry(entry, currentPublicAccessEntryAttempt.Result!); + + Attempt attempt = await SaveAsync(mappedEntry); + + return attempt.Success + ? Attempt.SucceedWithStatus(PublicAccessOperationStatus.Success, mappedEntry) + : Attempt.FailWithStatus(PublicAccessOperationStatus.CancelledByNotification, null); + } + /// /// Deletes the entry and all associated rules /// @@ -246,4 +362,100 @@ internal class PublicAccessService : RepositoryService, IPublicAccessService return OperationResult.Attempt.Succeed(evtMsgs); } + + public Task> GetEntryByContentKeyAsync(Guid key) + { + IEntitySlim? entity = _entityService.Get(key, UmbracoObjectTypes.Document); + if (entity is null) + { + return Task.FromResult(Attempt.FailWithStatus(PublicAccessOperationStatus.ContentNotFound, null)); + } + + PublicAccessEntry? entry = GetEntryForContent(entity.Path.EnsureEndsWith("," + entity.Id)); + + if (entry is null) + { + return Task.FromResult(Attempt.SucceedWithStatus(PublicAccessOperationStatus.Success, null)); + } + + return Task.FromResult(Attempt.SucceedWithStatus(PublicAccessOperationStatus.Success, entry)); + } + + public async Task> DeleteAsync(Guid key) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + Attempt attempt = await GetEntryByContentKeyAsync(key); + + if (attempt.Success is false) + { + return Attempt.Fail(attempt.Status); + } + + EventMessages evtMsgs = EventMessagesFactory.Get(); + + + var deletingNotification = new PublicAccessEntryDeletingNotification(attempt.Result!, evtMsgs); + if (scope.Notifications.PublishCancelable(deletingNotification)) + { + scope.Complete(); + return Attempt.Fail(PublicAccessOperationStatus.CancelledByNotification); + } + + _publicAccessRepository.Delete(attempt.Result!); + + scope.Complete(); + + scope.Notifications.Publish( + new PublicAccessEntryDeletedNotification(attempt.Result!, evtMsgs).WithStateFrom(deletingNotification)); + } + + return Attempt.Succeed(PublicAccessOperationStatus.Success); + } + + private IEnumerable CreateAccessRuleList(string[] ruleValues, string ruleType) => + ruleValues.Select(ruleValue => new PublicAccessRule + { + RuleValue = ruleValue, + RuleType = ruleType, + }); + + private PublicAccessEntry MapToUpdatedEntry(PublicAccessEntrySlim updatesModel, PublicAccessEntry entryToUpdate) + { + entryToUpdate.LoginNodeId = _entityService.GetId(updatesModel.LoginPageId, UmbracoObjectTypes.Document).Result; + entryToUpdate.NoAccessNodeId = _entityService.GetId(updatesModel.ErrorPageId, UmbracoObjectTypes.Document).Result; + + var isGroupBased = updatesModel.MemberGroupNames.Any(); + var candidateRuleValues = isGroupBased + ? updatesModel.MemberGroupNames + : updatesModel.MemberUserNames; + var newRuleType = isGroupBased + ? Constants.Conventions.PublicAccess.MemberRoleRuleType + : Constants.Conventions.PublicAccess.MemberUsernameRuleType; + + PublicAccessRule[] currentRules = entryToUpdate.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) + { + entryToUpdate.RemoveRule(rule); + } + + if (newRuleValues is not null) + { + foreach (var ruleValue in newRuleValues) + { + entryToUpdate.AddRule(ruleValue, newRuleType); + } + } + + return entryToUpdate; + } }