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 <nge@umbraco.dk>
Co-authored-by: Nikolaj <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Nikolaj Geisle
2023-07-05 14:56:51 +02:00
committed by GitHub
parent ed83b65de3
commit c7a38cdac4
20 changed files with 795 additions and 11 deletions

View File

@@ -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."),
};
}

View File

@@ -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<IActionResult> Create(Guid id, PublicAccessRequestModel publicAccessRequestModel)
{
PublicAccessEntrySlim publicAccessEntrySlim = _publicAccessPresentationFactory.CreatePublicAccessEntrySlim(publicAccessRequestModel, id);
Attempt<PublicAccessEntry?, PublicAccessOperationStatus> saveAttempt = await _publicAccessService.CreateAsync(publicAccessEntrySlim);
return saveAttempt.Success
? CreatedAtAction<GetPublicAccessDocumentController>(controller => nameof(controller.GetPublicAccess), saveAttempt.Result!.Key)
: PublicAccessOperationStatusResult(saveAttempt.Status);
}
}

View File

@@ -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<IActionResult> Delete(Guid id)
{
await _publicAccessService.DeleteAsync(id);
return Ok();
}
}

View File

@@ -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<IActionResult> GetPublicAccess(Guid id)
{
Attempt<PublicAccessEntry?, PublicAccessOperationStatus> accessAttempt =
await _publicAccessService.GetEntryByContentKeyAsync(id);
if (accessAttempt.Success is false)
{
return PublicAccessOperationStatusResult(accessAttempt.Status);
}
Attempt<PublicAccessResponseModel?, PublicAccessOperationStatus> responseModelAttempt =
_publicAccessPresentationFactory.CreatePublicAccessResponseModel(accessAttempt.Result!);
if (responseModelAttempt.Success is false)
{
return PublicAccessOperationStatusResult(responseModelAttempt.Status);
}
return Ok(responseModelAttempt.Result);
}
}

View File

@@ -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<IActionResult> Update(Guid id, PublicAccessRequestModel requestModel)
{
PublicAccessEntrySlim publicAccessEntrySlim = _publicAccessPresentationFactory.CreatePublicAccessEntrySlim(requestModel, id);
Attempt<PublicAccessEntry?, PublicAccessOperationStatus> updateAttempt = await _publicAccessService.UpdateAsync(publicAccessEntrySlim);
return updateAttempt.Success
? Ok()
: PublicAccessOperationStatusResult(updateAttempt.Status);
}
}

View File

@@ -24,11 +24,11 @@ public class ItemMemberGroupItemController : MemberGroupItemControllerBase
[HttpGet("item")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IEnumerable<MemberGroupItemReponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(IEnumerable<MemberGroupItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> Item([FromQuery(Name = "id")] HashSet<Guid> ids)
{
IEnumerable<IEntitySlim> memberGroups = _entityService.GetAll(UmbracoObjectTypes.MemberGroup, ids.ToArray());
List<MemberGroupItemReponseModel> responseModel = _mapper.MapEnumerable<IEntitySlim, MemberGroupItemReponseModel>(memberGroups);
List<MemberGroupItemResponseModel> responseModel = _mapper.MapEnumerable<IEntitySlim, MemberGroupItemResponseModel>(memberGroups);
return Ok(responseModel);
}
}

View File

@@ -14,6 +14,7 @@ internal static class DocumentBuilderExtensions
builder.Services.AddTransient<IDocumentNotificationPresentationFactory, DocumentNotificationPresentationFactory>();
builder.Services.AddTransient<IContentUrlFactory, ContentUrlFactory>();
builder.Services.AddTransient<IDocumentEditingPresentationFactory, DocumentEditingPresentationFactory>();
builder.Services.AddTransient<IPublicAccessPresentationFactory, PublicAccessPresentationFactory>();
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>()
.Add<DocumentMapDefinition>()

View File

@@ -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<PublicAccessResponseModel?, PublicAccessOperationStatus> CreatePublicAccessResponseModel(PublicAccessEntry entry);
PublicAccessEntrySlim CreatePublicAccessEntrySlim(PublicAccessRequestModel requestModel, Guid contentKey);
}

View File

