diff --git a/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs b/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs new file mode 100644 index 0000000000..b772a103ba --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs @@ -0,0 +1,34 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models; + +[UmbracoOptions(Constants.Configuration.ConfigWebhook)] +public class WebhookSettings +{ + private const bool StaticEnabled = true; + private const int StaticMaximumRetries = 5; + + /// + /// Gets or sets a value indicating whether webhooks are enabled. + /// + /// + /// + /// By default, webhooks are enabled. + /// If this option is set to false webhooks will no longer send web-requests. + /// + /// + [DefaultValue(StaticEnabled)] + public bool Enabled { get; set; } = StaticEnabled; + + /// + /// Gets or sets a value indicating the maximum number of retries for all webhooks. + /// + /// + /// + /// By default, maximum number of retries is 5. + /// If this option is set to 0 webhooks will no longer retry. + /// + /// + [DefaultValue(StaticMaximumRetries)] + public int MaximumRetries { get; set; } = StaticMaximumRetries; +} diff --git a/src/Umbraco.Core/Constants-Applications.cs b/src/Umbraco.Core/Constants-Applications.cs index dc36715585..aa1f19c791 100644 --- a/src/Umbraco.Core/Constants-Applications.cs +++ b/src/Umbraco.Core/Constants-Applications.cs @@ -147,6 +147,8 @@ public static partial class Constants public const string LogViewer = "logViewer"; + public const string Webhooks = "webhooks"; + public static class Groups { public const string Settings = "settingsGroup"; diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 4f9d045cb6..d29ee7019f 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -64,6 +64,7 @@ public static partial class Constants public const string ConfigHelpPage = ConfigPrefix + "HelpPage"; public const string ConfigInstallDefaultData = ConfigPrefix + "InstallDefaultData"; public const string ConfigDataTypes = ConfigPrefix + "DataTypes"; + public const string ConfigWebhook = ConfigPrefix + "Webhook"; public static class NamedOptions { diff --git a/src/Umbraco.Core/Constants-Icons.cs b/src/Umbraco.Core/Constants-Icons.cs index 5cfc2808fc..5aaeb2ba61 100644 --- a/src/Umbraco.Core/Constants-Icons.cs +++ b/src/Umbraco.Core/Constants-Icons.cs @@ -158,5 +158,10 @@ public static partial class Constants /// System user group icon /// public const string UserGroup = "icon-users"; + + /// + /// Webhooks icon + /// + public const string Webhooks = "icon-directions-alt"; } } diff --git a/src/Umbraco.Core/Constants-WebhookEvents.cs b/src/Umbraco.Core/Constants-WebhookEvents.cs new file mode 100644 index 0000000000..24fe890221 --- /dev/null +++ b/src/Umbraco.Core/Constants-WebhookEvents.cs @@ -0,0 +1,32 @@ +namespace Umbraco.Cms.Core; + +public static partial class Constants +{ + public static class WebhookEvents + { + /// + /// Webhook event name for content publish. + /// + public const string ContentPublish = "ContentPublish"; + + /// + /// Webhook event name for content delete. + /// + public const string ContentDelete = "ContentDelete"; + + /// + /// Webhook event name for content unpublish. + /// + public const string ContentUnpublish = "ContentUnpublish"; + + /// + /// Webhook event name for media delete. + /// + public const string MediaDelete = "MediaDelete"; + + /// + /// Webhook event name for media save. + /// + public const string MediaSave = "MediaSave"; + } +} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs index 73eb599695..e6b413b07f 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs @@ -19,6 +19,7 @@ using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Tour; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Core.WebAssets; +using Umbraco.Cms.Core.Webhooks; using Umbraco.Extensions; namespace Umbraco.Cms.Core.DependencyInjection; @@ -128,6 +129,7 @@ public static partial class UmbracoBuilderExtensions builder.FilterHandlers().Add(() => builder.TypeLoader.GetTypes()); builder.SortHandlers().Add(() => builder.TypeLoader.GetTypes()); builder.ContentIndexHandlers().Add(() => builder.TypeLoader.GetTypes()); + builder.WebhookEvents().AddCoreWebhooks(); } /// @@ -195,6 +197,12 @@ public static partial class UmbracoBuilderExtensions public static SectionCollectionBuilder Sections(this IUmbracoBuilder builder) => builder.WithCollectionBuilder(); + /// + /// Gets the backoffice sections/applications collection builder. + /// + /// The builder. + public static WebhookEventCollectionBuilder WebhookEvents(this IUmbracoBuilder builder) => builder.WithCollectionBuilder(); + /// /// Gets the components collection builder. /// diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 411c39b178..e09e731956 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -87,7 +87,8 @@ public static partial class UmbracoBuilderExtensions .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() - .AddUmbracoOptions(); + .AddUmbracoOptions() + .AddUmbracoOptions(); // Configure connection string and ensure it's updated when the configuration changes builder.Services.AddSingleton, ConfigureConnectionStrings>(); diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 098f770ebc..9e43c1eb35 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -40,6 +40,7 @@ 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 @@ -329,6 +330,9 @@ namespace Umbraco.Cms.Core.DependencyInjection // Register filestream security analyzers Services.AddUnique(); + Services.AddUnique(); + Services.AddUnique(); + Services.AddUnique(); } } } diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index c24bbdcdd0..73087fff30 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -1004,6 +1004,12 @@ Umbraco %0% for en frisk installation eller for en opgradering fra version 3.0.

Tryk på Næste for at begynde på guiden.]]>
+ + Opret webhook + Tilføj webhook header + Tilføj dokument type + Tilføj medie Type + Culture Code Culture Name diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index 3369f61af6..0cde55db08 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -1975,6 +1975,13 @@ To manage your website, simply open the Umbraco backoffice and start adding cont NOTE! The cleanup of historically content versions are disabled globally. These settings will not take effect before it is enabled.]]> Changing a data type with stored values is disabled. To allow this you can change the Umbraco:CMS:DataTypes:CanBeChanged setting in appsettings.json. + + Create webhook + Add webhook header + Logs + Add Document Type + Add Media Type + Add language ISO code @@ -2119,6 +2126,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Settings Templating Third Party + Webhooks New update ready diff --git a/src/Umbraco.Core/Models/DefaultPayloadModel.cs b/src/Umbraco.Core/Models/DefaultPayloadModel.cs new file mode 100644 index 0000000000..45b2592b51 --- /dev/null +++ b/src/Umbraco.Core/Models/DefaultPayloadModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Models; + +internal class DefaultPayloadModel +{ + public Guid Id { get; set; } +} diff --git a/src/Umbraco.Core/Models/Webhook.cs b/src/Umbraco.Core/Models/Webhook.cs new file mode 100644 index 0000000000..bc31745cf5 --- /dev/null +++ b/src/Umbraco.Core/Models/Webhook.cs @@ -0,0 +1,27 @@ +namespace Umbraco.Cms.Core.Models; + +public class Webhook +{ + public Webhook(string url, bool? enabled = null, Guid[]? entityKeys = null, string[]? events = null, IDictionary? headers = null) + { + Url = url; + Headers = headers ?? new Dictionary(); + Events = events ?? Array.Empty(); + ContentTypeKeys = entityKeys ?? Array.Empty(); + Enabled = enabled ?? false; + } + + public int Id { get; set; } + + public Guid Key { get; set; } + + public string Url { get; set; } + + public string[] Events { get; set; } + + public Guid[] ContentTypeKeys {get; set; } + + public bool Enabled { get; set; } + + public IDictionary Headers { get; set; } +} diff --git a/src/Umbraco.Core/Models/WebhookLog.cs b/src/Umbraco.Core/Models/WebhookLog.cs new file mode 100644 index 0000000000..bd37d79165 --- /dev/null +++ b/src/Umbraco.Core/Models/WebhookLog.cs @@ -0,0 +1,28 @@ +namespace Umbraco.Cms.Core.Models; + +public class WebhookLog +{ + public int Id { get; set; } + + public Guid WebhookKey { get; set; } + + public Guid Key { get; set; } + + public string Url { get; set; } = string.Empty; + + public string StatusCode { get; set; } = string.Empty; + + public DateTime Date { get; set; } + + public string EventName { get; set; } = string.Empty; + + public int RetryCount { get; set; } + + public string RequestHeaders { get; set; } = string.Empty; + + public string? RequestBody { get; set; } = string.Empty; + + public string ResponseHeaders { get; set; } = string.Empty; + + public string ResponseBody { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Core/Models/WebhookResponseModel.cs b/src/Umbraco.Core/Models/WebhookResponseModel.cs new file mode 100644 index 0000000000..1f40443806 --- /dev/null +++ b/src/Umbraco.Core/Models/WebhookResponseModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models; + +public class WebhookResponseModel +{ + public HttpResponseMessage? HttpResponseMessage { get; set; } + + public int RetryCount { get; set; } +} diff --git a/src/Umbraco.Core/PaginationHelper.cs b/src/Umbraco.Core/PaginationHelper.cs new file mode 100644 index 0000000000..eb9049c1da --- /dev/null +++ b/src/Umbraco.Core/PaginationHelper.cs @@ -0,0 +1,15 @@ +namespace Umbraco.Cms.Core; + +public static class PaginationHelper +{ + public 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/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 420f36c759..bfaad81c59 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -86,6 +86,11 @@ public static partial class Constants public const string LogViewerQuery = TableNamePrefix + "LogViewerQuery"; public const string CreatedPackageSchema = TableNamePrefix + "CreatedPackageSchema"; + public const string Webhook = TableNamePrefix + "Webhook"; + public const string Webhook2ContentTypeKeys = Webhook + "2ContentTypeKeys"; + public const string Webhook2Events = Webhook + "2Events"; + public const string Webhook2Headers = Webhook + "2Headers"; + public const string WebhookLog = TableNamePrefix + "WebhookLog"; } } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IWebhookLogRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IWebhookLogRepository.cs new file mode 100644 index 0000000000..a4652d5955 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IWebhookLogRepository.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Webhooks; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IWebhookLogRepository +{ + Task CreateAsync(WebhookLog log); + + Task> GetPagedAsync(int skip, int take); +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IWebhookRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IWebhookRepository.cs new file mode 100644 index 0000000000..d045cd172f --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IWebhookRepository.cs @@ -0,0 +1,49 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IWebhookRepository +{ + /// + /// Gets all of the webhooks in the current database. + /// + /// Number of entries to skip. + /// Number of entries to take. + /// A paged model of objects. + Task> GetAllAsync(int skip, int take); + + /// + /// Gets all of the webhooks in the current database. + /// + /// The webhook you want to create. + /// The created webhook + Task CreateAsync(Webhook webhook); + + /// + /// Gets a webhook by key + /// + /// The key of the webhook which will be retrieved. + /// The webhook with the given key. + Task GetAsync(Guid key); + + /// + /// Gets a webhook by key + /// + /// The key of the webhook which will be retrieved. + /// The webhook with the given key. + Task> GetByEventNameAsync(string eventName); + + /// + /// Gets a webhook by key + /// + /// The webhook to be deleted. + /// A representing the asynchronous operation. + Task DeleteAsync(Webhook webhook); + + /// + /// Updates a given webhook + /// + /// The webhook to be updated. + /// The updated webhook. + Task UpdateAsync(Webhook webhook); +} diff --git a/src/Umbraco.Core/Services/IWebHookService.cs b/src/Umbraco.Core/Services/IWebHookService.cs new file mode 100644 index 0000000000..e63f07bf11 --- /dev/null +++ b/src/Umbraco.Core/Services/IWebHookService.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +public interface IWebHookService +{ + Task CreateAsync(Webhook webhook); + + Task UpdateAsync(Webhook webhook); + + Task DeleteAsync(Guid key); + + Task GetAsync(Guid key); + + Task> GetAllAsync(int skip, int take); + + Task> GetByEventNameAsync(string eventName); +} diff --git a/src/Umbraco.Core/Services/IWebhookFiringService.cs b/src/Umbraco.Core/Services/IWebhookFiringService.cs new file mode 100644 index 0000000000..0482290c3d --- /dev/null +++ b/src/Umbraco.Core/Services/IWebhookFiringService.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +public interface IWebhookFiringService +{ + Task FireAsync(Webhook webhook, string eventName, object? payload, CancellationToken cancellationToken); +} diff --git a/src/Umbraco.Core/Services/IWebhookLogFactory.cs b/src/Umbraco.Core/Services/IWebhookLogFactory.cs new file mode 100644 index 0000000000..fa600dda82 --- /dev/null +++ b/src/Umbraco.Core/Services/IWebhookLogFactory.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Webhooks; + +namespace Umbraco.Cms.Core.Services; + +public interface IWebhookLogFactory +{ + Task CreateAsync(string eventName, WebhookResponseModel responseModel, Webhook webhook, CancellationToken cancellationToken); +} diff --git a/src/Umbraco.Core/Services/IWebhookLogService.cs b/src/Umbraco.Core/Services/IWebhookLogService.cs new file mode 100644 index 0000000000..12b53bfa76 --- /dev/null +++ b/src/Umbraco.Core/Services/IWebhookLogService.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Webhooks; + +namespace Umbraco.Cms.Core.Services; + +public interface IWebhookLogService +{ + Task CreateAsync(WebhookLog webhookLog); + + Task> Get(int skip = 0, int take = int.MaxValue); +} diff --git a/src/Umbraco.Core/Services/WebhookLogFactory.cs b/src/Umbraco.Core/Services/WebhookLogFactory.cs new file mode 100644 index 0000000000..22dd75fe84 --- /dev/null +++ b/src/Umbraco.Core/Services/WebhookLogFactory.cs @@ -0,0 +1,31 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Webhooks; + +namespace Umbraco.Cms.Core.Services; + +public class WebhookLogFactory : IWebhookLogFactory +{ + public async Task CreateAsync(string eventName, WebhookResponseModel responseModel, Webhook webhook, CancellationToken cancellationToken) + { + var log = new WebhookLog + { + Date = DateTime.UtcNow, + EventName = eventName, + Key = Guid.NewGuid(), + Url = webhook.Url, + WebhookKey = webhook.Key, + }; + + if (responseModel.HttpResponseMessage is not null) + { + log.RequestBody = await responseModel.HttpResponseMessage!.RequestMessage!.Content!.ReadAsStringAsync(cancellationToken); + log.ResponseBody = await responseModel.HttpResponseMessage.Content.ReadAsStringAsync(cancellationToken); + log.StatusCode = responseModel.HttpResponseMessage.StatusCode.ToString(); + log.RetryCount = responseModel.RetryCount; + log.ResponseHeaders = responseModel.HttpResponseMessage.Headers.ToString(); + log.RequestHeaders = responseModel.HttpResponseMessage.RequestMessage.Headers.ToString(); + } + + return log; + } +} diff --git a/src/Umbraco.Core/Services/WebhookLogService.cs b/src/Umbraco.Core/Services/WebhookLogService.cs new file mode 100644 index 0000000000..3b0bbebf19 --- /dev/null +++ b/src/Umbraco.Core/Services/WebhookLogService.cs @@ -0,0 +1,33 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Webhooks; + +namespace Umbraco.Cms.Core.Services; + +public class WebhookLogService : IWebhookLogService +{ + private readonly IWebhookLogRepository _webhookLogRepository; + private readonly ICoreScopeProvider _coreScopeProvider; + + public WebhookLogService(IWebhookLogRepository webhookLogRepository, ICoreScopeProvider coreScopeProvider) + { + _webhookLogRepository = webhookLogRepository; + _coreScopeProvider = coreScopeProvider; + } + + public async Task CreateAsync(WebhookLog webhookLog) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + await _webhookLogRepository.CreateAsync(webhookLog); + scope.Complete(); + + return webhookLog; + } + + public async Task> Get(int skip = 0, int take = int.MaxValue) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(autoComplete: true); + return await _webhookLogRepository.GetPagedAsync(skip, take); + } +} diff --git a/src/Umbraco.Core/Services/WebhookService.cs b/src/Umbraco.Core/Services/WebhookService.cs new file mode 100644 index 0000000000..5ccda00a0b --- /dev/null +++ b/src/Umbraco.Core/Services/WebhookService.cs @@ -0,0 +1,85 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services; + +public class WebhookService : IWebHookService +{ + private readonly ICoreScopeProvider _provider; + private readonly IWebhookRepository _webhookRepository; + + public WebhookService(ICoreScopeProvider provider, IWebhookRepository webhookRepository) + { + _provider = provider; + _webhookRepository = webhookRepository; + } + + public async Task CreateAsync(Webhook webhook) + { + using ICoreScope scope = _provider.CreateCoreScope(); + Webhook created = await _webhookRepository.CreateAsync(webhook); + scope.Complete(); + + return created; + } + + public async Task UpdateAsync(Webhook webhook) + { + using ICoreScope scope = _provider.CreateCoreScope(); + + Webhook? currentWebhook = await _webhookRepository.GetAsync(webhook.Key); + + if (currentWebhook is null) + { + throw new ArgumentException("Webhook does not exist"); + } + + currentWebhook.Enabled = webhook.Enabled; + currentWebhook.ContentTypeKeys = webhook.ContentTypeKeys; + currentWebhook.Events = webhook.Events; + currentWebhook.Url = webhook.Url; + currentWebhook.Headers = webhook.Headers; + + await _webhookRepository.UpdateAsync(currentWebhook); + scope.Complete(); + } + + public async Task DeleteAsync(Guid key) + { + using ICoreScope scope = _provider.CreateCoreScope(); + Webhook? webhook = await _webhookRepository.GetAsync(key); + if (webhook is not null) + { + await _webhookRepository.DeleteAsync(webhook); + } + + scope.Complete(); + } + + public async Task GetAsync(Guid key) + { + using ICoreScope scope = _provider.CreateCoreScope(); + Webhook? webhook = await _webhookRepository.GetAsync(key); + scope.Complete(); + return webhook; + } + + public async Task> GetAllAsync(int skip, int take) + { + using ICoreScope scope = _provider.CreateCoreScope(); + PagedModel webhooks = await _webhookRepository.GetAllAsync(skip, take); + scope.Complete(); + + return webhooks; + } + + public async Task> GetByEventNameAsync(string eventName) + { + using ICoreScope scope = _provider.CreateCoreScope(); + PagedModel webhooks = await _webhookRepository.GetByEventNameAsync(eventName); + scope.Complete(); + + return webhooks.Items; + } +} diff --git a/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs new file mode 100644 index 0000000000..629f47539a --- /dev/null +++ b/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Webhooks.Events; + +public class ContentDeleteWebhookEvent : WebhookEventBase +{ + public ContentDeleteWebhookEvent( + IWebhookFiringService webhookFiringService, + IWebHookService webHookService, + IOptionsMonitor webhookSettings, + IServerRoleAccessor serverRoleAccessor) + : base( + webhookFiringService, + webHookService, + webhookSettings, + serverRoleAccessor, + Constants.WebhookEvents.ContentDelete) + { + } + + protected override IEnumerable GetEntitiesFromNotification(ContentDeletedNotification notification) => + notification.DeletedEntities; + + protected override object ConvertEntityToRequestPayload(IContent entity) => new DefaultPayloadModel { Id = entity.Key }; +} diff --git a/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs new file mode 100644 index 0000000000..4c75516420 --- /dev/null +++ b/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Webhooks.Events; + +public class ContentPublishWebhookEvent : WebhookEventBase +{ + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IApiContentBuilder _apiContentBuilder; + + public ContentPublishWebhookEvent( + IWebhookFiringService webhookFiringService, + IWebHookService webHookService, + IOptionsMonitor webhookSettings, + IServerRoleAccessor serverRoleAccessor, + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IApiContentBuilder apiContentBuilder) + : base( + webhookFiringService, + webHookService, + webhookSettings, + serverRoleAccessor, + Constants.WebhookEvents.ContentPublish) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _apiContentBuilder = apiContentBuilder; + } + + protected override IEnumerable GetEntitiesFromNotification(ContentPublishedNotification notification) => notification.PublishedEntities; + + protected override object? ConvertEntityToRequestPayload(IContent entity) + { + if (_publishedSnapshotAccessor.TryGetPublishedSnapshot(out IPublishedSnapshot? publishedSnapshot) is false || publishedSnapshot!.Content is null) + { + return null; + } + + IPublishedContent? publishedContent = publishedSnapshot.Content.GetById(entity.Key); + return publishedContent is null ? null : _apiContentBuilder.Build(publishedContent); + } +} diff --git a/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs new file mode 100644 index 0000000000..6c8fdf3598 --- /dev/null +++ b/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Webhooks.Events; + +public class ContentUnpublishWebhookEvent : WebhookEventBase +{ + public ContentUnpublishWebhookEvent( + IWebhookFiringService webhookFiringService, + IWebHookService webHookService, + IOptionsMonitor webhookSettings, + IServerRoleAccessor serverRoleAccessor) + : base( + webhookFiringService, + webHookService, + webhookSettings, + serverRoleAccessor, + Constants.WebhookEvents.ContentUnpublish) + { + } + + protected override IEnumerable GetEntitiesFromNotification(ContentUnpublishedNotification notification) => notification.UnpublishedEntities; + + protected override object ConvertEntityToRequestPayload(IContent entity) => new DefaultPayloadModel { Id = entity.Key }; +} diff --git a/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs new file mode 100644 index 0000000000..51e1337f7d --- /dev/null +++ b/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Webhooks.Events; + +public class MediaDeleteWebhookEvent : WebhookEventBase +{ + public MediaDeleteWebhookEvent( + IWebhookFiringService webhookFiringService, + IWebHookService webHookService, + IOptionsMonitor webhookSettings, + IServerRoleAccessor serverRoleAccessor) + : base( + webhookFiringService, + webHookService, + webhookSettings, + serverRoleAccessor, + Constants.WebhookEvents.MediaDelete) + { + } + + protected override IEnumerable GetEntitiesFromNotification(MediaDeletedNotification notification) => notification.DeletedEntities; + + protected override object ConvertEntityToRequestPayload(IMedia entity) => new DefaultPayloadModel { Id = entity.Key }; +} diff --git a/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs new file mode 100644 index 0000000000..d5a4dc57c5 --- /dev/null +++ b/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Webhooks.Events; + +public class MediaSaveWebhookEvent : WebhookEventBase +{ + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IApiMediaBuilder _apiMediaBuilder; + + public MediaSaveWebhookEvent( + IWebhookFiringService webhookFiringService, + IWebHookService webHookService, + IOptionsMonitor webhookSettings, + IServerRoleAccessor serverRoleAccessor, + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IApiMediaBuilder apiMediaBuilder) + : base( + webhookFiringService, + webHookService, + webhookSettings, + serverRoleAccessor, + Constants.WebhookEvents.MediaSave) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _apiMediaBuilder = apiMediaBuilder; + } + + protected override IEnumerable GetEntitiesFromNotification(MediaSavedNotification notification) => notification.SavedEntities; + + protected override object? ConvertEntityToRequestPayload(IMedia entity) + { + if (_publishedSnapshotAccessor.TryGetPublishedSnapshot(out IPublishedSnapshot? publishedSnapshot) is false || publishedSnapshot!.Content is null) + { + return null; + } + + IPublishedContent? publishedContent = publishedSnapshot.Media?.GetById(entity.Key); + return publishedContent is null ? null : _apiMediaBuilder.Build(publishedContent); + } +} diff --git a/src/Umbraco.Core/Webhooks/IWebhookEvent.cs b/src/Umbraco.Core/Webhooks/IWebhookEvent.cs new file mode 100644 index 0000000000..85857c1aec --- /dev/null +++ b/src/Umbraco.Core/Webhooks/IWebhookEvent.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Webhooks; + +public interface IWebhookEvent +{ + string EventName { get; set; } +} diff --git a/src/Umbraco.Core/Webhooks/WebhookEventBase.cs b/src/Umbraco.Core/Webhooks/WebhookEventBase.cs new file mode 100644 index 0000000000..01384ea43f --- /dev/null +++ b/src/Umbraco.Core/Webhooks/WebhookEventBase.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Webhooks; + +public abstract class WebhookEventBase : IWebhookEvent, INotificationAsyncHandler + where TNotification : INotification + where TEntity : IContentBase +{ + private readonly IWebhookFiringService _webhookFiringService; + private readonly IWebHookService _webHookService; + private readonly IServerRoleAccessor _serverRoleAccessor; + private WebhookSettings _webhookSettings; + + protected WebhookEventBase( + IWebhookFiringService webhookFiringService, + IWebHookService webHookService, + IOptionsMonitor webhookSettings, + IServerRoleAccessor serverRoleAccessor, + string eventName) + { + _webhookFiringService = webhookFiringService; + _webHookService = webHookService; + _serverRoleAccessor = serverRoleAccessor; + EventName = eventName; + _webhookSettings = webhookSettings.CurrentValue; + webhookSettings.OnChange(x => _webhookSettings = x); + } + + public string EventName { get; set; } + + public virtual async Task HandleAsync(TNotification notification, CancellationToken cancellationToken) + { + if (_serverRoleAccessor.CurrentServerRole is not ServerRole.Single && _serverRoleAccessor.CurrentServerRole is not ServerRole.SchedulingPublisher) + { + return; + } + + if (_webhookSettings.Enabled is false) + { + return; + } + + IEnumerable webhooks = await _webHookService.GetByEventNameAsync(EventName); + + foreach (Webhook webhook in webhooks) + { + if (!webhook.Enabled) + { + continue; + } + + foreach (TEntity entity in GetEntitiesFromNotification(notification)) + { + if (webhook.ContentTypeKeys.Any() && !webhook.ContentTypeKeys.Contains(entity.ContentType.Key)) + { + continue; + } + + await _webhookFiringService.FireAsync(webhook, EventName, ConvertEntityToRequestPayload(entity), cancellationToken); + } + } + } + + protected abstract IEnumerable GetEntitiesFromNotification(TNotification notification); + + protected abstract object? ConvertEntityToRequestPayload(TEntity entity); +} diff --git a/src/Umbraco.Core/Webhooks/WebhookEventCollection.cs b/src/Umbraco.Core/Webhooks/WebhookEventCollection.cs new file mode 100644 index 0000000000..cf939f93ae --- /dev/null +++ b/src/Umbraco.Core/Webhooks/WebhookEventCollection.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.Webhooks; + +public class WebhookEventCollection : BuilderCollectionBase +{ + public WebhookEventCollection(Func> items) : base(items) + { + } +} diff --git a/src/Umbraco.Core/Webhooks/WebhookEventCollectionBuilder.cs b/src/Umbraco.Core/Webhooks/WebhookEventCollectionBuilder.cs new file mode 100644 index 0000000000..e0eeb186e8 --- /dev/null +++ b/src/Umbraco.Core/Webhooks/WebhookEventCollectionBuilder.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Webhooks.Events; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Webhooks; + +public class WebhookEventCollectionBuilder : OrderedCollectionBuilderBase +{ + protected override WebhookEventCollectionBuilder This => this; + + public override void RegisterWith(IServiceCollection services) + { + // register the collection + services.Add(new ServiceDescriptor(typeof(WebhookEventCollection), CreateCollection, ServiceLifetime.Singleton)); + + // register the types + RegisterTypes(services); + base.RegisterWith(services); + } + + public WebhookEventCollectionBuilder AddCoreWebhooks() + { + Append(); + Append(); + Append(); + Append(); + Append(); + return this; + } + + private void RegisterTypes(IServiceCollection services) + { + Type[] types = GetRegisteringTypes(GetTypes()).ToArray(); + + // ensure they are safe + foreach (Type type in types) + { + EnsureType(type, "register"); + } + + foreach (Type type in types) + { + Type? notificationType = GetNotificationType(type); + + if (notificationType is null) + { + continue; + } + + var descriptor = new ServiceDescriptor( + typeof(INotificationAsyncHandler<>).MakeGenericType(notificationType), + type, + ServiceLifetime.Transient); + + if (!services.Contains(descriptor)) + { + services.Add(descriptor); + } + } + } + + private Type? GetNotificationType(Type handlerType) + { + if (handlerType.IsOfGenericType(typeof(INotificationAsyncHandler<>))) + { + Type[] genericArguments = handlerType.BaseType!.GetGenericArguments(); + + Type? notificationType = genericArguments.FirstOrDefault(arg => typeof(INotification).IsAssignableFrom(arg)); + + if (notificationType is not null) + { + return notificationType; + } + } + + return null; + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index d6531ca6e4..3e12664f03 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -38,6 +38,7 @@ using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Core.Trees; using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Core.Webhooks; using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Infrastructure.DistributedLocking; using Umbraco.Cms.Infrastructure.Examine; @@ -225,6 +226,7 @@ public static partial class UmbracoBuilderExtensions builder.AddPropertyIndexValueFactories(); builder.AddDeliveryApiCoreServices(); + builder.Services.AddTransient(); return builder; } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 3afb9fe64a..df2ac91839 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -66,8 +66,10 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); - builder.Services.AddUnique(); - builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index d422ea1445..9a944e4a90 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -82,7 +82,12 @@ public class DatabaseSchemaCreator typeof(ContentVersionCleanupPolicyDto), typeof(UserGroup2NodeDto), typeof(CreatedPackageSchemaDto), - typeof(UserGroup2LanguageDto) + typeof(UserGroup2LanguageDto), + typeof(WebhookDto), + typeof(Webhook2ContentTypeKeysDto), + typeof(Webhook2EventsDto), + typeof(Webhook2HeadersDto), + typeof(WebhookLogDto), }; private readonly IUmbracoDatabase _database; diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 2844d53d80..306f7869f7 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -94,5 +94,8 @@ public class UmbracoPlan : MigrationPlan // And once more for 12 To("{2D4C9FBD-08B3-472D-A76C-6ED467A0CD20}"); + + // To 13.0.0 + To("{C76D9C9A-635B-4D2C-A301-05642A523E9D}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhooks.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhooks.cs new file mode 100644 index 0000000000..e8026ea34d --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhooks.cs @@ -0,0 +1,41 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0; + +public class AddWebhooks : MigrationBase +{ + public AddWebhooks(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + IEnumerable tables = SqlSyntax.GetTablesInSchema(Context.Database).ToArray(); + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.Webhook) is false) + { + Create.Table().Do(); + } + + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.Webhook2Events) is false) + { + Create.Table().Do(); + } + + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.Webhook2ContentTypeKeys) is false) + { + Create.Table().Do(); + } + + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.Webhook2Headers) is false) + { + Create.Table().Do(); + } + + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.WebhookLog) is false) + { + Create.Table().Do(); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/Webhook2ContentTypeKeysDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/Webhook2ContentTypeKeysDto.cs new file mode 100644 index 0000000000..71bbed5962 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/Webhook2ContentTypeKeysDto.cs @@ -0,0 +1,20 @@ +using System.Data; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + + +[TableName(Constants.DatabaseSchema.Tables.Webhook2ContentTypeKeys)] +[ExplicitColumns] +public class Webhook2ContentTypeKeysDto +{ + [Column("webhookId")] + [PrimaryKeyColumn(AutoIncrement = false, Name = "PK_webhookEntityKey2Webhook", OnColumns = "webhookId, entityKey")] + [ForeignKey(typeof(WebhookDto), OnDelete = Rule.Cascade)] + public int WebhookId { get; set; } + + [Column("entityKey")] + public Guid ContentTypeKey { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/Webhook2EventsDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/Webhook2EventsDto.cs new file mode 100644 index 0000000000..0278d22945 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/Webhook2EventsDto.cs @@ -0,0 +1,18 @@ +using System.Data; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(Constants.DatabaseSchema.Tables.Webhook2Events)] +public class Webhook2EventsDto +{ + [Column("webhookId")] + [PrimaryKeyColumn(AutoIncrement = false, Name = "PK_webhookEvent2WebhookDto", OnColumns = "webhookId, event")] + [ForeignKey(typeof(WebhookDto), OnDelete = Rule.Cascade)] + public int WebhookId { get; set; } + + [Column("event")] + public string Event { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/Webhook2HeadersDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/Webhook2HeadersDto.cs new file mode 100644 index 0000000000..80a7724109 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/Webhook2HeadersDto.cs @@ -0,0 +1,21 @@ +using System.Data; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(Constants.DatabaseSchema.Tables.Webhook2Headers)] +public class Webhook2HeadersDto +{ + [Column("webhookId")] + [PrimaryKeyColumn(AutoIncrement = false, Name = "PK_heaeders2WebhookDto", OnColumns = "webhookId, key")] + [ForeignKey(typeof(WebhookDto), OnDelete = Rule.Cascade)] + public int WebhookId { get; set; } + + [Column("Key")] + public string Key { get; set; } = string.Empty; + + [Column("Value")] + public string Value { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookDto.cs new file mode 100644 index 0000000000..abcf160b03 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookDto.cs @@ -0,0 +1,40 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + + +[TableName(Constants.DatabaseSchema.Tables.Webhook)] +[PrimaryKey("id")] +[ExplicitColumns] +internal class WebhookDto +{ + [Column("id")] + [PrimaryKeyColumn(AutoIncrement = true)] + public int Id { get; set; } + + [Column(Name = "key")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public Guid Key { get; set; } + + [Column(Name = "url")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string Url { get; set; } = string.Empty; + + [Column(Name = "enabled")] + public bool Enabled { get; set; } + + [ResultColumn] + [Reference(ReferenceType.Many, ReferenceMemberName = nameof(Webhook2EventsDto.WebhookId))] + public List Webhook2Events { get; set; } = new(); + + [ResultColumn] + [Reference(ReferenceType.Many, ReferenceMemberName = nameof(Webhook2ContentTypeKeysDto.WebhookId))] + public List Webhook2ContentTypeKeys { get; set; } = new(); + + [ResultColumn] + [Reference(ReferenceType.Many, ReferenceMemberName = nameof(Webhook2HeadersDto.WebhookId))] + public List Webhook2Headers { get; set; } = new(); +} + diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs new file mode 100644 index 0000000000..a8606c7391 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs @@ -0,0 +1,59 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(Constants.DatabaseSchema.Tables.WebhookLog)] +[PrimaryKey("id")] +[ExplicitColumns] +internal class WebhookLogDto +{ + [Column("id")] + [PrimaryKeyColumn(AutoIncrement = true)] + public int Id { get; set; } + + [Column("webhookId")] + public Guid WebhookKey { get; set; } + + [Column(Name = "key")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public Guid Key { get; set; } + + [Column(Name = "statusCode")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string StatusCode { get; set; } = string.Empty; + + [Column(Name = "date")] + [Index(IndexTypes.NonClustered, Name = "IX_" + Constants.DatabaseSchema.Tables.WebhookLog + "_date")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public DateTime Date { get; set; } + + [Column(Name = "url")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string Url { get; set; } = string.Empty; + + [Column(Name = "eventName")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string EventName { get; set; } = string.Empty; + + [Column(Name = "retryCount")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public int RetryCount { get; set; } + + [Column(Name = "requestHeaders")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string RequestHeaders { get; set; } = string.Empty; + + [Column(Name = "requestBody")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string RequestBody { get; set; } = string.Empty; + + [Column(Name = "responseHeaders")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string ResponseHeaders { get; set; } = string.Empty; + + [Column(Name = "responseBody")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public string ResponseBody { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/WebhookFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookFactory.cs new file mode 100644 index 0000000000..9e6328501f --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookFactory.cs @@ -0,0 +1,58 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Persistence.Factories; + +internal static class WebhookFactory +{ + public static Webhook BuildEntity(WebhookDto dto, IEnumerable? entityKey2WebhookDtos = null, IEnumerable? event2WebhookDtos = null, IEnumerable? headersWebhookDtos = null) + { + var entity = new Webhook( + dto.Url, + dto.Enabled, + entityKey2WebhookDtos?.Select(x => x.ContentTypeKey).ToArray(), + event2WebhookDtos?.Select(x => x.Event).ToArray(), + headersWebhookDtos?.ToDictionary(x => x.Key, x => x.Value)) + { + Id = dto.Id, + Key = dto.Key, + }; + + return entity; + } + + public static WebhookDto BuildDto(Webhook webhook) + { + var dto = new WebhookDto + { + Url = webhook.Url, + Key = webhook.Key, + Enabled = webhook.Enabled, + Id = webhook.Id, + }; + + return dto; + } + + public static IEnumerable BuildEntityKey2WebhookDto(Webhook webhook) => + webhook.ContentTypeKeys.Select(x => new Webhook2ContentTypeKeysDto + { + ContentTypeKey = x, + WebhookId = webhook.Id, + }); + + public static IEnumerable BuildEvent2WebhookDto(Webhook webhook) => + webhook.Events.Select(x => new Webhook2EventsDto + { + Event = x, + WebhookId = webhook.Id, + }); + + public static IEnumerable BuildHeaders2WebhookDtos(Webhook webhook) => + webhook.Headers.Select(x => new Webhook2HeadersDto + { + Key = x.Key, + Value = x.Value, + WebhookId = webhook.Id, + }); +} diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs new file mode 100644 index 0000000000..2cc6d5d55b --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs @@ -0,0 +1,42 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Webhooks; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Persistence.Factories; + +internal static class WebhookLogFactory +{ + public static WebhookLogDto CreateDto(WebhookLog log) => + new() + { + Date = log.Date, + EventName = log.EventName, + RequestBody = log.RequestBody ?? string.Empty, + ResponseBody = log.ResponseBody, + RetryCount = log.RetryCount, + StatusCode = log.StatusCode, + Key = log.Key, + Id = log.Id, + Url = log.Url, + RequestHeaders = log.RequestHeaders, + ResponseHeaders = log.ResponseHeaders, + WebhookKey = log.WebhookKey, + }; + + public static WebhookLog DtoToEntity(WebhookLogDto dto) => + new() + { + Date = dto.Date, + EventName = dto.EventName, + RequestBody = dto.RequestBody, + ResponseBody = dto.ResponseBody, + RetryCount = dto.RetryCount, + StatusCode = dto.StatusCode, + Key = dto.Key, + Id = dto.Id, + Url = dto.Url, + RequestHeaders = dto.RequestHeaders, + ResponseHeaders = dto.ResponseHeaders, + WebhookKey = dto.WebhookKey, + }; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookLogRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookLogRepository.cs new file mode 100644 index 0000000000..910f1178d4 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookLogRepository.cs @@ -0,0 +1,44 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Webhooks; +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 WebhookLogRepository : IWebhookLogRepository +{ + private readonly IScopeAccessor _scopeAccessor; + + public WebhookLogRepository(IScopeAccessor scopeAccessor) => _scopeAccessor = scopeAccessor; + + public async Task CreateAsync(WebhookLog log) + { + WebhookLogDto dto = WebhookLogFactory.CreateDto(log); + var result = await _scopeAccessor.AmbientScope?.Database.InsertAsync(dto)!; + var id = Convert.ToInt32(result); + log.Id = id; + } + + public async Task> GetPagedAsync(int skip, int take) + { + Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() + .Select() + .From() + .OrderByDescending(x => x.Date); + + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + + Page? page = await _scopeAccessor.AmbientScope?.Database.PageAsync(pageNumber + 1, pageSize, sql)!; + + return new PagedModel + { + Total = page.TotalItems, + Items = page.Items.Select(WebhookLogFactory.DtoToEntity), + }; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRepository.cs new file mode 100644 index 0000000000..af6d651e44 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRepository.cs @@ -0,0 +1,136 @@ +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 WebhookRepository : IWebhookRepository +{ + private readonly IScopeAccessor _scopeAccessor; + + public WebhookRepository(IScopeAccessor scopeAccessor) => _scopeAccessor = scopeAccessor; + + public async Task> GetAllAsync(int skip, int take) + { + Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() + .Select() + .From(); + + List? webhookDtos = await _scopeAccessor.AmbientScope?.Database.FetchAsync(sql)!; + + return new PagedModel + { + Items = await DtosToEntities(webhookDtos.Skip(skip).Take(take)), + Total = webhookDtos.Count, + }; + } + + public async Task CreateAsync(Webhook webhook) + { + WebhookDto webhookDto = WebhookFactory.BuildDto(webhook); + + var result = await _scopeAccessor.AmbientScope?.Database.InsertAsync(webhookDto)!; + + var id = Convert.ToInt32(result); + webhook.Id = id; + + await _scopeAccessor.AmbientScope?.Database.InsertBulkAsync(WebhookFactory.BuildEvent2WebhookDto(webhook))!; + await _scopeAccessor.AmbientScope?.Database.InsertBulkAsync(WebhookFactory.BuildEntityKey2WebhookDto(webhook))!; + await _scopeAccessor.AmbientScope?.Database.InsertBulkAsync(WebhookFactory.BuildHeaders2WebhookDtos(webhook))!; + + return webhook; + } + + public async Task GetAsync(Guid key) + { + Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() + .Select() + .From() + .Where(x => x.Key == key); + + WebhookDto? webhookDto = await _scopeAccessor.AmbientScope?.Database.FirstOrDefaultAsync(sql)!; + + return webhookDto is null ? null : await DtoToEntity(webhookDto); + } + + public async Task> GetByEventNameAsync(string eventName) + { + Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() + .SelectAll() + .From() + .InnerJoin() + .On(left => left.Id, right => right.WebhookId) + .Where(x => x.Event == eventName); + + List? webhookDtos = await _scopeAccessor.AmbientScope?.Database.FetchAsync(sql)!; + + return new PagedModel + { + Items = await DtosToEntities(webhookDtos), + Total = webhookDtos.Count, + }; + } + + public async Task DeleteAsync(Webhook webhook) + { + Sql sql = _scopeAccessor.AmbientScope!.Database.SqlContext.Sql() + .Delete() + .Where(x => x.Key == webhook.Key); + + await _scopeAccessor.AmbientScope?.Database.ExecuteAsync(sql)!; + } + + public async Task UpdateAsync(Webhook webhook) + { + WebhookDto dto = WebhookFactory.BuildDto(webhook); + await _scopeAccessor.AmbientScope?.Database.UpdateAsync(dto)!; + + // Delete and re-insert the many to one references (event & entity keys) + DeleteManyToOneReferences(dto.Id); + InsertManyToOneReferences(webhook); + } + + private void DeleteManyToOneReferences(int webhookId) + { + _scopeAccessor.AmbientScope?.Database.Delete("WHERE webhookId = @webhookId", new { webhookId }); + _scopeAccessor.AmbientScope?.Database.Delete("WHERE webhookId = @webhookId", new { webhookId }); + _scopeAccessor.AmbientScope?.Database.Delete("WHERE webhookId = @webhookId", new { webhookId }); + } + + private void InsertManyToOneReferences(Webhook webhook) + { + IEnumerable buildEntityKey2WebhookDtos = WebhookFactory.BuildEntityKey2WebhookDto(webhook); + IEnumerable buildEvent2WebhookDtos = WebhookFactory.BuildEvent2WebhookDto(webhook); + IEnumerable header2WebhookDtos = WebhookFactory.BuildHeaders2WebhookDtos(webhook); + + _scopeAccessor.AmbientScope?.Database.InsertBulkAsync(buildEntityKey2WebhookDtos); + _scopeAccessor.AmbientScope?.Database.InsertBulkAsync(buildEvent2WebhookDtos); + _scopeAccessor.AmbientScope?.Database.InsertBulkAsync(header2WebhookDtos); + } + + private async Task> DtosToEntities(IEnumerable dtos) + { + List result = new(); + + foreach (WebhookDto webhook in dtos) + { + result.Add(await DtoToEntity(webhook)); + } + + return result; + } + + private async Task DtoToEntity(WebhookDto dto) + { + List? webhookEntityKeyDtos = await _scopeAccessor.AmbientScope?.Database.FetchAsync("WHERE webhookId = @webhookId", new { webhookId = dto.Id })!; + List? event2WebhookDtos = await _scopeAccessor.AmbientScope?.Database.FetchAsync("WHERE webhookId = @webhookId", new { webhookId = dto.Id })!; + List? headersWebhookDtos = await _scopeAccessor.AmbientScope?.Database.FetchAsync("WHERE webhookId = @webhookId", new { webhookId = dto.Id })!; + Webhook entity = WebhookFactory.BuildEntity(dto, webhookEntityKeyDtos, event2WebhookDtos, headersWebhookDtos); + + return entity; + } +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs b/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs new file mode 100644 index 0000000000..cf09c0d3a2 --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs @@ -0,0 +1,74 @@ +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.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; + + public WebhookFiringService( + IJsonSerializer jsonSerializer, + IOptions webhookSettings, + IWebhookLogService webhookLogService, + IWebhookLogFactory webhookLogFactory) + { + _jsonSerializer = jsonSerializer; + _webhookLogService = webhookLogService; + _webhookLogFactory = webhookLogFactory; + _webhookSettings = webhookSettings.Value; + } + + // TODO: Add queing instead of processing directly in thread + // as this just makes save and publish longer + public async Task FireAsync(Webhook webhook, string eventName, object? payload, CancellationToken cancellationToken) + { + for (var retry = 0; retry < _webhookSettings.MaximumRetries; retry++) + { + HttpResponseMessage response = await SendRequestAsync(webhook, eventName, payload, retry, cancellationToken); + + if (response.IsSuccessStatusCode) + { + return; + } + } + } + + 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 = await httpClient.PostAsync(webhook.Url, stringContent, cancellationToken); + + 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.Web.BackOffice/Controllers/BackOfficeServerVariables.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs index 6dbd5f1e79..cc517ba178 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs @@ -585,6 +585,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers "mediaPickerThreeBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( controller => controller.UploadMedia(null!)) }, + { + "webhooksApiBaseUrl", _linkGenerator.GetUmbracoApiServiceBaseUrl( + controller => controller.GetAll(0, 0)) + }, } }, { diff --git a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs new file mode 100644 index 0000000000..9be5372ce5 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Webhooks; +using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Models; + +namespace Umbraco.Cms.Web.BackOffice.Controllers; + +[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +public class WebhookController : UmbracoAuthorizedJsonController +{ + private readonly IWebHookService _webHookService; + private readonly IUmbracoMapper _umbracoMapper; + private readonly WebhookEventCollection _webhookEventCollection; + private readonly IWebhookLogService _webhookLogService; + + public WebhookController(IWebHookService webHookService, IUmbracoMapper umbracoMapper, WebhookEventCollection webhookEventCollection, IWebhookLogService webhookLogService) + { + _webHookService = webHookService; + _umbracoMapper = umbracoMapper; + _webhookEventCollection = webhookEventCollection; + _webhookLogService = webhookLogService; + } + + [HttpGet] + public async Task GetAll(int skip = 0, int take = int.MaxValue) + { + PagedModel webhooks = await _webHookService.GetAllAsync(skip, take); + + List webhookViewModels = _umbracoMapper.MapEnumerable(webhooks.Items); + + return Ok(webhookViewModels); + } + + [HttpPut] + public async Task Update(WebhookViewModel webhookViewModel) + { + Webhook updateModel = _umbracoMapper.Map(webhookViewModel)!; + + await _webHookService.UpdateAsync(updateModel); + + return Ok(); + } + + [HttpPost] + public async Task Create(WebhookViewModel webhookViewModel) + { + Webhook webhook = _umbracoMapper.Map(webhookViewModel)!; + await _webHookService.CreateAsync(webhook); + + return Ok(); + } + + [HttpGet] + public async Task GetByKey(Guid key) + { + Webhook? webhook = await _webHookService.GetAsync(key); + + return webhook is null ? NotFound() : Ok(webhook); + } + + [HttpDelete] + public async Task Delete(Guid key) + { + await _webHookService.DeleteAsync(key); + + return Ok(); + } + + [HttpGet] + public IActionResult GetEvents() + { + List viewModels = _umbracoMapper.MapEnumerable(_webhookEventCollection.AsEnumerable()); + return Ok(viewModels); + } + + [HttpGet] + public async Task GetLogs(int skip = 0, int take = int.MaxValue) + { + PagedModel logs = await _webhookLogService.Get(skip, take); + List mappedLogs = _umbracoMapper.MapEnumerable(logs.Items); + return Ok(new PagedResult(logs.Total, 0, 0) + { + Items = mappedLogs, + }); + } +} diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs index 994493e761..c973963495 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs @@ -4,15 +4,16 @@ using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.DependencyInjection; using Umbraco.Cms.Infrastructure.Examine.DependencyInjection; -using Umbraco.Cms.Infrastructure.Templates.PartialViews; using Umbraco.Cms.Infrastructure.WebAssets; using Umbraco.Cms.Web.BackOffice.Controllers; using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.BackOffice.Install; +using Umbraco.Cms.Web.BackOffice.Mapping; using Umbraco.Cms.Web.BackOffice.Middleware; using Umbraco.Cms.Web.BackOffice.ModelsBuilder; using Umbraco.Cms.Web.BackOffice.Routing; @@ -91,6 +92,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.WithCollectionBuilder().Add(); // register back office trees // the collection builder only accepts types inheriting from TreeControllerBase diff --git a/src/Umbraco.Web.BackOffice/Mapping/WebhookMapDefinition.cs b/src/Umbraco.Web.BackOffice/Mapping/WebhookMapDefinition.cs new file mode 100644 index 0000000000..c797ce67ee --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Mapping/WebhookMapDefinition.cs @@ -0,0 +1,58 @@ +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Webhooks; +using Umbraco.Cms.Web.Common.Models; + +namespace Umbraco.Cms.Web.BackOffice.Mapping; + +public class WebhookMapDefinition : IMapDefinition +{ + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define((_, _) => new Webhook(string.Empty), Map); + mapper.Define((_, _) => new WebhookViewModel(), Map); + mapper.Define((_, _) => new WebhookEventViewModel(), Map); + mapper.Define((_, _) => new WebhookLogViewModel(), Map); + } + + // Umbraco.Code.MapAll -CreateDate -DeleteDate -Id -Key -UpdateDate + private void Map(WebhookViewModel source, Webhook target, MapperContext context) + { + target.ContentTypeKeys = source.ContentTypeKeys; + target.Events = source.Events; + target.Url = source.Url; + target.Enabled = source.Enabled; + target.Key = source.Key ?? Guid.NewGuid(); + target.Headers = source.Headers; + } + + // Umbraco.Code.MapAll + private void Map(Webhook source, WebhookViewModel target, MapperContext context) + { + target.ContentTypeKeys = source.ContentTypeKeys; + target.Events = source.Events; + target.Url = source.Url; + target.Enabled = source.Enabled; + target.Key = source.Key; + target.Headers = source.Headers; + } + + // Umbraco.Code.MapAll + private void Map(IWebhookEvent source, WebhookEventViewModel target, MapperContext context) => target.EventName = source.EventName; + + // Umbraco.Code.MapAll + private void Map(WebhookLog source, WebhookLogViewModel target, MapperContext context) + { + target.Date = source.Date; + target.EventName = source.EventName; + target.Key = source.Key; + target.RequestBody = source.RequestBody ?? string.Empty; + target.ResponseBody = source.ResponseBody; + target.RetryCount = source.RetryCount; + target.StatusCode = source.StatusCode; + target.Url = source.Url; + target.RequestHeaders = source.RequestHeaders; + target.ResponseHeaders = source.ResponseHeaders; + target.WebhookKey = source.WebhookKey; + } +} diff --git a/src/Umbraco.Web.BackOffice/Trees/WebhooksTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/WebhooksTreeController.cs new file mode 100644 index 0000000000..5f315f3cbb --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Trees/WebhooksTreeController.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Trees; +using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Web.BackOffice.Trees; + +[Authorize(Policy = AuthorizationPolicies.TreeAccessLogs)] +[Tree(Constants.Applications.Settings, Constants.Trees.Webhooks, SortOrder = 9, TreeGroup = Constants.Trees.Groups.Settings)] +[PluginController(Constants.Web.Mvc.BackOfficeTreeArea)] +[CoreTree] +public class WebhooksTreeController : TreeController +{ + private readonly IMenuItemCollectionFactory _menuItemCollectionFactory; + + public WebhooksTreeController( + ILocalizedTextService localizedTextService, + UmbracoApiControllerTypeCollection umbracoApiControllerTypeCollection, + IEventAggregator eventAggregator, + IMenuItemCollectionFactory menuItemCollectionFactory) + : base(localizedTextService, umbracoApiControllerTypeCollection, eventAggregator) => + _menuItemCollectionFactory = menuItemCollectionFactory; + + protected override ActionResult GetTreeNodes(string id, FormCollection queryStrings) => + //We don't have any child nodes & only use the root node to load a custom UI + new TreeNodeCollection(); + + protected override ActionResult GetMenuForNode(string id, FormCollection queryStrings) => + //We don't have any menu item options (such as create/delete/reload) & only use the root node to load a custom UI + _menuItemCollectionFactory.Create(); + + /// + /// Helper method to create a root model for a tree + /// + /// + protected override ActionResult CreateRootNode(FormCollection queryStrings) + { + ActionResult rootResult = base.CreateRootNode(queryStrings); + if (!(rootResult.Result is null)) + { + return rootResult; + } + + TreeNode? root = rootResult.Value; + + if (root is not null) + { + // This will load in a custom UI instead of the dashboard for the root node + root.RoutePath = $"{Constants.Applications.Settings}/{Constants.Trees.Webhooks}/overview"; + root.Icon = Constants.Icons.Webhooks; + root.HasChildren = false; + root.MenuUrl = null; + } + + return root; + } +} diff --git a/src/Umbraco.Web.Common/Models/WebhookEventViewModel.cs b/src/Umbraco.Web.Common/Models/WebhookEventViewModel.cs new file mode 100644 index 0000000000..441a367429 --- /dev/null +++ b/src/Umbraco.Web.Common/Models/WebhookEventViewModel.cs @@ -0,0 +1,10 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Web.Common.Models; + +[DataContract] +public class WebhookEventViewModel +{ + [DataMember(Name = "eventName")] + public string EventName { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Web.Common/Models/WebhookLogViewModel.cs b/src/Umbraco.Web.Common/Models/WebhookLogViewModel.cs new file mode 100644 index 0000000000..f9bf6762f8 --- /dev/null +++ b/src/Umbraco.Web.Common/Models/WebhookLogViewModel.cs @@ -0,0 +1,40 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Web.Common.Models; + +[DataContract] +public class WebhookLogViewModel +{ + [DataMember(Name = "key")] + public Guid Key { get; set; } + + [DataMember(Name = "webhookKey")] + public Guid WebhookKey { get; set; } + + [DataMember(Name = "statusCode")] + public string StatusCode { get; set; } = string.Empty; + + [DataMember(Name = "date")] + public DateTime Date { get; set; } + + [DataMember(Name = "eventName")] + public string EventName { get; set; } = string.Empty; + + [DataMember(Name = "url")] + public string Url { get; set; } = string.Empty; + + [DataMember(Name = "retryCount")] + public int RetryCount { get; set; } + + [DataMember(Name = "requestHeaders")] + public string RequestHeaders { get; set; } = string.Empty; + + [DataMember(Name = "requestBody")] + public string RequestBody { get; set; } = string.Empty; + + [DataMember(Name = "responseHeaders")] + public string ResponseHeaders { get; set; } = string.Empty; + + [DataMember(Name = "responseBody")] + public string ResponseBody { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Web.Common/Models/WebhookViewModel.cs b/src/Umbraco.Web.Common/Models/WebhookViewModel.cs new file mode 100644 index 0000000000..a0efff398b --- /dev/null +++ b/src/Umbraco.Web.Common/Models/WebhookViewModel.cs @@ -0,0 +1,25 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Web.Common.Models; + +[DataContract] +public class WebhookViewModel +{ + [DataMember(Name = "key")] + public Guid? Key { get; set; } + + [DataMember(Name = "url")] + public string Url { get; set; } = string.Empty; + + [DataMember(Name = "events")] + public string[] Events { get; set; } = Array.Empty(); + + [DataMember(Name = "contentTypeKeys")] + public Guid[] ContentTypeKeys { get; set; } = Array.Empty(); + + [DataMember(Name = "enabled")] + public bool Enabled { get; set; } + + [DataMember(Name = "headers")] + public IDictionary Headers { get; set; } = new Dictionary(); +} diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/webhooks.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/webhooks.resource.js new file mode 100644 index 0000000000..3611e67de9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/common/resources/webhooks.resource.js @@ -0,0 +1,47 @@ +function webhooksResource($q, $http, umbRequestHelper) { + return { + getByKey(key) { + return umbRequestHelper.resourcePromise( + $http.get(umbRequestHelper.getApiUrl('webhooksApiBaseUrl', 'GetByKey', {key})), + 'Failed to get webhooks' + ); + }, + getAll(pageNumber, pageSize) { + return umbRequestHelper.resourcePromise( + $http.get(umbRequestHelper.getApiUrl('webhooksApiBaseUrl', 'GetAll', {pageNumber, pageSize})), + 'Failed to get webhooks' + ); + }, + create(webhook) { + return umbRequestHelper.resourcePromise( + $http.post(umbRequestHelper.getApiUrl('webhooksApiBaseUrl', 'Create'), webhook), + `Failed to save webhook id ${webhook.id}` + ); + }, + update(webhook) { + return umbRequestHelper.resourcePromise( + $http.put(umbRequestHelper.getApiUrl('webhooksApiBaseUrl', 'Update'), webhook), + `Failed to save webhook id ${webhook.id}` + ); + }, + delete(key) { + return umbRequestHelper.resourcePromise( + $http.delete(umbRequestHelper.getApiUrl('webhooksApiBaseUrl', 'Delete', {key})), + `Failed to delete webhook id ${key}` + ); + }, + getAllEvents() { + return umbRequestHelper.resourcePromise( + $http.get(umbRequestHelper.getApiUrl('webhooksApiBaseUrl', 'GetEvents')), + 'Failed to get events' + ); + }, + getLogs(skip, take) { + return umbRequestHelper.resourcePromise( + $http.get(umbRequestHelper.getApiUrl('webhooksApiBaseUrl', 'GetLogs', {skip, take})), + 'Failed to get logs' + ); + } + }; +} +angular.module('umbraco.resources').factory('webhooksResource', webhooksResource); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/editor.service.js b/src/Umbraco.Web.UI.Client/src/common/services/editor.service.js index cabb9b0139..6ce2a61197 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/editor.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/editor.service.js @@ -847,6 +847,12 @@ When building a custom infinite editor view you can use the same components as a open(editor); } + function eventPicker(editor) { + editor.view = "views/common/infiniteeditors/eventpicker/eventpicker.html"; + if (!editor.size) editor.size = "small"; + open(editor); + } + /** * @ngdoc method * @name umbraco.services.editorService#sectionPicker @@ -1179,7 +1185,8 @@ When building a custom infinite editor view you can use the same components as a memberGroupPicker: memberGroupPicker, memberPicker: memberPicker, memberEditor: memberEditor, - mediaCropDetails + mediaCropDetails, + eventPicker : eventPicker }; return service; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.controller.js new file mode 100644 index 0000000000..67bea3c07b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.controller.js @@ -0,0 +1,102 @@ +(function () { + "use strict"; + + function LanguagePickerController($scope, languageResource, localizationService, webhooksResource) { + + var vm = this; + + vm.events = []; + vm.loading = false; + + vm.selectEvent = selectEvent; + vm.submit = submit; + vm.close = close; + + function onInit() { + + vm.loading = true; + + // set default title + if (!$scope.model.title) { + localizationService.localize("defaultdialogs_selectLanguages").then(function (value) { + $scope.model.title = value; + }); + } + + // make sure we can push to something + if (!$scope.model.selection) { + $scope.model.selection = []; + } + + getAllEvents(); + vm.loading = false; + } + + function getAllEvents(){ + // get all events + webhooksResource.getAllEvents() + .then((data) => { + let selectedEvents = []; + data.forEach(function (event) { + let eventObject = { name: event.eventName, selected: false} + vm.events.push(eventObject); + if($scope.model.selectedEvents && $scope.model.selectedEvents.includes(eventObject.name)){ + selectedEvents.push(eventObject); + } + }); + + selectedEvents.forEach(function (event) { + selectEvent(event) + }); + }); + } + + function selectEvent(event) { + if (!event.selected) { + event.selected = true; + $scope.model.selection.push(event); + // Only filter if we have not selected an item yet. + if($scope.model.selection.length === 1){ + if(event.name.toLowerCase().includes("content")){ + vm.events = vm.events.filter(event => event.name.toLowerCase().includes("content")); + } + else if (event.name.toLowerCase().includes("media")){ + vm.events = vm.events.filter(event => event.name.toLowerCase().includes("media")); + } + } + } else { + + $scope.model.selection.forEach(function (selectedEvent, index) { + if (selectedEvent.name === event.name) { + event.selected = false; + $scope.model.selection.splice(index, 1); + } + }); + + if($scope.model.selection.length === 0){ + vm.events = []; + getAllEvents(); + } + } + } + + function submit(model) { + if ($scope.model.submit) { + $scope.model.selection = $scope.model.selection.map((item) => item.name) + $scope.model.submit(model); + } + } + + function close() { + if ($scope.model.close) { + $scope.model.close(); + } + } + + onInit(); + + } + + angular.module("umbraco").controller("Umbraco.Editors.EventPickerController", LanguagePickerController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.html new file mode 100644 index 0000000000..d4033784ed --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.html @@ -0,0 +1,43 @@ +
+ + + + + + + + + + + + + +
    +
  • +
    + +
    +
  • +
+
+
+
+ + + + + + + + + + +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.controller.js new file mode 100644 index 0000000000..11f5debee9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.controller.js @@ -0,0 +1,39 @@ +(function () { + "use strict"; + + function WebhookLogController($q,$scope, webhooksResource, notificationsService, overlayService) { + var vm = this; + vm.logs = []; + vm.openLogOverlay = openLogOverlay; + vm.isChecked = isChecked; + + function loadLogs (){ + return webhooksResource.getLogs() + .then((data) => { + vm.logs = data.items; + }); + } + + function openLogOverlay (log) { + overlayService.open({ + view: "views/webhooks/overlays/details.html", + title: 'Details', + position: 'right', + log, + currentUser: this.currentUser, + close: () => { + overlayService.close(); + } + }); + } + + function isChecked (log) { + return log.statusCode === "OK"; + } + + loadLogs(); + } + + angular.module("umbraco").controller("Umbraco.Editors.Webhooks.WebhookLogController", WebhookLogController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.html new file mode 100644 index 0000000000..10603d0ac1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.html @@ -0,0 +1,47 @@ +
+ + + + + + + + + + + + + + + + + + + + + + +
Webhook keyDateUrlEventRetryCount
+ + + + + {{ log.webhookKey}} + + {{ log.date}} + + {{ log.url }} + + {{ log.eventName }} + + {{ log.retryCount}} + + + +
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/details.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/details.html new file mode 100644 index 0000000000..9c0856cb67 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/details.html @@ -0,0 +1,43 @@ +
+
+
+
+ + +
+
{{model.webhookLogEntry.response.statusDescription}} ({{model.webhookLogEntry.response.statusCode}})
+
+
+
+ Date +
{{model.log.date}}
+
+
+ Url +
{{model.log.url}}
+
+
+ Status Code +
{{model.log.statusCode}}
+
+
+ Event +
{{model.log.eventName}}
+
+
+ Retry count +
{{model.log.retryCount}}
+
+
+ Request +
{{model.log.requestHeaders}}
+---
+{{model.log.requestBody}}
+
+
+ Response +
{{model.log.responseHeaders}}
+---
+{{model.log.responseBody}}
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.controller.js new file mode 100644 index 0000000000..c25ccaa6ba --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.controller.js @@ -0,0 +1,121 @@ +(function () { + "use strict"; + + function EditController($scope, editorService, contentTypeResource, mediaTypeResource) { + var vm = this; + vm.clearContentType = clearContentType; + vm.clearEvent = clearEvent; + vm.removeHeader = removeHeader; + vm.openCreateHeader = openCreateHeader; + vm.openEventPicker = openEventPicker; + vm.openContentTypePicker = openContentTypePicker; + vm.close = close; + vm.submit = submit; + + + function openEventPicker() + { + editorService.eventPicker({ + title: "Select event", + selectedEvents: $scope.model.webhook.events, + submit(model) { + $scope.model.webhook.events = model.selection; + editorService.close(); + }, + close() { + editorService.close(); + } + }); + } + + function openContentTypePicker() + { + const isContent = $scope.model.webhook ? $scope.model.webhook.events[0].toLowerCase().includes("content") : null; + editorService.treePicker({ + section: 'settings', + treeAlias: isContent ? 'documentTypes' : 'mediaTypes', + entityType: isContent ? 'DocumentType' : 'MediaType', + multiPicker: true, + submit(model) { + getEntities(model.selection, isContent); + $scope.model.webhook.contentTypeKeys = model.selection.map((item) => item.key); + editorService.close(); + }, + close() { + editorService.close(); + } + }); + } + + function openCreateHeader() { + editorService.open({ + title: "Create header", + view: "views/webhooks/overlays/header.html", + size: 'small', + position: 'right', + submit(model) { + if (!$scope.model.webhook.headers) { + $scope.model.webhook.headers = {}; + } + $scope.model.webhook.headers[model.key] = model.value; + editorService.close(); + }, + close() { + editorService.close(); + } + }); + } + + function getEntities(selection, isContent) { + const resource = isContent ? contentTypeResource : mediaTypeResource; + $scope.model.contentTypes = []; + + selection.forEach((entity) => { + resource.getById(entity.key) + .then((data) => { + $scope.model.contentTypes.push(data); + }); + }); + } + + function clearContentType(contentTypeKey) { + if (Array.isArray($scope.model.webhook.contentTypeKeys)) { + $scope.model.webhook.contentTypeKeys = $scope.model.webhook.contentTypeKeys.filter(x => x !== contentTypeKey); + } + if (Array.isArray($scope.model.contentTypes)) { + $scope.model.contentTypes = $scope.model.contentTypes.filter(x => x.key !== contentTypeKey); + } + } + + function clearEvent(event) { + if (Array.isArray($scope.model.webhook.events)) { + $scope.model.webhook.events = $scope.model.webhook.events.filter(x => x !== event); + } + + if (Array.isArray($scope.model.contentTypes)) { + $scope.model.events = $scope.model.events.filter(x => x.key !== event); + } + } + + function removeHeader(key) { + delete $scope.model.webhook.headers[key]; + } + + + function close() + { + if ($scope.model.close) { + $scope.model.close(); + } + } + + function submit() + { + if ($scope.model.submit) { + $scope.model.submit($scope.model); + } + } + } + + angular.module("umbraco").controller("Umbraco.Editors.Webhooks.EditController", EditController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.html new file mode 100644 index 0000000000..40e216c79a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.html @@ -0,0 +1,141 @@ +
+ + + + + + + + +
+ + + + + + + + + Add + + + + + + + + Add + + Please select an event first. + + + + + + + + + + + + + + + + + + + + + +
NameValue
+ {{ key }} + + {{ value }} + + + +
+ + +
+
+
+
+
+ + + + + + + + +
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/header.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/header.controller.js new file mode 100644 index 0000000000..a77a4f5c00 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/header.controller.js @@ -0,0 +1,21 @@ +(function () { + "use strict"; + function HeaderController($scope) { + var vm = this; + $scope.headerModel = { key: "", value: "" }; + vm.submit = submit; + vm.close = close; + + function submit () { + if ($scope.headerModel.key && $scope.headerModel.value) { + $scope.model.submit($scope.headerModel); + } + } + + function close () { + $scope.model.close(); + } + } + + angular.module("umbraco").controller("Umbraco.Editors.Webhooks.HeaderController", HeaderController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/header.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/header.html new file mode 100644 index 0000000000..b9adcb2bf6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/header.html @@ -0,0 +1,61 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overview.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/overview.controller.js new file mode 100644 index 0000000000..0dc8ebf5a8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/overview.controller.js @@ -0,0 +1,64 @@ +(function () { + "use strict"; + + function OverviewController($q, $location, $routeParams, notificationsService, editorService, overlayService, localizationService) { + var vm = this; + vm.page = {}; + vm.page.labels = {}; + vm.page.name = ""; + vm.page.navigation = []; + let webhookUri = $routeParams.method; + + + onInit(); + + function onInit() { + + loadNavigation(); + + setPageName(); + } + + function loadNavigation() { + + var labels = ["treeHeaders_webhooks", "webhooks_logs"]; + + localizationService.localizeMany(labels).then(function (data) { + vm.page.labels.webhooks = data[0]; + vm.page.labels.logs = data[1]; + + vm.page.navigation = [ + { + "name": vm.page.labels.webhooks, + "icon": "icon-directions-alt", + "view": "views/webhooks/webhooks.html", + "active": webhookUri === 'overview', + "alias": "umbWebhooks", + "action": function () { + $location.path("/settings/webhooks/overview"); + } + }, + { + "name": vm.page.labels.logs, + "icon": "icon-box-alt", + "view": "views/webhooks/logs.html", + "active": webhookUri === 'logs', + "alias": "umbWebhookLogs", + "action": function () { + $location.path("/settings/webhooks/overview"); + } + } + ]; + }); + } + + function setPageName() { + localizationService.localize("treeHeaders_webhooks").then(function (data) { + vm.page.name = data; + }) + } + } + + angular.module("umbraco").controller("Umbraco.Editors.Webhooks.OverviewController", OverviewController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overview.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/overview.html new file mode 100644 index 0000000000..2576fa1e8b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/overview.html @@ -0,0 +1,23 @@ +
+ + + + + + + + + + + + + + + +
diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.controller.js new file mode 100644 index 0000000000..5814d6f6dc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.controller.js @@ -0,0 +1,182 @@ +(function () { + "use strict"; + + function WebhookController($q,$scope, webhooksResource, notificationsService, editorService, overlayService, contentTypeResource, mediaTypeResource) { + var vm = this; + + vm.openWebhookOverlay = openWebhookOverlay; + vm.deleteWebhook = deleteWebhook; + vm.handleSubmissionError = handleSubmissionError; + vm.resolveTypeNames = resolveTypeNames; + vm.resolveEventNames = resolveEventNames; + + vm.page = {}; + vm.webhooks = []; + vm.events = []; + vm.webHooksContentTypes = {}; + vm.webhookEvents = {}; + + function loadEvents (){ + return webhooksResource.getAllEvents() + .then((data) => { + vm.events = data.map(item => item.eventName); + }); + } + + function resolveEventNames(webhook) { + webhook.events.forEach((event) => { + if (!vm.webhookEvents[webhook.key]) { + vm.webhookEvents[webhook.key] = event; + } else { + vm.webhookEvents[webhook.key] += ", " + event; + } + }); + } + + function getEntities(webhook) { + const isContent = webhook.events[0].toLowerCase().includes("content"); + const resource = isContent ? contentTypeResource : mediaTypeResource; + let entities = []; + + webhook.contentTypeKeys.forEach((key) => { + resource.getById(key) + .then((data) => { + entities.push(data); + }); + }); + + return entities; + } + + function resolveTypeNames(webhook) { + const isContent = webhook.events[0].toLowerCase().includes("content"); + const resource = isContent ? contentTypeResource : mediaTypeResource; + + if (vm.webHooksContentTypes[webhook.key]){ + delete vm.webHooksContentTypes[webhook.key]; + } + + webhook.contentTypeKeys.forEach((key) => { + resource.getById(key) + .then((data) => { + if (!vm.webHooksContentTypes[webhook.key]) { + vm.webHooksContentTypes[webhook.key] = data.name; + } else { + vm.webHooksContentTypes[webhook.key] += ", " + data.name; + } + }); + }); + } + + function handleSubmissionError (model, errorMessage) { + notificationsService.error(errorMessage); + model.disableSubmitButton = false; + model.submitButtonState = 'error'; + } + + function openWebhookOverlay (webhook) { + let isCreating = !webhook; + editorService.open({ + title: webhook ? 'Edit webhook' : 'Add webhook', + position: 'right', + size: 'small', + submitButtonLabel: webhook ? 'Save' : 'Create', + view: "views/webhooks/overlays/edit.html", + events: vm.events, + contentTypes : webhook ? getEntities(webhook) : null, + webhook: webhook ? webhook : {enabled: true}, + submit: (model) => { + model.disableSubmitButton = true; + model.submitButtonState = 'busy'; + if (!model.webhook.url) { + //Due to validation url will only be populated if it's valid, hence we can make do with checking url is there + handleSubmissionError(model, 'Please provide a valid URL. Did you include https:// ?'); + return; + } + if (!model.webhook.events || model.webhook.events.length === 0) { + handleSubmissionError(model, 'Please provide the event for which the webhook should trigger'); + return; + } + if(isCreating){ + webhooksResource.create(model.webhook) + .then(() => { + loadWebhooks() + notificationsService.success('Webhook saved.'); + editorService.close(); + }, x => { + let errorMessage = undefined; + if (x.data.ModelState) { + errorMessage = `Message: ${Object.values(x.data.ModelState).flat().join(' ')}`; + } + handleSubmissionError(model, `Error saving webhook. ${errorMessage ?? ''}`); + }); + } + else{ + webhooksResource.update(model.webhook) + .then(() => { + loadWebhooks() + notificationsService.success('Webhook saved.'); + editorService.close(); + }, x => { + let errorMessage = undefined; + if (x.data.ModelState) { + errorMessage = `Message: ${Object.values(x.data.ModelState).flat().join(' ')}`; + } + handleSubmissionError(model, `Error saving webhook. ${errorMessage ?? ''}`); + }); + } + + }, + close: () => { + editorService.close(); + } + }); + } + + function loadWebhooks(){ + webhooksResource + .getAll() + .then((result) => { + vm.webhooks = result; + vm.webhookEvents = {}; + vm.webHooksContentTypes = {}; + + vm.webhooks.forEach((webhook) => { + resolveTypeNames(webhook); + resolveEventNames(webhook); + }) + }); + } + + function deleteWebhook (webhook) { + overlayService.open({ + title: 'Confirm delete webhook', + content: 'Are you sure you want to delete the webhook?', + submitButtonLabel: 'Yes, delete', + submitButtonStyle: 'danger', + closeButtonLabel: 'Cancel', + submit: () => { + webhooksResource.delete(webhook.key) + .then(() => { + const index = this.webhooks.indexOf(webhook); + this.webhooks.splice(index, 1); + + notificationsService.success('Webhook deleted.'); + overlayService.close(); + }, () => { + notificationsService.error('Error deleting webhook.'); + }); + }, + close: () => { + overlayService.close(); + } + }); + } + + loadWebhooks() + loadEvents() + } + + angular.module("umbraco").controller("Umbraco.Editors.Webhooks.WebhookController", WebhookController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.html new file mode 100644 index 0000000000..241a4a2015 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.html @@ -0,0 +1,57 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EnabledEventsUrlTypes
+ + + + + {{ vm.webhookEvents[webhook.key] }} + + {{ webhook.url }} + + {{ vm.webHooksContentTypes[webhook.key] }} + + + +
+ +
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookLogServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookLogServiceTests.cs new file mode 100644 index 0000000000..7716f83eda --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookLogServiceTests.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Webhooks; +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 WebhookLogServiceTests : UmbracoIntegrationTest +{ + private IWebhookLogService WebhookLogService => GetRequiredService(); + + [Test] + public async Task Can_Create_And_Get() + { + var createdWebhookLog = await WebhookLogService.CreateAsync(new WebhookLog + { + Date = DateTime.UtcNow, + EventName = Constants.WebhookEvents.ContentPublish, + RequestBody = "Test Request Body", + ResponseBody = "Test response body", + StatusCode = "200", + RetryCount = 0, + Key = Guid.NewGuid(), + }); + + + var webhookLogsPaged = await WebhookLogService.Get(); + + Assert.Multiple(() => + { + Assert.IsNotNull(webhookLogsPaged); + Assert.IsNotEmpty(webhookLogsPaged.Items); + Assert.AreEqual(1, webhookLogsPaged.Items.Count()); + var webHookLog = webhookLogsPaged.Items.First(); + Assert.AreEqual(createdWebhookLog.Date, webHookLog.Date); + Assert.AreEqual(createdWebhookLog.EventName, webHookLog.EventName); + Assert.AreEqual(createdWebhookLog.RequestBody, webHookLog.RequestBody); + Assert.AreEqual(createdWebhookLog.ResponseBody, webHookLog.ResponseBody); + Assert.AreEqual(createdWebhookLog.StatusCode, webHookLog.StatusCode); + Assert.AreEqual(createdWebhookLog.RetryCount, webHookLog.RetryCount); + Assert.AreEqual(createdWebhookLog.Key, webHookLog.Key); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs new file mode 100644 index 0000000000..6f6da74485 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs @@ -0,0 +1,106 @@ +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 WebhookServiceTests : UmbracoIntegrationTest +{ + private IWebHookService WebhookService => GetRequiredService(); + + [Test] + [TestCase("https://example.com", Constants.WebhookEvents.ContentPublish, "00000000-0000-0000-0000-010000000000")] + [TestCase("https://example.com", Constants.WebhookEvents.ContentDelete, "00000000-0000-0000-0000-000200000000")] + [TestCase("https://example.com", Constants.WebhookEvents.ContentUnpublish, "00000000-0000-0000-0000-300000000000")] + [TestCase("https://example.com", Constants.WebhookEvents.MediaDelete, "00000000-0000-0000-0000-000004000000")] + [TestCase("https://example.com", Constants.WebhookEvents.MediaSave, "00000000-0000-0000-0000-000000500000")] + public async Task Can_Create_And_Get(string url, string webhookEvent, Guid key) + { + var createdWebhook = await WebhookService.CreateAsync(new Webhook(url, true, new[] { key }, new[] { webhookEvent })); + var webhook = await WebhookService.GetAsync(createdWebhook.Key); + + Assert.Multiple(() => + { + Assert.IsNotNull(webhook); + Assert.AreEqual(1, webhook.Events.Length); + Assert.IsTrue(webhook.Events.Contains(webhookEvent)); + Assert.AreEqual(url, webhook.Url); + Assert.IsTrue(webhook.ContentTypeKeys.Contains(key)); + }); + } + + [Test] + public async Task Can_Get_All() + { + var createdWebhookOne = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.ContentPublish })); + var createdWebhookTwo = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.ContentDelete })); + var createdWebhookThree = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.ContentUnpublish })); + var webhooks = await WebhookService.GetAllAsync(0, int.MaxValue); + + Assert.Multiple(() => + { + Assert.IsNotEmpty(webhooks.Items); + Assert.IsNotNull(webhooks.Items.FirstOrDefault(x => x.Key == createdWebhookOne.Key)); + Assert.IsNotNull(webhooks.Items.FirstOrDefault(x => x.Key == createdWebhookTwo.Key)); + Assert.IsNotNull(webhooks.Items.FirstOrDefault(x => x.Key == createdWebhookThree.Key)); + }); + } + + [Test] + [TestCase("https://example.com", Constants.WebhookEvents.ContentPublish, "00000000-0000-0000-0000-010000000000")] + [TestCase("https://example.com", Constants.WebhookEvents.ContentDelete, "00000000-0000-0000-0000-000200000000")] + [TestCase("https://example.com", Constants.WebhookEvents.ContentUnpublish, "00000000-0000-0000-0000-300000000000")] + [TestCase("https://example.com", Constants.WebhookEvents.MediaDelete, "00000000-0000-0000-0000-000004000000")] + [TestCase("https://example.com", Constants.WebhookEvents.MediaSave, "00000000-0000-0000-0000-000000500000")] + public async Task Can_Delete(string url, string webhookEvent, Guid key) + { + var createdWebhook = await WebhookService.CreateAsync(new Webhook(url, true, new[] { key }, new[] { webhookEvent })); + var webhook = await WebhookService.GetAsync(createdWebhook.Key); + + Assert.IsNotNull(webhook); + await WebhookService.DeleteAsync(webhook.Key); + var deletedWebhook = await WebhookService.GetAsync(createdWebhook.Key); + Assert.IsNull(deletedWebhook); + } + + [Test] + public async Task Can_Create_With_No_EntityKeys() + { + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentPublish })); + var webhook = await WebhookService.GetAsync(createdWebhook.Key); + + Assert.IsNotNull(webhook); + Assert.IsEmpty(webhook.ContentTypeKeys); + } + + [Test] + public async Task Can_Update() + { + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentPublish })); + createdWebhook.Events = new[] { Constants.WebhookEvents.ContentDelete }; + await WebhookService.UpdateAsync(createdWebhook); + + var updatedWebhook = await WebhookService.GetAsync(createdWebhook.Key); + Assert.IsNotNull(updatedWebhook); + Assert.AreEqual(1, updatedWebhook.Events.Length); + Assert.IsTrue(updatedWebhook.Events.Contains(Constants.WebhookEvents.ContentDelete)); + } + + [Test] + public async Task Can_Get_By_EventName() + { + var webhook1 = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentPublish })); + var webhook2 = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentUnpublish })); + var webhook3 = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentUnpublish })); + + var result = await WebhookService.GetByEventNameAsync(Constants.WebhookEvents.ContentUnpublish); + + Assert.IsNotEmpty(result); + Assert.AreEqual(2, result.Count()); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeFinderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeFinderTests.cs index 3089d89893..047e28dda5 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeFinderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeFinderTests.cs @@ -55,10 +55,10 @@ public class TypeFinderTests Assert.AreEqual(0, typesFound.Count()); // 0 classes in _assemblies are marked with [Tree] typesFound = typeFinder.FindClassesWithAttribute(new[] { typeof(TreeAttribute).Assembly }); - Assert.AreEqual(23, typesFound.Count()); // + classes in Umbraco.Web are marked with [Tree] + Assert.AreEqual(24, typesFound.Count()); // + classes in Umbraco.Web are marked with [Tree] typesFound = typeFinder.FindClassesWithAttribute(); - Assert.AreEqual(23, typesFound.Count()); // + classes in Umbraco.Web are marked with [Tree] + Assert.AreEqual(24, typesFound.Count()); // + classes in Umbraco.Web are marked with [Tree] } [AttributeUsage(AttributeTargets.Class)] diff --git a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs index 3ef955afb8..a289a45ec3 100644 --- a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs +++ b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs @@ -80,5 +80,7 @@ internal class UmbracoCmsSchema public DataTypesSettings DataTypes { get; set; } = null!; public MarketplaceSettings Marketplace { get; set; } = null!; + + public WebhookSettings Webhook { get; set; } = null!; } }