diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs new file mode 100644 index 0000000000..84dbf0a001 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Document; +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.DocumentVersion; + +[ApiVersion("1.0")] +public class AllDocumentVersionController : DocumentVersionControllerBase +{ + private readonly IContentVersionService _contentVersionService; + private readonly IDocumentVersionPresentationFactory _documentVersionPresentationFactory; + + public AllDocumentVersionController( + IContentVersionService contentVersionService, + IDocumentVersionPresentationFactory documentVersionPresentationFactory) + { + _contentVersionService = contentVersionService; + _documentVersionPresentationFactory = documentVersionPresentationFactory; + } + + [MapToApiVersion("1.0")] + [HttpGet] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task All([Required] Guid documentId, string? culture, int skip = 0, int take = 100) + { + Attempt?, ContentVersionOperationStatus> attempt = + await _contentVersionService.GetPagedContentVersionsAsync(documentId, culture, skip, take); + + var pagedViewModel = new PagedViewModel + { + Total = attempt.Result!.Total, + Items = await _documentVersionPresentationFactory.CreateMultipleAsync(attempt.Result!.Items), + }; + + return attempt.Success + ? Ok(pagedViewModel) + : MapFailure(attempt.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/ByKeyDocumentVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/ByKeyDocumentVersionController.cs new file mode 100644 index 0000000000..ad16748fa7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/ByKeyDocumentVersionController.cs @@ -0,0 +1,41 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion; + +[ApiVersion("1.0")] +public class ByKeyDocumentVersionController : DocumentVersionControllerBase +{ + private readonly IContentVersionService _contentVersionService; + private readonly IUmbracoMapper _umbracoMapper; + + public ByKeyDocumentVersionController( + IContentVersionService contentVersionService, + IUmbracoMapper umbracoMapper) + { + _contentVersionService = contentVersionService; + _umbracoMapper = umbracoMapper; + } + + [MapToApiVersion("1.0")] + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(DocumentVersionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task ByKey(Guid id) + { + Attempt attempt = + await _contentVersionService.GetAsync(id); + + return attempt.Success + ? Ok(_umbracoMapper.Map(attempt.Result)) + : MapFailure(attempt.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs new file mode 100644 index 0000000000..1e31f6a078 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion; + +[VersionedApiBackOfficeRoute($"{Constants.UdiEntityType.Document}-version")] +[ApiExplorerSettings(GroupName = $"{nameof(Constants.UdiEntityType.Document)} Version")] +[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)] +public abstract class DocumentVersionControllerBase : ManagementApiControllerBase +{ + protected IActionResult MapFailure(ContentVersionOperationStatus status) + => OperationStatusResult(status, problemDetailsBuilder => status switch + { + ContentVersionOperationStatus.NotFound => NotFound(problemDetailsBuilder + .WithTitle("The requested version could not be found") + .Build()), + ContentVersionOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder + .WithTitle("The requested document could not be found") + .Build()), + ContentVersionOperationStatus.InvalidSkipTake => SkipTakeToPagingProblem(), + ContentVersionOperationStatus.RollBackFailed => BadRequest(problemDetailsBuilder + .WithTitle("Rollback failed") + .WithDetail("An unspecified error occurred while rolling back the requested version. Please check the logs for additional information.")), + ContentVersionOperationStatus.RollBackCanceled => BadRequest(problemDetailsBuilder + .WithTitle("Request cancelled by notification") + .WithDetail("The request to roll back was cancelled by a notification handler.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + .WithTitle("Unknown content version operation status.") + .Build()), + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/RollbackDocumentVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/RollbackDocumentVersionController.cs new file mode 100644 index 0000000000..bc94ef6719 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/RollbackDocumentVersionController.cs @@ -0,0 +1,39 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion; + +[ApiVersion("1.0")] +public class RollbackDocumentVersionController : DocumentVersionControllerBase +{ + private readonly IContentVersionService _contentVersionService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public RollbackDocumentVersionController( + IContentVersionService contentVersionService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _contentVersionService = contentVersionService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [MapToApiVersion("1.0")] + [HttpPost("{id:guid}/rollback")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Rollback(Guid id, string? culture) + { + Attempt attempt = + await _contentVersionService.RollBackAsync(id, culture, CurrentUserKey(_backOfficeSecurityAccessor)); + + return attempt.Success + ? Ok() + : MapFailure(attempt.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/UpdatePreventCleanupDocumentVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/UpdatePreventCleanupDocumentVersionController.cs new file mode 100644 index 0000000000..5af47223c9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/UpdatePreventCleanupDocumentVersionController.cs @@ -0,0 +1,39 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion; + +[ApiVersion("1.0")] +public class UpdatePreventCleanupDocumentVersionController : DocumentVersionControllerBase +{ + private readonly IContentVersionService _contentVersionService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public UpdatePreventCleanupDocumentVersionController( + IContentVersionService contentVersionService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _contentVersionService = contentVersionService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [MapToApiVersion("1.0")] + [HttpPut("{id:guid}/prevent-cleanup")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Set(Guid id, bool preventCleanup) + { + Attempt attempt = + await _contentVersionService.SetPreventCleanupAsync(id, preventCleanup, CurrentUserKey(_backOfficeSecurityAccessor)); + + return attempt.Success + ? Ok() + : MapFailure(attempt.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs index 9a9048ccb9..9bd369d70e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs @@ -62,4 +62,13 @@ public abstract class ManagementApiControllerBase : Controller, IUmbracoFeature protected static IActionResult OperationStatusResult(TEnum status, Func result) where TEnum : Enum => result(new ProblemDetailsBuilder().WithOperationStatus(status)); + + protected BadRequestObjectResult SkipTakeToPagingProblem() => + BadRequest(new ProblemDetails + { + Title = "Invalid skip/take", + Detail = "Skip must be a multiple of take - i.e. skip = 10, take = 5", + Status = StatusCodes.Status400BadRequest, + Type = "Error", + }); } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs index e2721e5bf2..f3662b683d 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs @@ -16,10 +16,12 @@ internal static class DocumentBuilderExtensions builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder() .Add() - .Add(); + .Add() + .Add(); return builder; } diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs new file mode 100644 index 0000000000..2c3b80fd3c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs @@ -0,0 +1,36 @@ +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Factories; + +internal sealed class DocumentVersionPresentationFactory : IDocumentVersionPresentationFactory +{ + private readonly IEntityService _entityService; + private readonly IUserIdKeyResolver _userIdKeyResolver; + + public DocumentVersionPresentationFactory( + IEntityService entityService, + IUserIdKeyResolver userIdKeyResolver) + { + _entityService = entityService; + _userIdKeyResolver = userIdKeyResolver; + } + + public async Task CreateAsync(ContentVersionMeta contentVersion) => + new( + contentVersion.VersionId.ToGuid(), // this is a magic guid since versions do not have keys in the DB + new ReferenceByIdModel(_entityService.GetKey(contentVersion.ContentId, UmbracoObjectTypes.Document).Result), + new ReferenceByIdModel(_entityService.GetKey(contentVersion.ContentTypeId, UmbracoObjectTypes.DocumentType) + .Result), + new ReferenceByIdModel(await _userIdKeyResolver.GetAsync(contentVersion.UserId)), + new DateTimeOffset(contentVersion.VersionDate, TimeSpan.Zero), // todo align with datetime offset rework + contentVersion.CurrentPublishedVersion, + contentVersion.CurrentDraftVersion, + contentVersion.PreventCleanup); + + public async Task> CreateMultipleAsync(IEnumerable contentVersions) => + await Task.WhenAll(contentVersions.Select(CreateAsync)); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentVersionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentVersionPresentationFactory.cs new file mode 100644 index 0000000000..8ffdf802ac --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentVersionPresentationFactory.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IDocumentVersionPresentationFactory +{ + Task CreateAsync(ContentVersionMeta contentVersion); + + Task> CreateMultipleAsync( + IEnumerable contentVersions); +} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs new file mode 100644 index 0000000000..40359b08c5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs @@ -0,0 +1,40 @@ +using Umbraco.Cms.Api.Management.Mapping.Content; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Mapping.Document; + +public class DocumentVersionMapDefinition : ContentMapDefinition, IMapDefinition +{ + public DocumentVersionMapDefinition(PropertyEditorCollection propertyEditorCollection) + : base(propertyEditorCollection) + { + } + + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define((_, _) => new DocumentVersionResponseModel(), Map); + } + + private void Map(IContent source, DocumentVersionResponseModel target, MapperContext context) + { + target.Id = source.VersionId.ToGuid(); // this is a magic guid since versions do not have Guids in the DB + target.Document = new ReferenceByIdModel(source.Key); + target.DocumentType = context.Map(source.ContentType)!; + target.Values = MapValueViewModels(source.Properties); + target.Variants = MapVariantViewModels( + source, + (culture, _, documentVariantViewModel) => + { + documentVariantViewModel.State = DocumentVariantStateHelper.GetState(source, culture); + documentVariantViewModel.PublishDate = culture == null + ? source.PublishDate + : source.GetPublishDate(culture); + }); + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index cda03adb95..e7704377c5 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -5446,6 +5446,451 @@ ] } }, + "/umbraco/management/api/v1/document-version": { + "get": { + "tags": [ + "Document Version" + ], + "operationId": "GetDocumentVersion", + "parameters": [ + { + "name": "documentId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedDocumentVersionItemResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedDocumentVersionItemResponseModel" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedDocumentVersionItemResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/document-version/{id}": { + "get": { + "tags": [ + "Document Version" + ], + "operationId": "GetDocumentVersionById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVersionResponseModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVersionResponseModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVersionResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/document-version/{id}/prevent-cleanup": { + "put": { + "tags": [ + "Document Version" + ], + "operationId": "PutDocumentVersionByIdPreventCleanup", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "preventCleanup", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/document-version/{id}/rollback": { + "post": { + "tags": [ + "Document Version" + ], + "operationId": "PostDocumentVersionByIdRollback", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/collection/document/{id}": { "get": { "tags": [ @@ -36214,6 +36659,89 @@ ], "type": "string" }, + "DocumentVersionItemResponseModel": { + "required": [ + "document", + "documentType", + "id", + "isCurrentDraftVersion", + "isCurrentPublishedVersion", + "preventCleanup", + "user", + "versionDate" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "document": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "user": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ] + }, + "versionDate": { + "type": "string", + "format": "date-time" + }, + "isCurrentPublishedVersion": { + "type": "boolean" + }, + "isCurrentDraftVersion": { + "type": "boolean" + }, + "preventCleanup": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DocumentVersionResponseModel": { + "required": [ + "documentType" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ContentForDocumentResponseModel" + } + ], + "properties": { + "document": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeReferenceResponseModel" + } + ] + } + }, + "additionalProperties": false + }, "DomainPresentationModel": { "required": [ "domainName", @@ -38891,6 +39419,30 @@ }, "additionalProperties": false }, + "PagedDocumentVersionItemResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVersionItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PagedFileSystemTreeItemPresentationModel": { "required": [ "items", @@ -43030,4 +43582,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionItemResponseModel.cs new file mode 100644 index 0000000000..0c11d97a32 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionItemResponseModel.cs @@ -0,0 +1,41 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class DocumentVersionItemResponseModel +{ + public DocumentVersionItemResponseModel( + Guid id, + ReferenceByIdModel document, + ReferenceByIdModel documentType, + ReferenceByIdModel user, + DateTimeOffset versionDate, + bool isCurrentPublishedVersion, + bool isCurrentDraftVersion, + bool preventCleanup) + { + Id = id; + Document = document; + DocumentType = documentType; + + User = user; + VersionDate = versionDate; + IsCurrentPublishedVersion = isCurrentPublishedVersion; + IsCurrentDraftVersion = isCurrentDraftVersion; + PreventCleanup = preventCleanup; + } + + public Guid Id { get; } + + public ReferenceByIdModel Document { get; } + + public ReferenceByIdModel DocumentType { get; } + + public ReferenceByIdModel User { get; } + + public DateTimeOffset VersionDate { get; } + + public bool IsCurrentPublishedVersion { get; } + + public bool IsCurrentDraftVersion { get; } + + public bool PreventCleanup { get; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionResponseModel.cs new file mode 100644 index 0000000000..9d96cf7eb3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionResponseModel.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; + +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class DocumentVersionResponseModel : ContentResponseModelBase +{ + public ReferenceByIdModel? Document { get; set; } + + public DocumentTypeReferenceResponseModel DocumentType { get; set; } = new(); +} diff --git a/src/Umbraco.Infrastructure/Extensions/GuidExtensions.cs b/src/Umbraco.Core/Extensions/GuidExtensions.cs similarity index 59% rename from src/Umbraco.Infrastructure/Extensions/GuidExtensions.cs rename to src/Umbraco.Core/Extensions/GuidExtensions.cs index d2a189bd14..b7eb302c47 100644 --- a/src/Umbraco.Infrastructure/Extensions/GuidExtensions.cs +++ b/src/Umbraco.Core/Extensions/GuidExtensions.cs @@ -1,22 +1,17 @@ -namespace Umbraco.Cms.Infrastructure.Extensions; +namespace Umbraco.Cms.Core.Extensions; public static class GuidExtensions { - internal static bool IsFakeGuid(this Guid guid) + public static bool IsFakeGuid(this Guid guid) { var bytes = guid.ToByteArray(); // Our fake guid is a 32 bit int, converted to a byte representation, // so we can check if everything but the first 4 bytes are 0, if so, we know it's a fake guid. - if (bytes[4..].All(x => x == 0)) - { - return true; - } - - return false; + return bytes[4..].All(x => x == 0); } - internal static int ToInt(this Guid guid) + public static int ToInt(this Guid guid) { var bytes = guid.ToByteArray(); return BitConverter.ToInt32(bytes, 0); diff --git a/src/Umbraco.Core/Services/ContentVersionService.cs b/src/Umbraco.Core/Services/ContentVersionService.cs index 3d3eed062e..a0b64a764a 100644 --- a/src/Umbraco.Core/Services/ContentVersionService.cs +++ b/src/Umbraco.Core/Services/ContentVersionService.cs @@ -1,9 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Extensions; 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.Cms.Core.Services.Pagination; using Umbraco.Extensions; // ReSharper disable once CheckNamespace @@ -16,9 +22,37 @@ internal class ContentVersionService : IContentVersionService private readonly IDocumentVersionRepository _documentVersionRepository; private readonly IEventMessagesFactory _eventMessagesFactory; private readonly ILanguageRepository _languageRepository; + private readonly IEntityService _entityService; + private readonly IContentService _contentService; + private readonly IUserIdKeyResolver _userIdKeyResolver; private readonly ILogger _logger; private readonly ICoreScopeProvider _scopeProvider; + public ContentVersionService( + ILogger logger, + IDocumentVersionRepository documentVersionRepository, + IContentVersionCleanupPolicy contentVersionCleanupPolicy, + ICoreScopeProvider scopeProvider, + IEventMessagesFactory eventMessagesFactory, + IAuditRepository auditRepository, + ILanguageRepository languageRepository, + IEntityService entityService, + IContentService contentService, + IUserIdKeyResolver userIdKeyResolver) + { + _logger = logger; + _documentVersionRepository = documentVersionRepository; + _contentVersionCleanupPolicy = contentVersionCleanupPolicy; + _scopeProvider = scopeProvider; + _eventMessagesFactory = eventMessagesFactory; + _auditRepository = auditRepository; + _languageRepository = languageRepository; + _entityService = entityService; + _contentService = contentService; + _userIdKeyResolver = userIdKeyResolver; + } + + [Obsolete("Use the non obsolete constructor instead. Scheduled to be removed in v15")] public ContentVersionService( ILogger logger, IDocumentVersionRepository documentVersionRepository, @@ -35,6 +69,9 @@ internal class ContentVersionService : IContentVersionService _eventMessagesFactory = eventMessagesFactory; _auditRepository = auditRepository; _languageRepository = languageRepository; + _entityService = StaticServiceProvider.Instance.GetRequiredService(); + _contentService = StaticServiceProvider.Instance.GetRequiredService(); + _userIdKeyResolver = StaticServiceProvider.Instance.GetRequiredService(); } /// @@ -45,6 +82,7 @@ internal class ContentVersionService : IContentVersionService CleanupDocumentVersions(asAtDate); /// + [Obsolete("Use the async version instead. Scheduled to be removed in v15")] public IEnumerable? GetPagedContentVersions(int contentId, long pageIndex, int pageSize, out long totalRecords, string? culture = null) { if (pageIndex < 0) @@ -57,6 +95,113 @@ internal class ContentVersionService : IContentVersionService throw new ArgumentOutOfRangeException(nameof(pageSize)); } + return HandleGetPagedContentVersions(contentId, pageIndex, pageSize, out totalRecords, culture); + } + + public ContentVersionMeta? Get(int versionId) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(Constants.Locks.ContentTree); + return _documentVersionRepository.Get(versionId); + } + } + + /// + [Obsolete("Use the async version instead. Scheduled to be removed in v15")] + public void SetPreventCleanup(int versionId, bool preventCleanup, int userId = Constants.Security.SuperUserId) + => HandleSetPreventCleanup(versionId, preventCleanup, userId); + + public async Task?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take) + { + IEntitySlim? document = await Task.FromResult(_entityService.Get(contentId, UmbracoObjectTypes.Document)); + if (document is null) + { + return Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.ContentNotFound); + } + + if (PaginationConverter.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize) == false) + { + return Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.InvalidSkipTake); + } + + IEnumerable? versions = + await Task.FromResult(HandleGetPagedContentVersions( + document.Id, + pageNumber, + pageSize, + out var total, + culture)); + + if (versions is null) + { + return Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.NotFound); + } + + return Attempt?, ContentVersionOperationStatus>.Succeed( + ContentVersionOperationStatus.Success, new PagedModel(total, versions)); + } + + public async Task> GetAsync(Guid versionId) + { + IContent? version = await Task.FromResult(_contentService.GetVersion(versionId.ToInt())); + if (version is null) + { + return Attempt.Fail(ContentVersionOperationStatus + .NotFound); + } + + return Attempt.Succeed(ContentVersionOperationStatus.Success, version); + } + + public async Task> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey) + { + ContentVersionMeta? version = Get(versionId.ToInt()); + if (version is null) + { + return Attempt.Fail(ContentVersionOperationStatus.NotFound); + } + + HandleSetPreventCleanup(version.VersionId, preventCleanup, await _userIdKeyResolver.GetAsync(userKey)); + + return Attempt.Succeed(ContentVersionOperationStatus.Success); + } + + public async Task> RollBackAsync(Guid versionId, string? culture, Guid userKey) + { + ContentVersionMeta? version = Get(versionId.ToInt()); + if (version is null) + { + return Attempt.Fail(ContentVersionOperationStatus.NotFound); + } + + OperationResult rollBackResult = _contentService.Rollback( + version.ContentId, + version.VersionId, + culture ?? "*", + await _userIdKeyResolver.GetAsync(userKey)); + + if (rollBackResult.Success) + { + return Attempt.Succeed(ContentVersionOperationStatus.Success); + } + + switch (rollBackResult.Result) + { + case OperationResultType.Failed: + case OperationResultType.FailedCannot: + case OperationResultType.FailedExceptionThrown: + case OperationResultType.NoOperation: + default: + return Attempt.Fail(ContentVersionOperationStatus.RollBackFailed); + case OperationResultType.FailedCancelledByEvent: + return Attempt.Fail(ContentVersionOperationStatus.RollBackCanceled); + } + } + + private IEnumerable? HandleGetPagedContentVersions(int contentId, long pageIndex, + int pageSize, out long totalRecords, string? culture = null) + { using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) { var languageId = _languageRepository.GetIdByIsoCode(culture, true); @@ -65,8 +210,7 @@ internal class ContentVersionService : IContentVersionService } } - /// - public void SetPreventCleanup(int versionId, bool preventCleanup, int userId = Constants.Security.SuperUserId) + private void HandleSetPreventCleanup(int versionId, bool preventCleanup, int userId) { using (ICoreScope scope = _scopeProvider.CreateCoreScope()) { diff --git a/src/Umbraco.Core/Services/IContentVersionService.cs b/src/Umbraco.Core/Services/IContentVersionService.cs index e0d518f52a..211f416604 100644 --- a/src/Umbraco.Core/Services/IContentVersionService.cs +++ b/src/Umbraco.Core/Services/IContentVersionService.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -13,10 +14,19 @@ public interface IContentVersionService /// Gets paginated content versions for given content id paginated. /// /// Thrown when is invalid. + [Obsolete("Use the async version instead. Scheduled to be removed in v15")] IEnumerable? GetPagedContentVersions(int contentId, long pageIndex, int pageSize, out long totalRecords, string? culture = null); /// /// Updates preventCleanup value for given content version. /// + [Obsolete("Use the async version instead. Scheduled to be removed in v15")] void SetPreventCleanup(int versionId, bool preventCleanup, int userId = -1); + + ContentVersionMeta? Get(int versionId); + Task?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take); + Task> GetAsync(Guid versionId); + + Task> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey); + Task> RollBackAsync(Guid versionId, string? culture, Guid userKey); } diff --git a/src/Umbraco.Core/Services/OperationStatus/ContentVersionOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ContentVersionOperationStatus.cs new file mode 100644 index 0000000000..b138d08416 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/ContentVersionOperationStatus.cs @@ -0,0 +1,11 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum ContentVersionOperationStatus +{ + Success, + NotFound, + ContentNotFound, + InvalidSkipTake, + RollBackFailed, + RollBackCanceled +} diff --git a/src/Umbraco.Core/Services/Pagination/PaginationConverter.cs b/src/Umbraco.Core/Services/Pagination/PaginationConverter.cs new file mode 100644 index 0000000000..4e3af351ec --- /dev/null +++ b/src/Umbraco.Core/Services/Pagination/PaginationConverter.cs @@ -0,0 +1,23 @@ +namespace Umbraco.Cms.Core.Services.Pagination; + +internal static class PaginationConverter +{ + internal static bool ConvertSkipTakeToPaging(int skip, int take, out long pageNumber, out int pageSize) + { + if (take < 0) + { + throw new ArgumentException("Must be equal to or greater than zero", nameof(take)); + } + + if (take != 0 && skip % take != 0) + { + pageSize = 0; + pageNumber = 0; + return false; + } + + pageSize = take; + pageNumber = take == 0 ? 0 : skip / take; + return true; + } +} diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs index 18acc14b1d..6b015bda58 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs @@ -2,7 +2,7 @@ using System.ComponentModel; using System.Globalization; using System.Security.Claims; using Microsoft.AspNetCore.Identity; -using Umbraco.Cms.Infrastructure.Extensions; +using Umbraco.Cms.Core.Extensions; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Security; diff --git a/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs b/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs index 9aeb17716b..11baa6229e 100644 --- a/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs +++ b/tests/Umbraco.Tests.UnitTests/AutoFixture/Customizations/UmbracoCustomizations.cs @@ -33,7 +33,8 @@ internal class UmbracoCustomizations : ICustomization .Customize(new ConstructorCustomization(typeof(MemberManager), new GreedyConstructorQuery())) .Customize(new ConstructorCustomization(typeof(DatabaseSchemaCreatorFactory), new GreedyConstructorQuery())) .Customize(new ConstructorCustomization(typeof(InstallHelper), new GreedyConstructorQuery())) - .Customize(new ConstructorCustomization(typeof(DatabaseBuilder), new GreedyConstructorQuery())); + .Customize(new ConstructorCustomization(typeof(DatabaseBuilder), new GreedyConstructorQuery())) + .Customize(new ConstructorCustomization(typeof(ContentVersionService), new GreedyConstructorQuery())); // When requesting an IUserStore ensure we actually uses a IUserLockoutStore fixture.Customize>(cc => diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/IntGuidIntConversionTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/IntGuidIntConversionTests.cs new file mode 100644 index 0000000000..b6b70ffec3 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/IntGuidIntConversionTests.cs @@ -0,0 +1,29 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Extensions; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions; + +[TestFixture] +public class IntGuidIntConversionTests +{ + [TestCase(int.MinValue)] + [TestCase(int.MaxValue)] + [TestCase(int.MinValue / 2)] + [TestCase(int.MinValue / 5)] + [TestCase(int.MinValue / 707)] + [TestCase(int.MinValue / 1313)] + [TestCase(int.MinValue / 17017)] + [TestCase(int.MaxValue / 2)] + [TestCase(int.MaxValue / 5)] + [TestCase(int.MaxValue / 707)] + [TestCase(int.MaxValue / 1313)] + [TestCase(int.MaxValue / 17017)] + public void IntoToGuidToInt_NoChange(int startValue) + { + var intermediateValue = startValue.ToGuid(); + var endValue = intermediateValue.ToInt(); + + Assert.AreEqual(startValue, endValue); + } +}