@@ -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<PublicAccessResponseModel?, PublicAccessOperationStatus> CreatePublicAccessResponseModel(PublicAccessEntry entry)
{
Attempt<Guid> loginNodeKeyAttempt = _entityService.GetKey(entry.LoginNodeId, UmbracoObjectTypes.Document);
Attempt<Guid> noAccessNodeKeyAttempt = _entityService.GetKey(entry.NoAccessNodeId, UmbracoObjectTypes.Document);
if (loginNodeKeyAttempt.Success is false)
{
return Attempt.FailWithStatus<PublicAccessResponseModel?, PublicAccessOperationStatus>(PublicAccessOperationStatus.LoginNodeNotFound, null);
}
if (noAccessNodeKeyAttempt.Success is false)
{
return Attempt.FailWithStatus<PublicAccessResponseModel?, PublicAccessOperationStatus>(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<MemberItemResponseModel>)
.WhereNotNull()
.ToArray();
var allGroups = _memberRoleManager.Roles.Where(x => x.Name != null).ToDictionary(x => x.Name!);
IEnumerable<UmbracoIdentityRole> 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<IEntitySlim> groupsEntities = _entityService.GetAll(UmbracoObjectTypes.MemberGroup, identityRoles.Select(x => Convert.ToInt32(x.Id)).ToArray());
MemberGroupItemResponseModel[] memberGroups = groupsEntities.Select(x => _mapper.Map<MemberGroupItemResponseModel>(x)!).ToArray();
var responseModel = new PublicAccessResponseModel
{
Members = members,
Groups = memberGroups,
LoginPageId = loginNodeKeyAttempt.Result,
ErrorPageId = noAccessNodeKeyAttempt.Result,
};
return Attempt.SucceedWithStatus<PublicAccessResponseModel?, PublicAccessOperationStatus>(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,
};
}

View File

@@ -27,7 +27,7 @@ public class ItemTypeMapDefinition : IMapDefinition
mapper.Define<IDictionaryItem, DictionaryItemItemResponseModel>((_, _) => new DictionaryItemItemResponseModel(), Map);
mapper.Define<IContentType, DocumentTypeItemResponseModel>((_, _) => new DocumentTypeItemResponseModel(), Map);
mapper.Define<IMediaType, MediaTypeItemResponseModel>((_, _) => new MediaTypeItemResponseModel(), Map);
mapper.Define<IEntitySlim, MemberGroupItemReponseModel>((_, _) => new MemberGroupItemReponseModel(), Map);
mapper.Define<IEntitySlim, MemberGroupItemResponseModel>((_, _) => new MemberGroupItemResponseModel(), Map);
mapper.Define<ITemplate, TemplateItemResponseModel>((_, _) => new TemplateItemResponseModel { Alias = string.Empty }, Map);
mapper.Define<IMemberType, MemberTypeItemResponseModel>((_, _) => new MemberTypeItemResponseModel(), Map);
mapper.Define<IRelationType, RelationTypeItemResponseModel>((_, _) => 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;

View File

@@ -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",

View File

@@ -2,6 +2,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.MemberGroup.Item;
public class MemberGroupItemReponseModel : ItemResponseModelBase
public class MemberGroupItemResponseModel : ItemResponseModelBase
{
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Api.Management.ViewModels.PublicAccess;
public class PublicAccessBaseModel
{
public Guid LoginPageId { get; set; }
public Guid ErrorPageId { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Api.Management.ViewModels.PublicAccess;
public class PublicAccessRequestModel : PublicAccessBaseModel
{
public string[] MemberUserNames { get; set; } = Array.Empty<string>();
public string[] MemberGroupNames { get; set; } = Array.Empty<string>();
}

View File

@@ -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<MemberItemResponseModel>();
public MemberGroupItemResponseModel[] Groups { get; set; } = Array.Empty<MemberGroupItemResponseModel>();
}

View File

@@ -0,0 +1,14 @@
namespace Umbraco.Cms.Core.Models;
public class PublicAccessEntrySlim
{
public Guid ContentId { get; set; }
public string[] MemberUserNames { get; set; } = Array.Empty<string>();
public string[] MemberGroupNames { get; set; } = Array.Empty<string>();
public Guid LoginPageId { get; set; }
public Guid ErrorPageId { get; set; }
}

View File

@@ -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; }
}

View File

@@ -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
/// <param name="entry"></param>
Attempt<OperationResult?> Save(PublicAccessEntry entry);
/// <summary>
/// Saves the entry asynchronously and returns a status result whether the operation succeeded or not
/// </summary>
/// <param name="entry"></param>
Task<Attempt<PublicAccessEntry?, PublicAccessOperationStatus>> CreateAsync(PublicAccessEntrySlim entry);
/// <summary>
/// Updates the entry asynchronously and returns a status result whether the operation succeeded or not
/// </summary>
/// <param name="entry"></param>
Task<Attempt<PublicAccessEntry?, PublicAccessOperationStatus>> UpdateAsync(PublicAccessEntrySlim entry);
/// <summary>
/// Deletes the entry and all associated rules
/// </summary>
/// <param name="entry"></param>
Attempt<OperationResult?> Delete(PublicAccessEntry entry);
/// <summary>
/// Gets the entry defined for the content item based on a content key
/// </summary>
/// <param name="key"></param>
/// <returns>Returns null if no entry is found</returns>
Task<Attempt<PublicAccessEntry?, PublicAccessOperationStatus>> GetEntryByContentKeyAsync(Guid key);
/// <summary>
/// Deletes the entry and all associated rules for a given key.
/// </summary>
/// <param name="key"></param>
Task<Attempt<PublicAccessOperationStatus>> DeleteAsync(Guid key);
}

View File

@@ -0,0 +1,12 @@
namespace Umbraco.Cms.Core.Services.OperationStatus;
public enum PublicAccessOperationStatus
{
Success,
ContentNotFound,
LoginNodeNotFound,
ErrorNodeNotFound,
NoAllowedEntities,
AmbiguousRule,
CancelledByNotification,
}

View File

@@ -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;
}
/// <summary>
/// Gets all defined entries and associated rules
@@ -220,6 +230,112 @@ internal class PublicAccessService : RepositoryService, IPublicAccessService
return OperationResult.Attempt.Succeed(evtMsgs);
}
public async Task<Attempt<PublicAccessEntry?, PublicAccessOperationStatus>> CreateAsync(PublicAccessEntrySlim entry)
{
Attempt<PublicAccessNodesValidationResult, PublicAccessOperationStatus> validationAttempt = ValidatePublicAccessEntrySlim(entry);
if (validationAttempt.Success is false)
{
return Attempt.FailWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(validationAttempt.Status, null);
}
IEnumerable<PublicAccessRule> 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<PublicAccessEntry?, PublicAccessOperationStatus> attempt = await SaveAsync(publicAccessEntry);
return attempt.Success ? Attempt.SucceedWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(PublicAccessOperationStatus.Success, attempt.Result!)
: Attempt.FailWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(attempt.Status, null);
}
private async Task<Attempt<PublicAccessEntry?, PublicAccessOperationStatus>> 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<PublicAccessEntry?, PublicAccessOperationStatus>(PublicAccessOperationStatus.CancelledByNotification, null);
}
_publicAccessRepository.Save(entry);
scope.Complete();
scope.Notifications.Publish(
new PublicAccessEntrySavedNotification(entry, eventMessages).WithStateFrom(savingNotification));
}
return Attempt.SucceedWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(PublicAccessOperationStatus.Success, entry);
}
private Attempt<PublicAccessNodesValidationResult, PublicAccessOperationStatus> 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<Attempt<PublicAccessEntry?, PublicAccessOperationStatus>> UpdateAsync(PublicAccessEntrySlim entry)
{
Attempt<PublicAccessNodesValidationResult, PublicAccessOperationStatus> validationAttempt = ValidatePublicAccessEntrySlim(entry);
if (validationAttempt.Success is false)
{
return Attempt.FailWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(validationAttempt.Status, null);
}
Attempt<PublicAccessEntry?, PublicAccessOperationStatus> currentPublicAccessEntryAttempt = await GetEntryByContentKeyAsync(entry.ContentId);
if (currentPublicAccessEntryAttempt.Success is false)
{
return currentPublicAccessEntryAttempt;
}
PublicAccessEntry mappedEntry = MapToUpdatedEntry(entry, currentPublicAccessEntryAttempt.Result!);
Attempt<PublicAccessEntry?, PublicAccessOperationStatus> attempt = await SaveAsync(mappedEntry);
return attempt.Success
? Attempt.SucceedWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(PublicAccessOperationStatus.Success, mappedEntry)
: Attempt.FailWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(PublicAccessOperationStatus.CancelledByNotification, null);
}
/// <summary>
/// Deletes the entry and all associated rules
/// </summary>
@@ -246,4 +362,100 @@ internal class PublicAccessService : RepositoryService, IPublicAccessService
return OperationResult.Attempt.Succeed(evtMsgs);
}
public Task<Attempt<PublicAccessEntry?, PublicAccessOperationStatus>> GetEntryByContentKeyAsync(Guid key)
{
IEntitySlim? entity = _entityService.Get(key, UmbracoObjectTypes.Document);
if (entity is null)
{
return Task.FromResult(Attempt.FailWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(PublicAccessOperationStatus.ContentNotFound, null));
}
PublicAccessEntry? entry = GetEntryForContent(entity.Path.EnsureEndsWith("," + entity.Id));
if (entry is null)
{
return Task.FromResult(Attempt.SucceedWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(PublicAccessOperationStatus.Success, null));
}
return Task.FromResult(Attempt.SucceedWithStatus<PublicAccessEntry?, PublicAccessOperationStatus>(PublicAccessOperationStatus.Success, entry));
}
public async Task<Attempt<PublicAccessOperationStatus>> DeleteAsync(Guid key)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
Attempt<PublicAccessEntry?, PublicAccessOperationStatus> 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<PublicAccessRule> 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<PublicAccessRule> obsoleteRules = currentRules.Where(rule =>
rule.RuleType != newRuleType
|| candidateRuleValues?.Contains(rule.RuleValue) == false);
IEnumerable<string>? 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;
}
}