diff --git a/backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRAction.cs b/backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRAction.cs new file mode 100644 index 000000000..6f247f1ab --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRAction.cs @@ -0,0 +1,76 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Extensions.Actions.SignalR +{ + [RuleAction( + Title = "Azure SignalR", + IconImage = "", + IconColor = "#1566BF", + Display = "Send to Azure SignalR", + Description = "Send an message to azure SignalR.", + ReadMore = "https://azure.microsoft.com/fr-fr/services/signalr-service/")] + public sealed record SignalRAction : RuleAction + { + [LocalizedRequired] + [Display(Name = "Connection", Description = "The connection string to the Signal R Azure.")] + [Editor(RuleFieldEditor.Text)] + [Formattable] + public string ConnectionString { get; set; } + + [LocalizedRequired] + [Display(Name = "Hub Name", Description = "The name of the hub.")] + [Editor(RuleFieldEditor.Text)] + [Formattable] + public string HubName { get; set; } + + [LocalizedRequired] + [Display(Name = "Action", Description = "Broadcast = send to all User, User = send to specific user(s) specified in the 'Target' field, Group = send to specific group(s) specified in the 'Target' field")] + public ActionTypeEnum Action { get; set; } + + [Display(Name = "Methode Name", Description = "Set the Name of the hub method received by the customer, default value 'push.")] + [Editor(RuleFieldEditor.Text)] + public string MethodName { get; set; } + + [Display(Name = "Target (Optional)", Description = "Defines a user, group or target list by an id or name. For a list, define one value per line. Not necessary with the 'Broadcast' action")] + [Editor(RuleFieldEditor.TextArea)] + [Formattable] + public string Target { get; set; } + + [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] + [Editor(RuleFieldEditor.TextArea)] + [Formattable] + public string Payload { get; set; } + + protected override IEnumerable CustomValidate() + { + if (!string.IsNullOrWhiteSpace(HubName) && !Regex.IsMatch(HubName, "^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$")) + { + yield return new ValidationError("Hub must be valid azure hub name.", nameof(HubName)); + } + + if ((Action == ActionTypeEnum.User || Action == ActionTypeEnum.Group) && string.IsNullOrWhiteSpace(Target)) + { + yield return new ValidationError("Target must be specified with 'User' or 'Group' Action.", nameof(HubName)); + } + } + } + + public enum ActionTypeEnum + { + Broadcast, + User, + Group + } +} diff --git a/backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionHandler.cs new file mode 100644 index 000000000..f4ae61ac7 --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionHandler.cs @@ -0,0 +1,119 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Azure.SignalR.Management; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; + +namespace Squidex.Extensions.Actions.SignalR +{ + public sealed class SignalRActionHandler : RuleActionHandler + { + private readonly ClientPool<(string ConnectionString, string HubName), IServiceManager> clients; + + public SignalRActionHandler(RuleEventFormatter formatter) + : base(formatter) + { + clients = new ClientPool<(string ConnectionString, string HubName), IServiceManager>(key => + { + var serviceManager = new ServiceManagerBuilder() + .WithOptions(option => + { + option.ConnectionString = key.ConnectionString; + option.ServiceTransportType = ServiceTransportType.Transient; + }) + .Build(); + return serviceManager; + }); + } + + protected override async Task<(string Description, SignalRJob Data)> CreateJobAsync(EnrichedEvent @event, SignalRAction action) + { + var hubName = await FormatAsync(action.HubName, @event); + + string requestBody; + + if (!string.IsNullOrWhiteSpace(action.Payload)) + { + requestBody = await FormatAsync(action.Payload, @event); + } + else + { + requestBody = ToEnvelopeJson(@event); + } + + string[] targetArray = new string[0]; + string target = string.Empty; + target = await FormatAsync(action.Target, @event); + if (!string.IsNullOrEmpty(target)) + { + targetArray = target.Split('\n'); + } + + var ruleDescription = $"Send SignalRJob to signalR hub '{hubName}'"; + + var ruleJob = new SignalRJob + { + ConnectionString = action.ConnectionString, + HubName = hubName, + Action = action.Action, + MethodName = action.MethodName, + Target = target, + TargetArray = targetArray, + Payload = requestBody + }; + return (ruleDescription, ruleJob); + } + + protected override async Task ExecuteJobAsync(SignalRJob job, CancellationToken ct = default) + { + var signalR = await clients.GetClientAsync((job.ConnectionString, job.HubName)); + + await using (var signalRContext = await signalR.CreateHubContextAsync(job.HubName)) + { + var methodeName = !string.IsNullOrWhiteSpace(job.MethodName) ? job.MethodName : "push"; + + switch (job.Action) + { + case ActionTypeEnum.Broadcast: + await signalRContext.Clients.All.SendAsync(methodeName, job.Payload); + break; + case ActionTypeEnum.User: + await signalRContext.Clients.Users(job.TargetArray).SendAsync(methodeName, job.Payload); + + break; + case ActionTypeEnum.Group: + await signalRContext.Clients.Groups(job.TargetArray).SendAsync(methodeName, job.Payload); + + break; + } + } + + return Result.Complete(); + } + } + + public sealed class SignalRJob + { + public string ConnectionString { get; set; } + + public string HubName { get; set; } + + public ActionTypeEnum Action { get; set; } + + public string MethodName { get; set; } + + public string Target { get; set; } + + public string[] TargetArray { get; set; } + + public string Payload { get; set; } + } +} diff --git a/backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRPlugin.cs b/backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRPlugin.cs new file mode 100644 index 000000000..e771cf13e --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRPlugin.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Infrastructure.Plugins; + +namespace Squidex.Extensions.Actions.SignalR +{ + public sealed class SignalRPlugin : IPlugin + { + public void ConfigureServices(IServiceCollection services, IConfiguration config) + { + services.AddRuleAction(); + } + } +} diff --git a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj index c5531632d..9550a15fb 100644 --- a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj +++ b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj @@ -16,6 +16,7 @@ +