diff --git a/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs b/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs index b772a103ba..2bfb5a2375 100644 --- a/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs @@ -7,6 +7,7 @@ public class WebhookSettings { private const bool StaticEnabled = true; private const int StaticMaximumRetries = 5; + internal const string StaticPeriod = "00:00:10"; /// /// Gets or sets a value indicating whether webhooks are enabled. @@ -31,4 +32,10 @@ public class WebhookSettings /// [DefaultValue(StaticMaximumRetries)] public int MaximumRetries { get; set; } = StaticMaximumRetries; + + /// + /// Gets or sets a value for the period of the webhook firing. + /// + [DefaultValue(StaticPeriod)] + public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod); } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 354ef7a40c..c8ef1fbff7 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -35,13 +35,11 @@ using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Snippets; using Umbraco.Cms.Core.DynamicRoot; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Core.Telemetry; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Core.Web; -using Umbraco.Cms.Core.Webhooks; using Umbraco.Extensions; namespace Umbraco.Cms.Core.DependencyInjection @@ -332,9 +330,12 @@ namespace Umbraco.Cms.Core.DependencyInjection // Register filestream security analyzers Services.AddUnique(); Services.AddUnique(); + + // Register Webhook services Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); } } } diff --git a/src/Umbraco.Core/Models/WebhookRequest.cs b/src/Umbraco.Core/Models/WebhookRequest.cs new file mode 100644 index 0000000000..b7f2d58284 --- /dev/null +++ b/src/Umbraco.Core/Models/WebhookRequest.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models; + +public class WebhookRequest +{ + public int Id { get; set; } + + public Guid WebhookKey { get; set; } + + public string EventAlias { get; set; } = string.Empty; + + public string? RequestObject { get; set; } + + public int RetryCount { get; set; } +} diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index cdc5af450a..631e99df6e 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -91,6 +91,7 @@ public static partial class Constants public const string Webhook2Events = Webhook + "2Events"; public const string Webhook2Headers = Webhook + "2Headers"; public const string WebhookLog = Webhook + "Log"; + public const string WebhookRequest = Webhook + "Request"; } } } diff --git a/src/Umbraco.Core/Persistence/Constants-Locks.cs b/src/Umbraco.Core/Persistence/Constants-Locks.cs index e97f16a663..7672d73ec7 100644 --- a/src/Umbraco.Core/Persistence/Constants-Locks.cs +++ b/src/Umbraco.Core/Persistence/Constants-Locks.cs @@ -70,5 +70,10 @@ public static partial class Constants /// ScheduledPublishing job. /// public const int ScheduledPublishing = -341; + + /// + /// ScheduledPublishing job. + /// + public const int WebhookRequest = -342; } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IWebhookRequestRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IWebhookRequestRepository.cs new file mode 100644 index 0000000000..1a2b7b158d --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IWebhookRequestRepository.cs @@ -0,0 +1,33 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IWebhookRequestRepository +{ + /// + /// Creates a webhook request in the current repository. + /// + /// The webhook you want to create. + /// The created webhook + Task CreateAsync(WebhookRequest webhookRequest); + + /// + /// Deletes a webhook request in the current repository + /// + /// The webhook request to be deleted. + /// A representing the asynchronous operation. + Task DeleteAsync(WebhookRequest webhookRequest); + + /// + /// Gets all of the webhook requests in the current repository. + /// + /// A paged model of objects. + Task> GetAllAsync(); + + /// + /// Update a webhook request in the current repository. + /// + /// The webhook request you want to update. + /// The updated webhook + Task UpdateAsync(WebhookRequest webhookRequest); +} diff --git a/src/Umbraco.Core/Services/IWebhookRequestService.cs b/src/Umbraco.Core/Services/IWebhookRequestService.cs new file mode 100644 index 0000000000..b3b6396b01 --- /dev/null +++ b/src/Umbraco.Core/Services/IWebhookRequestService.cs @@ -0,0 +1,35 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +public interface IWebhookRequestService +{ + /// + /// Creates a webhook request. + /// + /// The key of the webhook. + /// The alias of the event that is creating the request. + /// The payload you want to send with your request. + /// The created webhook + Task CreateAsync(Guid webhookKey, string eventAlias, object? payload); + + /// + /// Gets all of the webhook requests in the current database. + /// + /// An enumerable of objects. + Task> GetAllAsync(); + + /// + /// Deletes a webhook request + /// + /// The webhook request to be deleted. + /// A representing the asynchronous operation. + Task DeleteAsync(WebhookRequest webhookRequest); + + /// + /// Update a webhook request. + /// + /// The webhook request you want to update. + /// The updated webhook + Task UpdateAsync(WebhookRequest webhookRequest); +} diff --git a/src/Umbraco.Core/Services/WebhookRequestService.cs b/src/Umbraco.Core/Services/WebhookRequestService.cs new file mode 100644 index 0000000000..249ed21421 --- /dev/null +++ b/src/Umbraco.Core/Services/WebhookRequestService.cs @@ -0,0 +1,63 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.Services; + +public class WebhookRequestService : IWebhookRequestService +{ + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly IWebhookRequestRepository _webhookRequestRepository; + private readonly IJsonSerializer _jsonSerializer; + + public WebhookRequestService(ICoreScopeProvider coreScopeProvider, IWebhookRequestRepository webhookRequestRepository, IJsonSerializer jsonSerializer) + { + _coreScopeProvider = coreScopeProvider; + _webhookRequestRepository = webhookRequestRepository; + _jsonSerializer = jsonSerializer; + } + + public async Task CreateAsync(Guid webhookKey, string eventAlias, object? payload) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.WebhookRequest); + var webhookRequest = new WebhookRequest + { + WebhookKey = webhookKey, + EventAlias = eventAlias, + RequestObject = _jsonSerializer.Serialize(payload), + RetryCount = 0, + }; + + webhookRequest = await _webhookRequestRepository.CreateAsync(webhookRequest); + scope.Complete(); + + return webhookRequest; + } + + public Task> GetAllAsync() + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + Task> webhookRequests = _webhookRequestRepository.GetAllAsync(); + scope.Complete(); + return webhookRequests; + } + + public async Task DeleteAsync(WebhookRequest webhookRequest) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.WebhookRequest); + await _webhookRequestRepository.DeleteAsync(webhookRequest); + scope.Complete(); + } + + public async Task UpdateAsync(WebhookRequest webhookRequest) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.WebhookRequest); + WebhookRequest updated = await _webhookRequestRepository.UpdateAsync(webhookRequest); + scope.Complete(); + return updated; + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs new file mode 100644 index 0000000000..fe8cf1e204 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs @@ -0,0 +1,124 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +public class WebhookFiring : IRecurringBackgroundJob +{ + private readonly ILogger _logger; + private readonly IWebhookRequestService _webhookRequestService; + private readonly IJsonSerializer _jsonSerializer; + private readonly IWebhookLogFactory _webhookLogFactory; + private readonly IWebhookLogService _webhookLogService; + private readonly IWebhookService _webHookService; + private readonly ICoreScopeProvider _coreScopeProvider; + private WebhookSettings _webhookSettings; + + public TimeSpan Period => _webhookSettings.Period; + + public TimeSpan Delay { get; } = TimeSpan.FromSeconds(20); + + // No-op event as the period never changes on this job + public event EventHandler PeriodChanged { add { } remove { } } + + public WebhookFiring( + ILogger logger, + IWebhookRequestService webhookRequestService, + IJsonSerializer jsonSerializer, + IWebhookLogFactory webhookLogFactory, + IWebhookLogService webhookLogService, + IWebhookService webHookService, + IOptionsMonitor webhookSettings, + ICoreScopeProvider coreScopeProvider) + { + _logger = logger; + _webhookRequestService = webhookRequestService; + _jsonSerializer = jsonSerializer; + _webhookLogFactory = webhookLogFactory; + _webhookLogService = webhookLogService; + _webHookService = webHookService; + _coreScopeProvider = coreScopeProvider; + _webhookSettings = webhookSettings.CurrentValue; + webhookSettings.OnChange(x => _webhookSettings = x); + } + + public async Task RunJobAsync() + { + IEnumerable requests; + using (ICoreScope scope = _coreScopeProvider.CreateCoreScope()) + { + scope.ReadLock(Constants.Locks.WebhookRequest); + requests = await _webhookRequestService.GetAllAsync(); + scope.Complete(); + } + + await Task.WhenAll(requests.Select(request => + { + using (ExecutionContext.SuppressFlow()) + { + return Task.Run(async () => + { + Webhook? webhook = await _webHookService.GetAsync(request.WebhookKey); + if (webhook is null) + { + return; + } + + HttpResponseMessage? response = await SendRequestAsync(webhook, request.EventAlias, request.RequestObject, request.RetryCount, CancellationToken.None); + + if ((response?.IsSuccessStatusCode ?? false) || request.RetryCount >= _webhookSettings.MaximumRetries) + { + await _webhookRequestService.DeleteAsync(request); + } + else + { + request.RetryCount++; + await _webhookRequestService.UpdateAsync(request); + } + }); + } + })); + } + + private async Task SendRequestAsync(Webhook webhook, string eventName, object? payload, int retryCount, CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + + var serializedObject = _jsonSerializer.Serialize(payload); + var stringContent = new StringContent(serializedObject, Encoding.UTF8, "application/json"); + stringContent.Headers.TryAddWithoutValidation("Umb-Webhook-Event", eventName); + + foreach (KeyValuePair header in webhook.Headers) + { + stringContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + HttpResponseMessage? response = null; + try + { + response = await httpClient.PostAsync(webhook.Url, stringContent, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while sending webhook request for webhook {WebhookKey}.", webhook); + } + + var webhookResponseModel = new WebhookResponseModel + { + HttpResponseMessage = response, + RetryCount = retryCount, + }; + + WebhookLog log = await _webhookLogFactory.CreateAsync(eventName, webhookResponseModel, webhook, cancellationToken); + await _webhookLogService.CreateAsync(log); + + return response; + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 2e4832cff0..8c9f4b1193 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -73,6 +73,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index a3f685f0ae..2d17d42f83 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -993,31 +993,19 @@ internal class DatabaseDataCreator private void CreateLockData() { // all lock objects - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.Servers, Name = "Servers" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.ContentTypes, Name = "ContentTypes" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.ContentTree, Name = "ContentTree" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MediaTypes, Name = "MediaTypes" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MediaTree, Name = "MediaTree" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MemberTypes, Name = "MemberTypes" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MemberTree, Name = "MemberTree" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.Domains, Name = "Domains" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.KeyValues, Name = "KeyValues" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.Languages, Name = "Languages" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.ScheduledPublishing, Name = "ScheduledPublishing" }); - - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Servers, Name = "Servers" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.ContentTypes, Name = "ContentTypes" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.ContentTree, Name = "ContentTree" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MediaTypes, Name = "MediaTypes" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MediaTree, Name = "MediaTree" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MemberTypes, Name = "MemberTypes" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MemberTree, Name = "MemberTree" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Domains, Name = "Domains" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.KeyValues, Name = "KeyValues" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Languages, Name = "Languages" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.ScheduledPublishing, Name = "ScheduledPublishing" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.WebhookRequest, Name = "WebhookRequest" }); } private void CreateContentTypeData() diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index 9a944e4a90..fdd8a07256 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -88,6 +88,7 @@ public class DatabaseSchemaCreator typeof(Webhook2EventsDto), typeof(Webhook2HeadersDto), typeof(WebhookLogDto), + typeof(WebhookRequestDto), }; private readonly IUmbracoDatabase _database; diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 3a4e715228..e543c61279 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -98,5 +98,7 @@ public class UmbracoPlan : MigrationPlan // To 13.0.0 To("{C76D9C9A-635B-4D2C-A301-05642A523E9D}"); To("{D5139400-E507-4259-A542-C67358F7E329}"); + To("{4E652F18-9A29-4656-A899-E3F39069C47E}"); + To("{148714C8-FE0D-4553-B034-439D91468761}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhookRequest.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhookRequest.cs new file mode 100644 index 0000000000..458cd26b31 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhookRequest.cs @@ -0,0 +1,35 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0; + +public class AddWebhookRequest : MigrationBase +{ + public AddWebhookRequest(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + IEnumerable tables = SqlSyntax.GetTablesInSchema(Context.Database).ToArray(); + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.WebhookRequest) is false) + { + Create.Table().Do(); + } + + Sql sql = Database.SqlContext.Sql() + .Select() + .From() + .Where(x => x.Id == Constants.Locks.WebhookRequest); + + LockDto? webhookRequestLock = Database.FirstOrDefault(sql); + + if (webhookRequestLock is null) + { + Database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.WebhookRequest, Name = "WebhookRequest" }); + } + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameWebhookIdToKey.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameWebhookIdToKey.cs new file mode 100644 index 0000000000..e68aa258a9 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameWebhookIdToKey.cs @@ -0,0 +1,27 @@ +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0; + +public class RenameWebhookIdToKey : MigrationBase +{ + public RenameWebhookIdToKey(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + // This check is here because we renamed a column from 13-rc1 to 13-rc2, the previous migration adds the table + // so if you are upgrading from 13-rc1 to 13-rc2 then this column will not exist. + // If you are however upgrading from 12, then this column will exist, and thus there is no need to rename it. + if (ColumnExists(Constants.DatabaseSchema.Tables.WebhookLog, "webhookId") is false) + { + return; + } + + Rename + .Column("webhookId") + .OnTable(Constants.DatabaseSchema.Tables.WebhookLog) + .To("webhookKey") + .Do(); + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs index a9588ecb7d..3f3e5623b7 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs @@ -13,7 +13,7 @@ internal class WebhookLogDto [PrimaryKeyColumn(AutoIncrement = true)] public int Id { get; set; } - [Column("webhookId")] + [Column("webhookKey")] public Guid WebhookKey { get; set; } [Column(Name = "key")] diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookRequestDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookRequestDto.cs new file mode 100644 index 0000000000..e1f55ad042 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookRequestDto.cs @@ -0,0 +1,28 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(Constants.DatabaseSchema.Tables.WebhookRequest)] +[PrimaryKey("id")] +[ExplicitColumns] +public class WebhookRequestDto +{ + [Column("id")] + [PrimaryKeyColumn(AutoIncrement = true)] + public int Id { get; set; } + + [Column("webhookKey")] + public Guid WebhookKey { get; set; } + + [Column("eventName")] + public string Alias { get; set; } = string.Empty; + + [Column(Name = "requestObject")] + [NullSetting(NullSetting = NullSettings.Null)] + public string? RequestObject { get; set; } + + [Column("retryCount")] + public int RetryCount { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/WebhookRequestFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookRequestFactory.cs new file mode 100644 index 0000000000..dd0648588b --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookRequestFactory.cs @@ -0,0 +1,27 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Persistence.Factories; + +internal static class WebhookRequestFactory +{ + public static WebhookRequestDto CreateDto(WebhookRequest webhookRequest) => + new() + { + Alias = webhookRequest.EventAlias, + Id = webhookRequest.Id, + WebhookKey = webhookRequest.WebhookKey, + RequestObject = webhookRequest.RequestObject, + RetryCount = webhookRequest.RetryCount, + }; + + public static WebhookRequest CreateModel(WebhookRequestDto webhookRequestDto) => + new() + { + EventAlias = webhookRequestDto.Alias, + Id = webhookRequestDto.Id, + WebhookKey = webhookRequestDto.WebhookKey, + RequestObject = webhookRequestDto.RequestObject, + RetryCount = webhookRequestDto.RetryCount, + }; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRequestRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRequestRepository.cs new file mode 100644 index 0000000000..21b25c77a7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRequestRepository.cs @@ -0,0 +1,65 @@ +using NPoco; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Factories; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +public class WebhookRequestRepository : IWebhookRequestRepository +{ + private readonly IScopeAccessor _scopeAccessor; + + public WebhookRequestRepository(IScopeAccessor scopeAccessor) => _scopeAccessor = scopeAccessor; + + private IUmbracoDatabase Database + { + get + { + if (_scopeAccessor.AmbientScope is null) + { + throw new NotSupportedException("Need to be executed in a scope"); + } + + return _scopeAccessor.AmbientScope.Database; + } + } + + public async Task CreateAsync(WebhookRequest webhookRequest) + { + WebhookRequestDto dto = WebhookRequestFactory.CreateDto(webhookRequest); + var result = await Database.InsertAsync(dto); + var id = Convert.ToInt32(result); + webhookRequest.Id = id; + return webhookRequest; + } + + public async Task DeleteAsync(WebhookRequest webhookRequest) + { + Sql sql = Database.SqlContext.Sql() + .Delete() + .Where(x => x.Id == webhookRequest.Id); + + await Database.ExecuteAsync(sql); + } + + public async Task> GetAllAsync() + { + Sql? sql = Database.SqlContext.Sql() + .Select() + .From(); + + List webhookDtos = await Database.FetchAsync(sql); + + return webhookDtos.Select(WebhookRequestFactory.CreateModel); + } + + public async Task UpdateAsync(WebhookRequest webhookRequest) + { + WebhookRequestDto dto = WebhookRequestFactory.CreateDto(webhookRequest); + await Database.UpdateAsync(dto); + return webhookRequest; + } +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs b/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs index 67b55c913e..b107d82999 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs @@ -1,74 +1,16 @@ -using System.Net.Http.Headers; -using System.Text; -using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Infrastructure.Services.Implement; public class WebhookFiringService : IWebhookFiringService { - private readonly IJsonSerializer _jsonSerializer; - private readonly WebhookSettings _webhookSettings; - private readonly IWebhookLogService _webhookLogService; - private readonly IWebhookLogFactory _webhookLogFactory; + private readonly IWebhookRequestService _webhookRequestService; - public WebhookFiringService( - IJsonSerializer jsonSerializer, - IOptions webhookSettings, - IWebhookLogService webhookLogService, - IWebhookLogFactory webhookLogFactory) - { - _jsonSerializer = jsonSerializer; - _webhookLogService = webhookLogService; - _webhookLogFactory = webhookLogFactory; - _webhookSettings = webhookSettings.Value; - } + public WebhookFiringService(IWebhookRequestService webhookRequestService) => _webhookRequestService = webhookRequestService; - // TODO: Add queing instead of processing directly in thread - // as this just makes save and publish longer - public async Task FireAsync(Webhook webhook, string eventAlias, object? payload, CancellationToken cancellationToken) - { - for (var retry = 0; retry < _webhookSettings.MaximumRetries; retry++) - { - HttpResponseMessage response = await SendRequestAsync(webhook, eventAlias, payload, retry, cancellationToken); - - if (response.IsSuccessStatusCode) - { - return; - } - } - } - - private async Task SendRequestAsync(Webhook webhook, string eventAlias, object? payload, int retryCount, CancellationToken cancellationToken) - { - using var httpClient = new HttpClient(); - - var serializedObject = _jsonSerializer.Serialize(payload); - var stringContent = new StringContent(serializedObject, Encoding.UTF8, "application/json"); - stringContent.Headers.TryAddWithoutValidation("Umb-Webhook-Event", eventAlias); - - foreach (KeyValuePair header in webhook.Headers) - { - stringContent.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - - HttpResponseMessage response = await httpClient.PostAsync(webhook.Url, stringContent, cancellationToken); - - var webhookResponseModel = new WebhookResponseModel - { - HttpResponseMessage = response, - RetryCount = retryCount, - }; - - - WebhookLog log = await _webhookLogFactory.CreateAsync(eventAlias, webhookResponseModel, webhook, cancellationToken); - await _webhookLogService.CreateAsync(log); - - return response; - } + public async Task FireAsync(Webhook webhook, string eventAlias, object? payload, CancellationToken cancellationToken) => + await _webhookRequestService.CreateAsync(webhook.Key, eventAlias, payload); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs b/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs index 2e48fbf25d..d41ccf684e 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs @@ -1,6 +1,7 @@ using Examine; using Examine.Search; using Lucene.Net.QueryParsers.Classic; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; @@ -8,11 +9,13 @@ using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; using SearchResult = Umbraco.Cms.Core.Models.ContentEditing.SearchResult; namespace Umbraco.Cms.Web.BackOffice.Controllers; +[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] public class ExamineManagementController : UmbracoAuthorizedJsonController { diff --git a/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs b/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs index d87398d574..a5e5e3b447 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.Security; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -14,10 +15,12 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; namespace Umbraco.Cms.Web.BackOffice.Controllers; +[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] public class RedirectUrlManagementController : UmbracoAuthorizedApiController { diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 71704f4edc..a08f712b37 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -215,6 +215,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddRecurringBackgroundJob(); builder.Services.AddRecurringBackgroundJob(); builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); builder.Services.AddRecurringBackgroundJob(provider => new ReportSiteJob( provider.GetRequiredService>(), @@ -224,7 +225,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddHostedService(); builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); builder.Services.AddHostedService(); - + return builder; } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs new file mode 100644 index 0000000000..3c41aa5ab2 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs @@ -0,0 +1,67 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class WebhookRequestServiceTests : UmbracoIntegrationTest +{ + private IWebhookRequestService WebhookRequestService => GetRequiredService(); + + private IWebhookService WebhookService => GetRequiredService(); + + [Test] + public async Task Can_Create_And_Get() + { + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + var created = await WebhookRequestService.CreateAsync(createdWebhook.Key, Constants.WebhookEvents.Aliases.ContentPublish, null); + var webhooks = await WebhookRequestService.GetAllAsync(); + var webhook = webhooks.First(x => x.Id == created.Id); + + Assert.Multiple(() => + { + Assert.AreEqual(created.Id, webhook.Id); + Assert.AreEqual(created.EventAlias, webhook.EventAlias); + Assert.AreEqual(created.RetryCount, webhook.RetryCount); + Assert.AreEqual(created.RequestObject, webhook.RequestObject); + Assert.AreEqual(created.WebhookKey, webhook.WebhookKey); + }); + } + + [Test] + public async Task Can_Update() + { + var newRetryCount = 4; + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + var created = await WebhookRequestService.CreateAsync(createdWebhook.Key, Constants.WebhookEvents.Aliases.ContentPublish, null); + created.RetryCount = newRetryCount; + await WebhookRequestService.UpdateAsync(created); + var webhooks = await WebhookRequestService.GetAllAsync(); + var webhook = webhooks.First(x => x.Id == created.Id); + + Assert.Multiple(() => + { + Assert.AreEqual(newRetryCount, webhook.RetryCount); + }); + } + + [Test] + public async Task Can_Delete() + { + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + var created = await WebhookRequestService.CreateAsync(createdWebhook.Key, Constants.WebhookEvents.Aliases.ContentPublish, null); + await WebhookRequestService.DeleteAsync(created); + var webhooks = await WebhookRequestService.GetAllAsync(); + var webhook = webhooks.FirstOrDefault(x => x.Id == created.Id); + + Assert.Multiple(() => + { + Assert.IsNull(webhook); + }); + } +}