diff --git a/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/AuditLogControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/AuditLogControllerBase.cs new file mode 100644 index 0000000000..29293c67b7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/AuditLogControllerBase.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Routing; + +namespace Umbraco.Cms.Api.Management.Controllers.AuditLog; + +[ApiController] +[VersionedApiBackOfficeRoute("audit-log")] +[ApiExplorerSettings(GroupName = "Audit Log")] +[ApiVersion("1.0")] +public class AuditLogControllerBase : ManagementApiControllerBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/ByKeyAuditLogController.cs b/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/ByKeyAuditLogController.cs new file mode 100644 index 0000000000..b94139dd8c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/ByKeyAuditLogController.cs @@ -0,0 +1,39 @@ +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.AuditLogs; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Controllers.AuditLog; + +public class ByKeyAuditLogController : AuditLogControllerBase +{ + private readonly IAuditService _auditService; + private readonly IAuditLogViewModelFactory _auditLogViewModelFactory; + + public ByKeyAuditLogController(IAuditService auditService, IAuditLogViewModelFactory auditLogViewModelFactory) + { + _auditService = auditService; + _auditLogViewModelFactory = auditLogViewModelFactory; + } + + [HttpGet("{key:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task ByKey(Guid key, Direction orderDirection = Direction.Descending, DateTime? sinceDate = null, int skip = 0, int take = 100) + { + PagedModel result = await _auditService.GetItemsByKeyAsync(key, skip, take, orderDirection, sinceDate); + IEnumerable mapped = _auditLogViewModelFactory.CreateAuditLogViewModel(result.Items); + var viewModel = new PagedViewModel + { + Total = result.Total, + Items = mapped, + }; + + return Ok(viewModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/ByTypeAuditLogController.cs b/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/ByTypeAuditLogController.cs new file mode 100644 index 0000000000..0fd14ea891 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/ByTypeAuditLogController.cs @@ -0,0 +1,37 @@ +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.AuditLogs; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.AuditLog; + +public class ByTypeAuditLogController : AuditLogControllerBase +{ + private readonly IAuditService _auditService; + private readonly IAuditLogViewModelFactory _auditLogViewModelFactory; + + public ByTypeAuditLogController(IAuditService auditService, IAuditLogViewModelFactory auditLogViewModelFactory) + { + _auditService = auditService; + _auditLogViewModelFactory = auditLogViewModelFactory; + } + + [HttpGet("type/{logType}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task ByType(AuditType logType, DateTime? sinceDate = null, int skip = 0, int take = 100) + { + IAuditItem[] result = _auditService.GetLogs(logType, sinceDate).ToArray(); + IEnumerable mapped = _auditLogViewModelFactory.CreateAuditLogViewModel(result.Skip(skip).Take(take)); + var viewModel = new PagedViewModel + { + Total = result.Length, + Items = mapped, + }; + + return await Task.FromResult(Ok(viewModel)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/CurrentUserAuditLogController.cs b/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/CurrentUserAuditLogController.cs new file mode 100644 index 0000000000..d34192756a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/AuditLog/CurrentUserAuditLogController.cs @@ -0,0 +1,69 @@ +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.AuditLogs; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Controllers.AuditLog; + +public class CurrentUserAuditLogController : AuditLogControllerBase +{ + private readonly IAuditService _auditService; + private readonly IAuditLogViewModelFactory _auditLogViewModelFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IUserService _userService; + + public CurrentUserAuditLogController( + IAuditService auditService, + IAuditLogViewModelFactory auditLogViewModelFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUserService userService) + { + _auditService = auditService; + _auditLogViewModelFactory = auditLogViewModelFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _userService = userService; + } + + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task CurrentUser(Direction orderDirection = Direction.Descending, DateTime? sinceDate = null, int skip = 0, int take = 100) + { + // FIXME: Pull out current backoffice user when its implemented. + // var userId = _backOfficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? -1; + var userId = Constants.Security.SuperUserId; + + IUser? user = _userService.GetUserById(userId); + + if (user is null) + { + throw new PanicException("Could not find current user"); + } + + PagedModel result = await _auditService.GetPagedItemsByUserAsync( + user.Key, + skip, + take, + orderDirection, + null, + sinceDate); + + IEnumerable mapped = _auditLogViewModelFactory.CreateAuditLogWithUsernameViewModels(result.Items.Skip(skip).Take(take)); + var viewModel = new PagedViewModel + { + Total = result.Total, + Items = mapped, + }; + + return Ok(viewModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs new file mode 100644 index 0000000000..392e9ecf57 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Core.DependencyInjection; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +internal static class AuditLogBuilderExtensions +{ + internal static IUmbracoBuilder AddAuditLogs(this IUmbracoBuilder builder) + { + builder.Services.AddTransient(); + + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/AuditLogViewModelFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/AuditLogViewModelFactory.cs new file mode 100644 index 0000000000..6cbaa5a794 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/AuditLogViewModelFactory.cs @@ -0,0 +1,81 @@ +using Umbraco.Cms.Api.Management.ViewModels.AuditLogs; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Media; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Factories; + +public class AuditLogViewModelFactory : IAuditLogViewModelFactory +{ + private readonly IUserService _userService; + private readonly AppCaches _appCaches; + private readonly MediaFileManager _mediaFileManager; + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly IEntityService _entityService; + + public AuditLogViewModelFactory(IUserService userService, AppCaches appCaches, MediaFileManager mediaFileManager, IImageUrlGenerator imageUrlGenerator, IEntityService entityService) + { + _userService = userService; + _appCaches = appCaches; + _mediaFileManager = mediaFileManager; + _imageUrlGenerator = imageUrlGenerator; + _entityService = entityService; + } + + public IEnumerable CreateAuditLogViewModel(IEnumerable auditItems) => auditItems.Select(CreateAuditLogViewModel); + + public IEnumerable CreateAuditLogWithUsernameViewModels(IEnumerable auditItems) => auditItems.Select(CreateAuditLogWithUsernameViewModel); + + private AuditLogWithUsernameResponseModel CreateAuditLogWithUsernameViewModel(IAuditItem auditItem) + { + IEntitySlim? entitySlim = _entityService.Get(auditItem.Id); + + var target = new AuditLogWithUsernameResponseModel + { + Comment = auditItem.Comment, + EntityType = auditItem.EntityType, + EntityKey = entitySlim?.Key, + LogType = auditItem.AuditType, + Parameters = auditItem.Parameters, + Timestamp = auditItem.CreateDate, + }; + + IUser? user = _userService.GetUserById(auditItem.UserId); + if (user is null) + { + throw new ArgumentException($"Could not find user with id {auditItem.UserId}"); + } + + target.UserKey = user.Key; + target.UserAvatars = user.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator); + target.UserName = user.Name; + return target; + } + + private AuditLogResponseModel CreateAuditLogViewModel(IAuditItem auditItem) + { + IEntitySlim? entitySlim = _entityService.Get(auditItem.Id); + var target = new AuditLogResponseModel + { + Comment = auditItem.Comment, + EntityType = auditItem.EntityType, + EntityKey = entitySlim?.Key, + LogType = auditItem.AuditType, + Parameters = auditItem.Parameters, + Timestamp = auditItem.CreateDate, + }; + + IUser? user = _userService.GetUserById(auditItem.UserId); + if (user is null) + { + throw new ArgumentException($"Could not find user with id {auditItem.UserId}"); + } + + target.UserKey = user.Key; + return target; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IAuditLogViewModelFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IAuditLogViewModelFactory.cs new file mode 100644 index 0000000000..798eb47e76 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IAuditLogViewModelFactory.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Api.Management.ViewModels.AuditLogs; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IAuditLogViewModelFactory +{ + IEnumerable CreateAuditLogViewModel(IEnumerable auditItems); + + IEnumerable CreateAuditLogWithUsernameViewModels(IEnumerable auditItems); +} diff --git a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs index 1bb5f1ff4c..1e58bac8b8 100644 --- a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs @@ -25,6 +25,7 @@ public class ManagementApiComposer : IComposer .AddUpgrader() .AddSearchManagement() .AddTrees() + .AddAuditLogs() .AddDocuments() .AddDocumentTypes() .AddLanguages() diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index cac318b00f..ab53d86a16 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -6,6 +6,181 @@ "version": "1.0" }, "paths": { + "/umbraco/management/api/v1/audit-log": { + "get": { + "tags": [ + "Audit Log" + ], + "operationId": "GetAuditLog", + "parameters": [ + { + "name": "orderDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/DirectionModel" + } + }, + { + "name": "sinceDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "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/PagedAuditLogWithUsernameResponseModel" + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/audit-log/{key}": { + "get": { + "tags": [ + "Audit Log" + ], + "operationId": "GetAuditLogByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "orderDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/DirectionModel" + } + }, + { + "name": "sinceDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "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/PagedAuditLogResponseModel" + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/audit-log/type/{logType}": { + "get": { + "tags": [ + "Audit Log" + ], + "operationId": "GetAuditLogTypeByLogType", + "parameters": [ + { + "name": "logType", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/AuditTypeModel" + } + }, + { + "name": "sinceDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "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/PagedAuditLogResponseModel" + } + } + } + } + } + } + }, "/umbraco/management/api/v1/culture": { "get": { "tags": [ @@ -5225,7 +5400,8 @@ "name": "filterMustBeIsDependency", "in": "query", "schema": { - "type": "boolean" + "type": "boolean", + "default": true } } ], @@ -5558,6 +5734,101 @@ }, "components": { "schemas": { + "AuditLogBaseModel": { + "type": "object", + "properties": { + "userKey": { + "type": "string", + "format": "uuid" + }, + "entityKey": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "logType": { + "$ref": "#/components/schemas/AuditTypeModel" + }, + "entityType": { + "type": "string", + "nullable": true + }, + "comment": { + "type": "string", + "nullable": true + }, + "parameters": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "AuditLogResponseModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/AuditLogBaseModel" + } + ], + "additionalProperties": false + }, + "AuditLogWithUsernameResponseModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/AuditLogBaseModel" + } + ], + "properties": { + "userName": { + "type": "string", + "nullable": true + }, + "userAvatars": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "AuditTypeModel": { + "enum": [ + "New", + "Save", + "SaveVariant", + "Open", + "Delete", + "Publish", + "PublishVariant", + "SendToPublish", + "SendToPublishVariant", + "Unpublish", + "UnpublishVariant", + "Move", + "Copy", + "AssignDomain", + "PublicAccess", + "Sort", + "Notify", + "System", + "RollBack", + "PackagerInstall", + "PackagerUninstall", + "Custom", + "ContentVersionPreventCleanup", + "ContentVersionEnableCleanup" + ], + "type": "integer", + "format": "int32" + }, "ConsentLevelModel": { "type": "object", "properties": { @@ -7074,6 +7345,54 @@ "type": "integer", "format": "int32" }, + "PagedAuditLogResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/AuditLogResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "PagedAuditLogWithUsernameResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/AuditLogWithUsernameResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PagedContentTreeItemModel": { "required": [ "items", diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/AuditLogs/AuditLogBaseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/AuditLogs/AuditLogBaseModel.cs new file mode 100644 index 0000000000..7168fd9dfd --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/AuditLogs/AuditLogBaseModel.cs @@ -0,0 +1,20 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.ViewModels.AuditLogs; + +public class AuditLogBaseModel +{ + public Guid UserKey { get; set; } + + public Guid? EntityKey { get; set; } + + public DateTime Timestamp { get; set; } + + public AuditType LogType { get; set; } + + public string? EntityType { get; set; } + + public string? Comment { get; set; } + + public string? Parameters { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/AuditLogs/AuditLogResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/AuditLogs/AuditLogResponseModel.cs new file mode 100644 index 0000000000..7b0f43b4e1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/AuditLogs/AuditLogResponseModel.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.ViewModels.AuditLogs; + +/// +public class AuditLogResponseModel : AuditLogBaseModel +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/AuditLogs/AuditLogWithUsernameResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/AuditLogs/AuditLogWithUsernameResponseModel.cs new file mode 100644 index 0000000000..d1b0933ae7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/AuditLogs/AuditLogWithUsernameResponseModel.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.ViewModels.AuditLogs; + +public class AuditLogWithUsernameResponseModel : AuditLogBaseModel +{ + public string? UserName { get; set; } + + public string[]? UserAvatars { get; set; } +} diff --git a/src/Umbraco.Core/Models/AuditItem.cs b/src/Umbraco.Core/Models/AuditItem.cs index bbfca724aa..cfbe676dac 100644 --- a/src/Umbraco.Core/Models/AuditItem.cs +++ b/src/Umbraco.Core/Models/AuditItem.cs @@ -7,7 +7,7 @@ public sealed class AuditItem : EntityBase, IAuditItem /// /// Initializes a new instance of the class. /// - public AuditItem(int objectId, AuditType type, int userId, string? entityType, string? comment = null, string? parameters = null) + public AuditItem(int objectId, AuditType type, int userId, string? entityType, string? comment = null, string? parameters = null, DateTime? createDate = null) { DisableChangeTracking(); @@ -17,6 +17,7 @@ public sealed class AuditItem : EntityBase, IAuditItem UserId = userId; EntityType = entityType; Parameters = parameters; + CreateDate = createDate ?? default; EnableChangeTracking(); } diff --git a/src/Umbraco.Core/PaginationHelper.cs b/src/Umbraco.Core/PaginationHelper.cs new file mode 100644 index 0000000000..2fbf6ff771 --- /dev/null +++ b/src/Umbraco.Core/PaginationHelper.cs @@ -0,0 +1,15 @@ +namespace Umbraco.Cms.Core; + +public static class PaginationHelper +{ + internal static void ConvertSkipTakeToPaging(int skip, int take, out long pageNumber, out int pageSize) + { + if (skip % take != 0) + { + throw new ArgumentException("Invalid skip/take, Skip must be a multiple of take - i.e. skip = 10, take = 5"); + } + + pageSize = take; + pageNumber = skip / take; + } +} diff --git a/src/Umbraco.Core/Services/AuditService.cs b/src/Umbraco.Core/Services/AuditService.cs index 046c5fff3d..bbfaf70076 100644 --- a/src/Umbraco.Core/Services/AuditService.cs +++ b/src/Umbraco.Core/Services/AuditService.cs @@ -1,15 +1,23 @@ using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; +using Umbraco.New.Cms.Core.Models; namespace Umbraco.Cms.Core.Services.Implement; public sealed class AuditService : RepositoryService, IAuditService { private readonly IAuditEntryRepository _auditEntryRepository; + private readonly IUserService _userService; + private readonly IEntityRepository _entityRepository; private readonly IAuditRepository _auditRepository; private readonly Lazy _isAvailable; @@ -18,11 +26,15 @@ public sealed class AuditService : RepositoryService, IAuditService ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, IAuditRepository auditRepository, - IAuditEntryRepository auditEntryRepository) + IAuditEntryRepository auditEntryRepository, + IUserService userService, + IEntityRepository entityRepository) : base(provider, loggerFactory, eventMessagesFactory) { _auditRepository = auditRepository; _auditEntryRepository = auditEntryRepository; + _userService = userService; + _entityRepository = entityRepository; _isAvailable = new Lazy(DetermineIsAvailable); } @@ -184,6 +196,77 @@ public sealed class AuditService : RepositoryService, IAuditService } } + public async Task> GetItemsByKeyAsync( + Guid entityKey, + int skip, + int take, + Direction orderDirection = Direction.Descending, + DateTime? sinceDate = null, + AuditType[]? auditTypeFilter = null) + { + if (skip < 0) + { + throw new ArgumentOutOfRangeException(nameof(skip)); + } + + if (take <= 0) + { + throw new ArgumentOutOfRangeException(nameof(take)); + } + + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + IEntitySlim? entity = _entityRepository.Get(entityKey); + if (entity is null) + { + throw new ArgumentNullException($"Could not find user with key {entityKey}"); + } + + IQuery query = Query().Where(x => x.Id == entity.Id); + IQuery? customFilter = sinceDate.HasValue ? Query().Where(x => x.CreateDate >= sinceDate) : null; + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + + IEnumerable auditItems = _auditRepository.GetPagedResultsByQuery(query, pageNumber, pageSize, out var totalRecords, orderDirection, auditTypeFilter, customFilter); + return await Task.FromResult(new PagedModel { Items = auditItems, Total = totalRecords }); + } + } + + public async Task> GetPagedItemsByUserAsync( + Guid userKey, + int skip, + int take, + Direction orderDirection = Direction.Descending, + AuditType[]? auditTypeFilter = null, + DateTime? sinceDate = null) + { + if (skip < 0) + { + throw new ArgumentOutOfRangeException(nameof(skip)); + } + + if (take <= 0) + { + throw new ArgumentOutOfRangeException(nameof(take)); + } + + IUser? user = await _userService.GetAsync(userKey); + + if (user is null) + { + return await Task.FromResult(new PagedModel()); + } + + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + IQuery query = Query().Where(x => x.UserId == user.Id); + IQuery? customFilter = sinceDate.HasValue ? Query().Where(x => x.CreateDate >= sinceDate) : null; + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + + IEnumerable auditItems = _auditRepository.GetPagedResultsByQuery(query, pageNumber, pageSize, out var totalRecords, orderDirection, auditTypeFilter, customFilter); + return await Task.FromResult(new PagedModel { Items = auditItems, Total = totalRecords }); + } + } + /// public IAuditEntry Write(int performingUserId, string perfomingDetails, string performingIp, DateTime eventDateUtc, int affectedUserId, string? affectedDetails, string eventType, string eventDetails) { diff --git a/src/Umbraco.Core/Services/IAuditService.cs b/src/Umbraco.Core/Services/IAuditService.cs index f58da53174..ba6d6aa4ff 100644 --- a/src/Umbraco.Core/Services/IAuditService.cs +++ b/src/Umbraco.Core/Services/IAuditService.cs @@ -1,5 +1,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.New.Cms.Core.Models; namespace Umbraco.Cms.Core.Services; @@ -74,6 +76,60 @@ public interface IAuditService : IService AuditType[]? auditTypeFilter = null, IQuery? customFilter = null); + /// + /// Returns paged items in the audit trail for a given user + /// + /// The key of the user + /// The amount of entries to skip + /// The amount of entiries to take + /// The total amount of entires + /// + /// By default this will always be ordered descending (newest first) + /// + /// + /// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query + /// or the custom filter + /// so we need to do that here + /// + /// + /// If populated, will only return entries after this time. + /// + /// + Task> GetItemsByKeyAsync( + Guid entityKey, + int skip, + int take, + Direction orderDirection = Direction.Descending, + DateTime? sinceDate = null, + AuditType[]? auditTypeFilter = null) => throw new NotImplementedException(); + + /// + /// Returns paged items in the audit trail for a given user + /// + /// + /// + /// + /// + /// + /// By default this will always be ordered descending (newest first) + /// + /// + /// Since we currently do not have enum support with our expression parser, we cannot query on AuditType in the query + /// or the custom filter + /// so we need to do that here + /// + /// + /// Optional filter to be applied + /// + /// + Task> GetPagedItemsByUserAsync( + Guid userKey, + int skip, + int take, + Direction orderDirection = Direction.Descending, + AuditType[]? auditTypeFilter = null, + DateTime? sinceDate = null) => throw new NotImplementedException(); + /// /// Writes an audit entry for an audited event. /// diff --git a/src/Umbraco.Core/Services/OperationStatus/AuditLogOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/AuditLogOperationStatus.cs new file mode 100644 index 0000000000..c21a412e49 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/AuditLogOperationStatus.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum AuditLogOperationStatus +{ + Success, + UserNotFound, +} diff --git a/src/Umbraco.Core/Services/UserServiceExtensions.cs b/src/Umbraco.Core/Services/UserServiceExtensions.cs index bc39b682a5..761b924ac5 100644 --- a/src/Umbraco.Core/Services/UserServiceExtensions.cs +++ b/src/Umbraco.Core/Services/UserServiceExtensions.cs @@ -82,9 +82,5 @@ public static class UserServiceExtensions } [Obsolete("Use IUserService.Get that takes a Guid instead. Scheduled for removal in V15.")] - public static IUser? GetByKey(this IUserService userService, Guid key) - { - var id = BitConverter.ToInt32(key.ToByteArray(), 0); - return userService.GetUserById(id); - } + public static IUser? GetByKey(this IUserService userService, Guid key) => userService.GetAsync(key).GetAwaiter().GetResult(); } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditRepository.cs index f11e17a236..9f0df27897 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditRepository.cs @@ -29,7 +29,7 @@ internal class AuditRepository : EntityRepositoryBase, IAuditRe List? dtos = Database.Fetch(sql); - return dtos.Select(x => new AuditItem(x.NodeId, Enum.Parse(x.Header), x.UserId ?? Constants.Security.UnknownUserId, x.EntityType, x.Comment, x.Parameters)).ToList(); + return dtos.Select(x => new AuditItem(x.NodeId, Enum.Parse(x.Header), x.UserId ?? Constants.Security.UnknownUserId, x.EntityType, x.Comment, x.Parameters, x.Datestamp)).ToList(); } public void CleanLogs(int maximumAgeOfLogsInMinutes)