From 366eb274e8de7e23ed5ae082ef668b17387ef4cb Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 24 May 2025 16:37:17 +0200 Subject: [PATCH 01/17] chore(deps): update dependencies to latest versions --- src/WART-Client/WART-Client.csproj | 2 +- src/WART-Tests/WART-Tests.csproj | 2 +- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index b211044..5bb9859 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index c9673cb..4b5a5d6 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -16,7 +16,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index 792f9b1..5d25a82 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From 4e981343a46e45f61e7fbfeadfa336423f502f03 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 26 May 2025 01:01:56 +0200 Subject: [PATCH 02/17] chore(ci): explicitly set read permission for GitHub Action --- .github/workflows/commit-lint.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index 8dd34cb..b749361 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -1,5 +1,8 @@ name: Commit Lint - +permissions: + contents: read + pull-requests: write + on: pull_request: branches: From 66120f7e18aa1803419e31c5ae7fbca5ebb2ce0e Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 6 Jun 2025 18:30:31 +0200 Subject: [PATCH 03/17] chore(deps): update dependencies to latest versions --- src/WART-Client/WART-Client.csproj | 2 +- src/WART-Tests/WART-Tests.csproj | 2 +- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 5bb9859..4e25c56 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 4b5a5d6..5830fea 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -16,7 +16,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index 5d25a82..a520309 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From 36bf9996dfc4c376419cd2757404f1d0d67e3ca3 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 16 Jun 2025 18:59:42 +0200 Subject: [PATCH 04/17] fix(logging): sanitize structured logging to prevent log injection --- src/WART-Client/WART-Client.csproj | 6 +++--- src/WART-Core/Hubs/WartHubBase.cs | 10 +++++----- src/WART-Core/WART-Core.csproj | 4 ++-- src/WART-Tests/WART-Tests.csproj | 4 ++-- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 4e25c56..f61116c 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/WART-Core/Hubs/WartHubBase.cs b/src/WART-Core/Hubs/WartHubBase.cs index a1a76b3..8fe7435 100644 --- a/src/WART-Core/Hubs/WartHubBase.cs +++ b/src/WART-Core/Hubs/WartHubBase.cs @@ -51,7 +51,7 @@ public override async Task OnConnectedAsync() await AddToGroup(wartGroup); } - _logger?.LogInformation($"OnConnect: ConnectionId={Context.ConnectionId}, User={userName}"); + _logger?.LogInformation("OnConnect: ConnectionId={ConnectionId}, User={UserName}", Context.ConnectionId, userName); await base.OnConnectedAsync(); } @@ -68,11 +68,11 @@ public override Task OnDisconnectedAsync(Exception exception) if (exception != null) { - _logger?.LogWarning(exception, $"OnDisconnect with error: ConnectionId={Context.ConnectionId}"); + _logger?.LogWarning(exception, "OnDisconnect with error: ConnectionId={ConnectionId}", Context.ConnectionId); } else { - _logger?.LogInformation($"OnDisconnect: ConnectionId={Context.ConnectionId}"); + _logger?.LogInformation("OnDisconnect: ConnectionId={ConnectionId}", Context.ConnectionId); } return base.OnDisconnectedAsync(exception); @@ -93,7 +93,7 @@ public async Task AddToGroup(string groupName) await Groups.AddToGroupAsync(Context.ConnectionId, groupName); - _logger?.LogInformation($"Connection {Context.ConnectionId} added to group {groupName}"); + _logger?.LogInformation("Connection {ConnectionId} added to group {GroupName}", Context.ConnectionId, groupName); } /// @@ -111,7 +111,7 @@ public async Task RemoveFromGroup(string groupName) await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); - _logger?.LogInformation($"Connection {Context.ConnectionId} removed from group {groupName}"); + _logger?.LogInformation("Connection {ConnectionId} removed from group {GroupName}", Context.ConnectionId, groupName); } /// diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 4c3cbf6..606f431 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -22,8 +22,8 @@ - - + + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 5830fea..8bfba26 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -15,11 +15,11 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index a520309..dbd986a 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From ee76e3251ae24812a229100e482084bf909b3353 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 16 Jun 2025 19:11:12 +0200 Subject: [PATCH 05/17] fix(logging): sanitize structured logging to prevent log injection --- src/WART-Core/Hubs/WartHubBase.cs | 7 ++++--- src/WART-Core/Utilities/LogSanitizer.cs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 src/WART-Core/Utilities/LogSanitizer.cs diff --git a/src/WART-Core/Hubs/WartHubBase.cs b/src/WART-Core/Hubs/WartHubBase.cs index 8fe7435..8a4140f 100644 --- a/src/WART-Core/Hubs/WartHubBase.cs +++ b/src/WART-Core/Hubs/WartHubBase.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; +using WART_Core.Utilities; namespace WART_Core.Hubs { @@ -51,7 +52,7 @@ public override async Task OnConnectedAsync() await AddToGroup(wartGroup); } - _logger?.LogInformation("OnConnect: ConnectionId={ConnectionId}, User={UserName}", Context.ConnectionId, userName); + _logger?.LogInformation("OnConnect: ConnectionId={ConnectionId}, User={UserName}", Context.ConnectionId, LogSanitizer.Sanitize(userName)); await base.OnConnectedAsync(); } @@ -93,7 +94,7 @@ public async Task AddToGroup(string groupName) await Groups.AddToGroupAsync(Context.ConnectionId, groupName); - _logger?.LogInformation("Connection {ConnectionId} added to group {GroupName}", Context.ConnectionId, groupName); + _logger?.LogInformation("Connection {ConnectionId} added to group {GroupName}", Context.ConnectionId, LogSanitizer.Sanitize(groupName)); } /// @@ -111,7 +112,7 @@ public async Task RemoveFromGroup(string groupName) await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); - _logger?.LogInformation("Connection {ConnectionId} removed from group {GroupName}", Context.ConnectionId, groupName); + _logger?.LogInformation("Connection {ConnectionId} removed from group {GroupName}", Context.ConnectionId, LogSanitizer.Sanitize(groupName)); } /// diff --git a/src/WART-Core/Utilities/LogSanitizer.cs b/src/WART-Core/Utilities/LogSanitizer.cs new file mode 100644 index 0000000..95c93c2 --- /dev/null +++ b/src/WART-Core/Utilities/LogSanitizer.cs @@ -0,0 +1,23 @@ +// (c) 2024 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using System.Linq; + +namespace WART_Core.Utilities +{ + public static class LogSanitizer + { + /// + /// Sanitizes a string to make it safe for logging by removing control characters. + /// + /// The input string to sanitize. + /// The sanitized string with control characters removed. + public static string Sanitize(string input) + { + if (string.IsNullOrEmpty(input)) return input; + + return new string(input + .Where(c => !char.IsControl(c)) + .ToArray()); + } + } +} From d3ff4df56822281acfc617476d641b63bfe25e1b Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 26 Jun 2025 00:15:52 +0200 Subject: [PATCH 06/17] chore(deps): update dependencies to latest versions --- src/WART-Client/WART-Client.csproj | 2 +- src/WART-WebApiRealTime/Startup.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index f61116c..b3a296b 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/WART-WebApiRealTime/Startup.cs b/src/WART-WebApiRealTime/Startup.cs index 8a84660..43fd915 100755 --- a/src/WART-WebApiRealTime/Startup.cs +++ b/src/WART-WebApiRealTime/Startup.cs @@ -33,10 +33,10 @@ public void ConfigureServices(IServiceCollection services) //services.AddWartMiddleware(); // with JWT authentication - //services.AddWartMiddleware(hubType: HubType.JwtAuthentication, tokenKey: "dn3341fmcscscwe28419brhwbwgbss4t"); + services.AddWartMiddleware(hubType: HubType.JwtAuthentication, tokenKey: "dn3341fmcscscwe28419brhwbwgbss4t"); // with Cookie authentication - services.AddWartMiddleware(hubType: HubType.CookieAuthentication); + //services.AddWartMiddleware(hubType: HubType.CookieAuthentication); // Register the Swagger generator, defining 1 or more Swagger documents services.AddSwaggerGen(c => @@ -75,10 +75,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) //app.UseWartMiddleware(); // with JWT authentication - //app.UseWartMiddleware(HubType.JwtAuthentication); + app.UseWartMiddleware(HubType.JwtAuthentication); // with Cookie authentication - app.UseWartMiddleware(HubType.CookieAuthentication); + //app.UseWartMiddleware(HubType.CookieAuthentication); // multiple hub with authentication //var hubNameList = new List From c79b78d39113a63b8c5e577766af025459c750d8 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 13 Jul 2025 15:06:32 +0200 Subject: [PATCH 07/17] chore(deps): update dependencies to latest versions --- src/WART-Client/WART-Client.csproj | 6 +++--- src/WART-Core/WART-Core.csproj | 4 ++-- src/WART-Tests/WART-Tests.csproj | 2 +- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index b3a296b..46c111e 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 606f431..7b6df21 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -22,8 +22,8 @@ - - + + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 8bfba26..45897aa 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -15,7 +15,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index dbd986a..0e8df5a 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From 54890f0d7ef7910e6809d5d8e001babee9197367 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 3 Aug 2025 01:48:08 +0200 Subject: [PATCH 08/17] docs: update README to include GitHub Sponsors badge and sponsorship info --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f986971..4dc123a 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ![NuGet Downloads](https://img.shields.io/nuget/dt/WART-Core) [![issues - wart](https://img.shields.io/github/issues/engineering87/WART)](https://github.com/engineering87/WART/issues) [![stars - wart](https://img.shields.io/github/stars/engineering87/WART?style=social)](https://github.com/engineering87/WART) +[![Sponsor me](https://img.shields.io/badge/Sponsor-❤-pink)](https://github.com/sponsors/engineering87) From fac6df395bf5281a1ce27fe106e9df7a78dc69a5 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 10 Aug 2025 19:30:07 +0200 Subject: [PATCH 09/17] chore(deps): update dependencies to latest versions --- src/WART-Client/WART-Client.csproj | 8 ++++---- src/WART-Core/WART-Core.csproj | 4 ++-- src/WART-Tests/WART-Tests.csproj | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 46c111e..a923a31 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,10 +21,10 @@ - - - - + + + + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 7b6df21..73365fc 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -22,8 +22,8 @@ - - + + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 45897aa..7db8f8a 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -15,11 +15,11 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 4b154f8a4ae6e2e00ec4e77a93874d665c921dee Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 15 Aug 2025 17:28:44 +0200 Subject: [PATCH 10/17] refactor(controller): use request snapshot from HttpContext.Items in OnActionExecuted --- .../Controllers/WartBaseController.cs | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/WART-Core/Controllers/WartBaseController.cs b/src/WART-Core/Controllers/WartBaseController.cs index 26439f9..1f2160f 100644 --- a/src/WART-Core/Controllers/WartBaseController.cs +++ b/src/WART-Core/Controllers/WartBaseController.cs @@ -1,13 +1,15 @@ // (c) 2024 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) -using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using WART_Core.Entity; using WART_Core.Filters; -using Microsoft.Extensions.DependencyInjection; using WART_Core.Services; namespace WART_Core.Controllers @@ -16,9 +18,10 @@ public abstract class WartBaseController : Controller where THub : Hub { private readonly ILogger _logger; private readonly IHubContext _hubContext; - private const string RouteDataKey = "REQUEST"; - private WartEventQueueService _eventQueue; + // Strongly-typed, collision-free key for HttpContext.Items + private sealed class RequestSnapshotKey { } + private static readonly object ItemsRequestKey = new RequestSnapshotKey(); protected WartBaseController(IHubContext hubContext, ILogger logger) { @@ -27,12 +30,19 @@ protected WartBaseController(IHubContext hubContext, ILogger logger) } /// - /// Adds the request objects to RouteData. + /// Stores a snapshot of action arguments in + /// under a collision-free typed key. /// - /// The action executing context. public override void OnActionExecuting(ActionExecutingContext context) { - context?.RouteData.Values.Add(RouteDataKey, context.ActionArguments); + if (context is not null) + { + var snapshot = new ReadOnlyDictionary( + new Dictionary(context.ActionArguments) + ); + context.HttpContext.Items[ItemsRequestKey] = snapshot; + } + base.OnActionExecuting(context); } @@ -44,22 +54,27 @@ public override void OnActionExecuted(ActionExecutedContext context) { if (context?.Result is ObjectResult objectResult) { - var exclusion = context.Filters.Any(f => f.GetType().Name == nameof(ExcludeWartAttribute)); - if (!exclusion && context.RouteData.Values.TryGetValue(RouteDataKey, out var request)) + // Opt-out if ExcludeWartAttribute is present + var excluded = context.Filters.OfType().Any(); + + if (!excluded && context.HttpContext.Items.TryGetValue(ItemsRequestKey, out var snapshotObj)) { - var httpMethod = context.HttpContext?.Request.Method; - var httpPath = context.HttpContext?.Request.Path; - var remoteAddress = context.HttpContext?.Connection.RemoteIpAddress?.ToString(); - var response = objectResult.Value; + var http = context.HttpContext; - var wartEvent = new WartEvent(request, response, httpMethod, httpPath, remoteAddress); + var wartEvent = new WartEvent( + request: snapshotObj, + response: objectResult.Value, + httpMethod: http?.Request.Method, + httpPath: http?.Request.Path, + remoteAddress: http?.Connection.RemoteIpAddress?.ToString() + ); - _eventQueue = context.HttpContext?.RequestServices.GetService(); - _eventQueue?.Enqueue(new WartEventWithFilters(wartEvent, [.. context.Filters])); + var queue = context.HttpContext?.RequestServices.GetService(); + queue?.Enqueue(new WartEventWithFilters(wartEvent, [.. context.Filters])); } } base.OnActionExecuted(context); } } -} +} \ No newline at end of file From caad3ad6b001df3d47af5947e3438d0be23a2fc0 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 15 Aug 2025 17:48:37 +0200 Subject: [PATCH 11/17] refactor(worker): improve WartEventWorker robustness and clarity --- .../Services/WartEventQueueService.cs | 25 +++++++++---- src/WART-Core/Services/WartEventWorker.cs | 37 +++++++++---------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/WART-Core/Services/WartEventQueueService.cs b/src/WART-Core/Services/WartEventQueueService.cs index a13bbcb..4d71ce4 100644 --- a/src/WART-Core/Services/WartEventQueueService.cs +++ b/src/WART-Core/Services/WartEventQueueService.cs @@ -12,7 +12,7 @@ namespace WART_Core.Services public class WartEventQueueService { // A thread-safe queue to hold WartEvent objects along with their associated filters. - private readonly ConcurrentQueue _queue = new ConcurrentQueue(); + private readonly ConcurrentQueue _queue = new(); /// /// Enqueues a WartEventWithFilters object to the queue. @@ -20,8 +20,11 @@ public class WartEventQueueService /// The WartEventWithFilters object to enqueue. public void Enqueue(WartEventWithFilters wartEventWithFilters) { - // Adds the event with filters to the concurrent queue. - _queue.Enqueue(wartEventWithFilters); + if (wartEventWithFilters != null) + { + // Adds the event with filters to the concurrent queue. + _queue.Enqueue(wartEventWithFilters); + } } /// @@ -29,15 +32,21 @@ public void Enqueue(WartEventWithFilters wartEventWithFilters) /// /// The dequeued WartEventWithFilters object. /// True if an event was dequeued; otherwise, false. - public bool TryDequeue(out WartEventWithFilters wartEventWithFilters) - { - // Attempts to remove and return the event with filters from the queue. - return _queue.TryDequeue(out wartEventWithFilters); - } + public bool TryDequeue(out WartEventWithFilters item) => _queue.TryDequeue(out item); + + /// + /// Attempts to peek at the next item without removing it. + /// + public bool TryPeek(out WartEventWithFilters item) => _queue.TryPeek(out item); /// /// Gets the current count of events in the queue. /// public int Count => _queue.Count; + + /// + /// Check if the queue is empty + /// + public bool IsEmpty => _queue.IsEmpty; } } \ No newline at end of file diff --git a/src/WART-Core/Services/WartEventWorker.cs b/src/WART-Core/Services/WartEventWorker.cs index 5004965..1928ee0 100644 --- a/src/WART-Core/Services/WartEventWorker.cs +++ b/src/WART-Core/Services/WartEventWorker.cs @@ -24,6 +24,9 @@ public class WartEventWorker : BackgroundService where THub : Hub private readonly IHubContext _hubContext; private readonly ILogger> _logger; + private const int NoClientsDelayMs = 500; + private const int IdleDelayMs = 200; + /// /// Constructor that initializes the worker with the event queue, hub context, and logger. /// @@ -46,8 +49,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Check if there are any connected clients. if (!WartHubBase.HasConnectedClients) - { - await Task.Delay(500, stoppingToken); + { + await Task.Delay(NoClientsDelayMs, stoppingToken); continue; } @@ -58,10 +61,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Extract the event and filters. var wartEvent = wartEventWithFilters.WartEvent; - var filters = wartEventWithFilters.Filters; + var filters = wartEventWithFilters.Filters ?? []; // Send the event to the SignalR hub. - await SendToHub(wartEvent, filters); + await SendToHub(wartEvent, filters, stoppingToken); _logger.LogInformation("Event sent: {Event}", wartEvent); } @@ -77,7 +80,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } // Wait for 200 ms before checking for new events in the queue. - await Task.Delay(200, stoppingToken); + await Task.Delay(IdleDelayMs, stoppingToken); } _logger.LogInformation("WartEventWorker stopped."); @@ -87,7 +90,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// Sends the current event to the SignalR hub. /// This method determines if the event should be sent to specific groups or all clients. /// - private async Task SendToHub(WartEvent wartEvent, List filters) + private async Task SendToHub(WartEvent wartEvent, List filters, CancellationToken cancellationToken) { try { @@ -97,13 +100,13 @@ private async Task SendToHub(WartEvent wartEvent, List filters) // If specific groups are defined, send the event to each group in parallel. if (groups.Count != 0) { - var tasks = groups.Select(group => SendEventToGroup(wartEvent, group)); + var tasks = groups.Select(group => SendEventToGroup(wartEvent, group, cancellationToken)); await Task.WhenAll(tasks); } else { // If no groups are defined, send the event to all clients. - await SendEventToAllClients(wartEvent); + await SendEventToAllClients(wartEvent, cancellationToken); } } catch (Exception ex) @@ -124,14 +127,10 @@ private List GetTargetGroups(List filters) { var groups = new List(); - // Check if there is a GroupWartAttribute filter indicating the groups. - if (filters.Any(f => f.GetType().Name == nameof(GroupWartAttribute))) + var groupAttr = filters?.OfType().FirstOrDefault(); + if (groupAttr is not null && groupAttr.GroupNames is not null) { - var wartGroup = filters.FirstOrDefault(f => f.GetType() == typeof(GroupWartAttribute)) as GroupWartAttribute; - if (wartGroup != null) - { - groups.AddRange(wartGroup.GroupNames); - } + groups.AddRange(groupAttr.GroupNames); } return groups; @@ -140,12 +139,12 @@ private List GetTargetGroups(List filters) /// /// Sends the WartEvent to a specific group of clients. /// - private async Task SendEventToGroup(WartEvent wartEvent, string group) + private async Task SendEventToGroup(WartEvent wartEvent, string group, CancellationToken cancellationToken) { // Send the event to the group using SignalR. await _hubContext?.Clients .Group(group) - .SendAsync("Send", wartEvent.ToString()); + .SendAsync("Send", wartEvent.ToString(), cancellationToken); // Log the event sent to the group. _logger?.LogInformation($"Group: {group}, WartEvent: {wartEvent}"); @@ -154,11 +153,11 @@ await _hubContext?.Clients /// /// Sends the WartEvent to all connected clients. /// - private async Task SendEventToAllClients(WartEvent wartEvent) + private async Task SendEventToAllClients(WartEvent wartEvent, CancellationToken cancellationToken) { // Send the event to all clients using SignalR. await _hubContext?.Clients.All - .SendAsync("Send", wartEvent.ToString()); + .SendAsync("Send", wartEvent.ToString(), cancellationToken); // Log the event sent to all clients. _logger?.LogInformation("Event: {EventName}, Details: {EventDetails}", nameof(WartEvent), wartEvent.ToString()); From fb6d3ab9c1352bda2527997b2bb533795324d246 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 15 Aug 2025 18:08:54 +0200 Subject: [PATCH 12/17] refactor(middleware): fix pipeline order and consolidate endpoint mapping --- .../WartApplicationBuilderExtension.cs | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs index d5fa39b..15cf760 100755 --- a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs +++ b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs @@ -15,6 +15,9 @@ public static class WartApplicationBuilderExtension { private const string DefaultHubName = "warthub"; + private static string NormalizeHubPath(string name) + => "/" + (name ?? string.Empty).Trim().Trim('/'); + /// /// Configures and adds the WART middleware to the IApplicationBuilder. /// This method sets up the default SignalR hub (warthub) without authentication. @@ -23,16 +26,15 @@ public static class WartApplicationBuilderExtension /// The updated IApplicationBuilder to continue configuration. public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app) { + app.UseForwardedHeaders(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{DefaultHubName}"); + endpoints.MapHub(NormalizeHubPath(DefaultHubName)); }); - app.UseForwardedHeaders(); - return app; } @@ -46,6 +48,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app /// The updated IApplicationBuilder to continue configuration. public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, HubType hubType) { + app.UseForwardedHeaders(); app.UseRouting(); switch(hubType) @@ -56,7 +59,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{DefaultHubName}"); + endpoints.MapHub(NormalizeHubPath(DefaultHubName)); }); break; } @@ -66,7 +69,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{DefaultHubName}"); + endpoints.MapHub(NormalizeHubPath(DefaultHubName)); }); break; } @@ -76,14 +79,12 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{DefaultHubName}"); + endpoints.MapHub(NormalizeHubPath(DefaultHubName)); }); break; } } - app.UseForwardedHeaders(); - return app; } @@ -98,18 +99,17 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName) { if (string.IsNullOrEmpty(hubName)) - throw new ArgumentNullException("Invalid hub name"); + throw new ArgumentException("Invalid hub name"); + app.UseForwardedHeaders(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); + endpoints.MapHub(NormalizeHubPath(hubName)); }); - app.UseForwardedHeaders(); - return app; } @@ -126,18 +126,21 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app { ArgumentNullException.ThrowIfNull(hubNameList); + app.UseForwardedHeaders(); app.UseRouting(); - foreach (var hubName in hubNameList.Distinct()) - { - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); - }); - } + var unique = hubNameList + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(NormalizeHubPath) + .Distinct() + .ToList(); - app.UseForwardedHeaders(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + foreach (var path in unique) + endpoints.MapHub(path); + }); return app; } @@ -154,8 +157,9 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName, HubType hubType) { if (string.IsNullOrEmpty(hubName)) - throw new ArgumentNullException("Invalid hub name"); + throw new ArgumentException("Invalid hub name"); + app.UseForwardedHeaders(); app.UseRouting(); switch (hubType) @@ -166,7 +170,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); + endpoints.MapHub(NormalizeHubPath(hubName)); }); break; } @@ -176,7 +180,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); + endpoints.MapHub(NormalizeHubPath(hubName)); }); break; } @@ -186,14 +190,12 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); + endpoints.MapHub(NormalizeHubPath(hubName)); }); break; } } - app.UseForwardedHeaders(); - return app; } @@ -212,6 +214,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app { ArgumentNullException.ThrowIfNull(hubNameList); + app.UseForwardedHeaders(); app.UseRouting(); foreach (var hubName in hubNameList.Distinct()) @@ -224,7 +227,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); + endpoints.MapHub(NormalizeHubPath(hubName)); }); break; } @@ -234,7 +237,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); + endpoints.MapHub(NormalizeHubPath(hubName)); }); break; } @@ -244,15 +247,13 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseEndpoints(endpoints => { endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); + endpoints.MapHub(NormalizeHubPath(hubName)); }); break; } } } - app.UseForwardedHeaders(); - return app; } } From afefa7e0b720acb683eb616186c362cafdeaf40b Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 15 Aug 2025 18:45:49 +0200 Subject: [PATCH 13/17] perf(middleware): enable response compression and standardize pipeline order --- .../CookieServiceCollectionExtension.cs | 21 +++++++++++------ .../JWT/JwtServiceCollectionExtension.cs | 23 ++++++++++++------- .../WartApplicationBuilderExtension.cs | 6 +++++ .../WartServiceCollectionExtension.cs | 15 ++++++++---- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs b/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs index df778ca..a1e7b85 100644 --- a/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs +++ b/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs @@ -1,14 +1,17 @@ // (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) -using System; -using System.IO; -using System.Linq; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; using WART_Core.Hubs; using WART_Core.Services; @@ -58,10 +61,10 @@ public static IServiceCollection AddCookieMiddleware( }); // Register WART event queue service - services.AddSingleton(); + services.TryAddSingleton(); // Register the WART event worker for the cookie-authenticated hub - services.AddHostedService>(); + services.TryAddEnumerable(ServiceDescriptor.Singleton>()); // SignalR configuration services.AddSignalR(options => @@ -75,9 +78,13 @@ public static IServiceCollection AddCookieMiddleware( // Compression for SignalR WebSocket/Binary transport services.AddResponseCompression(opts => { - opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( - new[] { "application/octet-stream" }); + opts.EnableForHttps = true; + opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]); + opts.Providers.Add(); + opts.Providers.Add(); }); + services.Configure(o => o.Level = CompressionLevel.Fastest); + services.Configure(o => o.Level = CompressionLevel.Fastest); return services; } diff --git a/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs b/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs index 2686146..168972d 100755 --- a/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs +++ b/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs @@ -1,16 +1,19 @@ // (c) 2021 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) -using System; -using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; +using System; +using System.IO.Compression; using System.Linq; +using System.Text; +using System.Threading.Tasks; using WART_Core.Hubs; using WART_Core.Services; @@ -31,7 +34,7 @@ public static IServiceCollection AddJwtMiddleware(this IServiceCollection servic // Validate that the token key is provided if (string.IsNullOrEmpty(tokenKey)) { - throw new ArgumentNullException("Invalid token key"); + throw new ArgumentException("Invalid token key"); } // Configure forwarded headers (to support proxy scenarios, e.g., when behind a load balancer) @@ -82,10 +85,10 @@ public static IServiceCollection AddJwtMiddleware(this IServiceCollection servic }); // Register WART event queue as a singleton service. - services.AddSingleton(); + services.TryAddSingleton(); // Register the WART event worker as a hosted service. - services.AddHostedService>(); + services.TryAddEnumerable(ServiceDescriptor.Singleton>()); // Configure SignalR options, including error handling and timeouts services.AddSignalR(options => @@ -99,9 +102,13 @@ public static IServiceCollection AddJwtMiddleware(this IServiceCollection servic // Configure response compression to support additional MIME types services.AddResponseCompression(opts => { - opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( - new[] { "application/octet-stream" }); + opts.EnableForHttps = true; + opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]); + opts.Providers.Add(); + opts.Providers.Add(); }); + services.Configure(o => o.Level = CompressionLevel.Fastest); + services.Configure(o => o.Level = CompressionLevel.Fastest); return services; } diff --git a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs index 15cf760..339fc2f 100755 --- a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs +++ b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs @@ -27,6 +27,7 @@ private static string NormalizeHubPath(string name) public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app) { app.UseForwardedHeaders(); + app.UseResponseCompression(); app.UseRouting(); app.UseEndpoints(endpoints => @@ -49,6 +50,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, HubType hubType) { app.UseForwardedHeaders(); + app.UseResponseCompression(); app.UseRouting(); switch(hubType) @@ -102,6 +104,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app throw new ArgumentException("Invalid hub name"); app.UseForwardedHeaders(); + app.UseResponseCompression(); app.UseRouting(); app.UseEndpoints(endpoints => @@ -127,6 +130,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app ArgumentNullException.ThrowIfNull(hubNameList); app.UseForwardedHeaders(); + app.UseResponseCompression(); app.UseRouting(); var unique = hubNameList @@ -160,6 +164,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app throw new ArgumentException("Invalid hub name"); app.UseForwardedHeaders(); + app.UseResponseCompression(); app.UseRouting(); switch (hubType) @@ -215,6 +220,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app ArgumentNullException.ThrowIfNull(hubNameList); app.UseForwardedHeaders(); + app.UseResponseCompression(); app.UseRouting(); foreach (var hubName in hubNameList.Distinct()) diff --git a/src/WART-Core/Middleware/WartServiceCollectionExtension.cs b/src/WART-Core/Middleware/WartServiceCollectionExtension.cs index 4886c4b..1dfd14f 100755 --- a/src/WART-Core/Middleware/WartServiceCollectionExtension.cs +++ b/src/WART-Core/Middleware/WartServiceCollectionExtension.cs @@ -4,8 +4,11 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System; +using System.IO.Compression; using System.Linq; using WART_Core.Authentication.Cookie; using WART_Core.Authentication.JWT; @@ -36,10 +39,10 @@ public static IServiceCollection AddWartMiddleware(this IServiceCollection servi services.AddLogging(configure => configure.AddConsole()); // Register WART event queue as a singleton service. - services.AddSingleton(); + services.TryAddSingleton(); // Register the WART event worker as a hosted service. - services.AddHostedService>(); + services.TryAddEnumerable(ServiceDescriptor.Singleton>()); // Configure SignalR with custom options. services.AddSignalR(options => @@ -53,9 +56,13 @@ public static IServiceCollection AddWartMiddleware(this IServiceCollection servi // Configure response compression for specific MIME types. services.AddResponseCompression(opts => { - opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( - new[] { "application/octet-stream" }); + opts.EnableForHttps = true; + opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]); + opts.Providers.Add(); + opts.Providers.Add(); }); + services.Configure(o => o.Level = CompressionLevel.Fastest); + services.Configure(o => o.Level = CompressionLevel.Fastest); return services; } From 6d5854baee54d47f22808a8adb085f0fe64d9c0e Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 18 Aug 2025 00:20:10 +0200 Subject: [PATCH 14/17] feat(wart-event): unify timestamps --- src/WART-Core/Entity/WartEvent.cs | 88 ++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/src/WART-Core/Entity/WartEvent.cs b/src/WART-Core/Entity/WartEvent.cs index 5f83199..7170051 100755 --- a/src/WART-Core/Entity/WartEvent.cs +++ b/src/WART-Core/Entity/WartEvent.cs @@ -16,17 +16,55 @@ namespace WART_Core.Entity [Serializable] public class WartEvent { + /// + /// Unique identifier for this event instance. + /// public Guid EventId { get; set; } + + /// + /// Local timestamp when the event was created. Intended to mirror + /// converted to the local time zone. + /// public DateTime TimeStamp { get; set; } + + /// + /// UTC timestamp when the event was created. Preferred for storage and comparisons. + /// public DateTime UtcTimeStamp { get; set; } - public string HttpMethod { get; set; } - public string HttpPath { get; set; } - public string RemoteAddress { get; set; } + + /// + /// HTTP verb used by the request (e.g., GET, POST, PUT, DELETE). + /// + public string HttpMethod { get; set; } = string.Empty; + + /// + /// HTTP path or route template of the request (e.g., "/api/orders/42"). + /// + public string HttpPath { get; set; } = string.Empty; + + /// + /// Remote client address (e.g., IP or "host:port") that originated the request. + /// + public string RemoteAddress { get; set; } = string.Empty; + + /// + /// Request payload serialized as JSON text. May contain either a JSON object or array. + /// Left as raw string to preserve the original body. + /// [JsonConverter(typeof(JsonArrayOrObjectStringConverter))] - public string JsonRequestPayload { get; set; } + public string JsonRequestPayload { get; set; } = string.Empty; + + /// + /// Response payload serialized as JSON text. May contain either a JSON object or array. + /// Left as raw string to preserve the original body. + /// [JsonConverter(typeof(JsonArrayOrObjectStringConverter))] - public string JsonResponsePayload { get; set; } - public string ExtraInfo { get; set; } + public string JsonResponsePayload { get; set; } = string.Empty; + + /// + /// Optional, free-form additional information (tags, notes, etc.). + /// + public string ExtraInfo { get; set; } = string.Empty; /// /// Private constructor used for JSON deserialization. @@ -40,14 +78,20 @@ private WartEvent() { } /// The HTTP method (e.g., GET, POST). /// The path of the HTTP request. /// The remote address (IP) from which the request originated. + /// + /// Initializes a new instance with method, path and remote address. + /// public WartEvent(string httpMethod, string httpPath, string remoteAddress) { - this.EventId = Guid.NewGuid(); - this.TimeStamp = DateTime.Now; - this.UtcTimeStamp = DateTime.UtcNow; - this.HttpMethod = httpMethod; - this.HttpPath = httpPath; - this.RemoteAddress = remoteAddress; + EventId = Guid.NewGuid(); + + var utcNow = DateTime.UtcNow; + UtcTimeStamp = utcNow; + TimeStamp = utcNow.ToLocalTime(); + + HttpMethod = httpMethod ?? string.Empty; + HttpPath = httpPath ?? string.Empty; + RemoteAddress = remoteAddress ?? string.Empty; } /// @@ -61,14 +105,18 @@ public WartEvent(string httpMethod, string httpPath, string remoteAddress) /// The remote address (IP) from which the request originated. public WartEvent(object request, object response, string httpMethod, string httpPath, string remoteAddress) { - this.EventId = Guid.NewGuid(); - this.TimeStamp = DateTime.Now; - this.UtcTimeStamp = DateTime.UtcNow; - this.HttpMethod = httpMethod; - this.HttpPath = httpPath; - this.RemoteAddress = remoteAddress; - this.JsonRequestPayload = SerializationHelper.Serialize(request); - this.JsonResponsePayload = SerializationHelper.Serialize(response); + EventId = Guid.NewGuid(); + + var utcNow = DateTime.UtcNow; + UtcTimeStamp = utcNow; + TimeStamp = utcNow.ToLocalTime(); + + HttpMethod = httpMethod ?? string.Empty; + HttpPath = httpPath ?? string.Empty; + RemoteAddress = remoteAddress ?? string.Empty; + + JsonRequestPayload = SerializationHelper.Serialize(request); + JsonResponsePayload = SerializationHelper.Serialize(response); } /// From 1ae1dc61423dd8d57d20674fe0c1ae9a647f2c7f Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 18 Aug 2025 17:41:51 +0200 Subject: [PATCH 15/17] refactor(hubs,worker): partition connection tracking per hub and some other optimization --- src/WART-Client/WART-Client.csproj | 2 +- src/WART-Core/Filters/ExcludeWartAttribute.cs | 4 +- src/WART-Core/Filters/GroupWartAttribute.cs | 19 ++++++- src/WART-Core/Helpers/SerializationHelper.cs | 2 +- src/WART-Core/Hubs/WartHubBase.cs | 56 +++++++++++-------- .../WartApplicationBuilderExtension.cs | 8 +-- src/WART-Core/Services/WartEventWorker.cs | 35 +++++++----- src/WART-Tests/WART-Tests.csproj | 2 +- 8 files changed, 79 insertions(+), 49 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index a923a31..2c84c39 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/WART-Core/Filters/ExcludeWartAttribute.cs b/src/WART-Core/Filters/ExcludeWartAttribute.cs index 6c2052b..d0cfbdb 100644 --- a/src/WART-Core/Filters/ExcludeWartAttribute.cs +++ b/src/WART-Core/Filters/ExcludeWartAttribute.cs @@ -1,6 +1,7 @@ // (c) 2019 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using Microsoft.AspNetCore.Mvc.Filters; +using System; namespace WART_Core.Filters { @@ -9,7 +10,8 @@ namespace WART_Core.Filters /// When applied to an action, this filter ensures that no SignalR events are triggered or broadcasted /// as a result of the execution of the action. /// - public class ExcludeWartAttribute : ActionFilterAttribute + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public sealed class ExcludeWartAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { diff --git a/src/WART-Core/Filters/GroupWartAttribute.cs b/src/WART-Core/Filters/GroupWartAttribute.cs index 01358ef..52288b9 100644 --- a/src/WART-Core/Filters/GroupWartAttribute.cs +++ b/src/WART-Core/Filters/GroupWartAttribute.cs @@ -1,7 +1,10 @@ // (c) 2024 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using Microsoft.AspNetCore.Mvc.Filters; +using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; namespace WART_Core.Filters { @@ -10,18 +13,30 @@ namespace WART_Core.Filters /// This attribute allows specifying a list of group names, which can be used to target SignalR events /// to one or more SignalR groups during the execution of an action. /// - public class GroupWartAttribute : ActionFilterAttribute + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public sealed class GroupWartAttribute : ActionFilterAttribute { public IReadOnlyList GroupNames { get; } // Constructor accepting a list of group names public GroupWartAttribute(params string[] groupNames) { - GroupNames = new List(groupNames); + var cleaned = (groupNames ?? []) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + GroupNames = new ReadOnlyCollection(cleaned); } public override void OnActionExecuting(ActionExecutingContext context) { + if (GroupNames.Count > 0) + { + context.HttpContext.Items["WartGroups"] = GroupNames; + } + base.OnActionExecuting(context); } diff --git a/src/WART-Core/Helpers/SerializationHelper.cs b/src/WART-Core/Helpers/SerializationHelper.cs index 6c2c157..c235564 100644 --- a/src/WART-Core/Helpers/SerializationHelper.cs +++ b/src/WART-Core/Helpers/SerializationHelper.cs @@ -49,7 +49,7 @@ public static T Deserialize(string jsonString) } catch (Exception) { - return default(T); + return default; } } } diff --git a/src/WART-Core/Hubs/WartHubBase.cs b/src/WART-Core/Hubs/WartHubBase.cs index 8a4140f..c8c384f 100644 --- a/src/WART-Core/Hubs/WartHubBase.cs +++ b/src/WART-Core/Hubs/WartHubBase.cs @@ -1,10 +1,11 @@ // (c) 2024 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; +using System.Linq; using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; using WART_Core.Utilities; namespace WART_Core.Hubs @@ -16,9 +17,10 @@ namespace WART_Core.Hubs public abstract class WartHubBase : Hub { /// - /// Stores active connections with their respective identifiers. + /// Stores active connections for each hub, with their respective identifiers. /// - private static readonly ConcurrentDictionary _connections = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary> _connectionsByHub + = new(); /// /// Logger instance for logging hub activities. @@ -42,17 +44,19 @@ protected WartHubBase(ILogger logger) public override async Task OnConnectedAsync() { var httpContext = Context.GetHttpContext(); - var wartGroup = httpContext.Request.Query["WartGroup"].ToString(); - var userName = Context.User?.Identity?.Name ?? "Anonymous"; + var wartGroup = httpContext?.Request?.Query["WartGroup"].ToString(); + var userName = Context.User?.Identity?.Name ?? Context.UserIdentifier ?? "Anonymous"; - _connections.TryAdd(Context.ConnectionId, userName); + var bucket = _connectionsByHub.GetOrAdd(GetType(), _ => new ConcurrentDictionary()); + bucket[Context.ConnectionId] = userName; if (!string.IsNullOrEmpty(wartGroup)) { await AddToGroup(wartGroup); } - _logger?.LogInformation("OnConnect: ConnectionId={ConnectionId}, User={UserName}", Context.ConnectionId, LogSanitizer.Sanitize(userName)); + _logger?.LogInformation("OnConnected: ConnectionId={ConnectionId}, User={User}", + Context.ConnectionId, LogSanitizer.Sanitize(userName)); await base.OnConnectedAsync(); } @@ -65,7 +69,11 @@ public override async Task OnConnectedAsync() /// A task that represents the asynchronous operation. public override Task OnDisconnectedAsync(Exception exception) { - _connections.TryRemove(Context.ConnectionId, out _); + if (_connectionsByHub.TryGetValue(GetType(), out var dict)) + { + dict.TryRemove(Context.ConnectionId, out _); + if (dict.IsEmpty) _connectionsByHub.TryRemove(GetType(), out _); + } if (exception != null) { @@ -84,7 +92,7 @@ public override Task OnDisconnectedAsync(Exception exception) /// /// The name of the SignalR group to add the connection to. /// A task that represents the asynchronous operation. - public async Task AddToGroup(string groupName) + protected async Task AddToGroup(string groupName) { if (string.IsNullOrWhiteSpace(groupName)) { @@ -94,7 +102,8 @@ public async Task AddToGroup(string groupName) await Groups.AddToGroupAsync(Context.ConnectionId, groupName); - _logger?.LogInformation("Connection {ConnectionId} added to group {GroupName}", Context.ConnectionId, LogSanitizer.Sanitize(groupName)); + _logger?.LogInformation("Connection {ConnectionId} added to group {GroupName}", + Context.ConnectionId, LogSanitizer.Sanitize(groupName)); } /// @@ -102,7 +111,7 @@ public async Task AddToGroup(string groupName) /// /// The name of the SignalR group to remove the connection from. /// A task that represents the asynchronous operation. - public async Task RemoveFromGroup(string groupName) + protected async Task RemoveFromGroup(string groupName) { if (string.IsNullOrWhiteSpace(groupName)) { @@ -112,21 +121,20 @@ public async Task RemoveFromGroup(string groupName) await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); - _logger?.LogInformation("Connection {ConnectionId} removed from group {GroupName}", Context.ConnectionId, LogSanitizer.Sanitize(groupName)); + _logger?.LogInformation("Connection {ConnectionId} removed from group {GroupName}", + Context.ConnectionId, LogSanitizer.Sanitize(groupName)); } - /// - /// Gets the current number of active connections. - /// - /// The count of active connections. public static int GetConnectionsCount() - { - return _connections.Count; - } + => _connectionsByHub.Values.Sum(d => d.Count); - /// - /// Returns a value indicating whether there are connected clients. - /// - public static bool HasConnectedClients => !_connections.IsEmpty; + public static bool HasConnectedClients + => _connectionsByHub.Values.Any(d => !d.IsEmpty); + + public static int GetConnectionsCountFor() where THub : Hub + => _connectionsByHub.TryGetValue(typeof(THub), out var d) ? d.Count : 0; + + public static bool HasConnectedClientsFor() where THub : Hub + => GetConnectionsCountFor() > 0; } } \ No newline at end of file diff --git a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs index 339fc2f..17586d3 100755 --- a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs +++ b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs @@ -97,7 +97,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app /// The IApplicationBuilder to configure the middleware pipeline. /// The custom SignalR hub name (URL path). /// The updated IApplicationBuilder to continue configuration. - /// Thrown when the hub name is null or empty. + /// Thrown when the hub name is null or empty. public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName) { if (string.IsNullOrEmpty(hubName)) @@ -124,7 +124,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app /// The IApplicationBuilder to configure the middleware pipeline. /// The list of custom SignalR hub names (URL paths). /// The updated IApplicationBuilder to continue configuration. - /// Thrown when the hub name list is null. + /// Thrown when the hub name list is null. public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, IEnumerable hubNameList) { ArgumentNullException.ThrowIfNull(hubNameList); @@ -157,7 +157,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app /// The custom SignalR hub name (URL path). /// The type of SignalR hub to configure, determining if authentication is required. /// The updated IApplicationBuilder to continue configuration. - /// Thrown when the hub name is null or empty. + /// Thrown when the hub name is null or empty. public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName, HubType hubType) { if (string.IsNullOrEmpty(hubName)) @@ -214,7 +214,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app /// The list of custom SignalR hub names (URL paths). /// The type of SignalR hub to configure, determining if authentication is required. /// The updated IApplicationBuilder to continue configuration. - /// Thrown when the hub name list is null. + /// Thrown when the hub name list is null. public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, IEnumerable hubNameList, HubType hubType) { ArgumentNullException.ThrowIfNull(hubNameList); diff --git a/src/WART-Core/Services/WartEventWorker.cs b/src/WART-Core/Services/WartEventWorker.cs index 1928ee0..60413a9 100644 --- a/src/WART-Core/Services/WartEventWorker.cs +++ b/src/WART-Core/Services/WartEventWorker.cs @@ -48,7 +48,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) while (!stoppingToken.IsCancellationRequested) { // Check if there are any connected clients. - if (!WartHubBase.HasConnectedClients) + if (!WartHubBase.HasConnectedClientsFor()) { await Task.Delay(NoClientsDelayMs, stoppingToken); continue; @@ -92,6 +92,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// private async Task SendToHub(WartEvent wartEvent, List filters, CancellationToken cancellationToken) { + if (filters?.OfType().Any() == true) + { + return; + } + try { // Retrieve the target groups based on the filters. @@ -123,17 +128,15 @@ private async Task SendToHub(WartEvent wartEvent, List filters, /// /// The list of filters that may contain group-related information. /// A list of group names to send the WartEvent to. - private List GetTargetGroups(List filters) + private static List GetTargetGroups(List filters) { - var groups = new List(); - - var groupAttr = filters?.OfType().FirstOrDefault(); - if (groupAttr is not null && groupAttr.GroupNames is not null) - { - groups.AddRange(groupAttr.GroupNames); - } - - return groups; + var attr = filters?.OfType().FirstOrDefault(); + return attr?.GroupNames? + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList() + ?? []; } /// @@ -142,12 +145,13 @@ private List GetTargetGroups(List filters) private async Task SendEventToGroup(WartEvent wartEvent, string group, CancellationToken cancellationToken) { // Send the event to the group using SignalR. - await _hubContext?.Clients + await _hubContext.Clients .Group(group) .SendAsync("Send", wartEvent.ToString(), cancellationToken); // Log the event sent to the group. - _logger?.LogInformation($"Group: {group}, WartEvent: {wartEvent}"); + _logger?.LogInformation("Group: {group}, WartEvent: {wartEvent}", + group, wartEvent); } /// @@ -156,11 +160,12 @@ await _hubContext?.Clients private async Task SendEventToAllClients(WartEvent wartEvent, CancellationToken cancellationToken) { // Send the event to all clients using SignalR. - await _hubContext?.Clients.All + await _hubContext.Clients.All .SendAsync("Send", wartEvent.ToString(), cancellationToken); // Log the event sent to all clients. - _logger?.LogInformation("Event: {EventName}, Details: {EventDetails}", nameof(WartEvent), wartEvent.ToString()); + _logger?.LogInformation("Event: {EventName}, Details: {EventDetails}", + nameof(WartEvent), wartEvent.ToString()); } } } \ No newline at end of file diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 7db8f8a..2042972 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -19,7 +19,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From ba724ec6d34a87d127c95ef6790ba687744a80ca Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 23 Aug 2025 15:45:47 +0200 Subject: [PATCH 16/17] docs(readme): improve documentation --- README.md | 165 +++++++++++++++++++++++++++++------------------------- 1 file changed, 90 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 4dc123a..190eb6b 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,29 @@ -WART is a C# .NET library that enables you to extend any Web API controller and forward incoming calls directly to a SignalR hub. This hub then broadcasts notifications containing detailed information about the calls, including both the request and the response. Additionally, WART supports JWT authentication for secure communication with SignalR. - -## Features +WART is a lightweight C# .NET library that extends your Web API controllers to forward incoming calls directly to a SignalR Hub. +The Hub broadcasts rich, structured events containing request and response details in **real-time**. +Supports **JWT** and **Cookie Authentication** for secure communication. + +## 📑 Table of Contents +- [Features](#-features) +- [Installation](#-installation) +- [How It Works](#️-how-it-works) +- [Usage](#-usage) + - [Basic Setup](#basic-setup) + - [Using JWT Authentication](#using-jwt-authentication) + - [Custom Hub Names](#custom-hub-names) + - [Multiple Hubs](#multiple-hubs) + - [Client Example](#client-example) +- [Supported Authentication Modes](#-supported-authentication-modes) +- [Excluding APIs from Event Propagation](#-excluding-apis-from-event-propagation) +- [Group-based Event Dispatching](#-group-based-event-dispatching) +- [NuGet](#-nuget) +- [Contributing](#-contributing) +- [License](#-license) +- [Contact](#-contact) + +## ✨ Features - Converts REST API calls into SignalR events, enabling real-time communication. - Provides controllers (`WartController`, `WartControllerJwt`, `WartControllerCookie`) for automatic SignalR event broadcasting. - Supports JWT authentication for SignalR hub connections. @@ -19,19 +39,23 @@ WART is a C# .NET library that enables you to extend any Web API controller and - Enables group-specific event dispatching with `[GroupWart("group_name")]`. - Configurable middleware (`AddWartMiddleware`) for flexible integration. -## Installation -You can install the library via the NuGet package manager with the following command: +## 📦 Installation +Install from **NuGet** ```bash dotnet add package WART-Core ``` -### How it works -WART implements a custom controller which overrides the `OnActionExecuting` and `OnActionExecuted` methods to retrieve the request and the response and encapsulates them in a **WartEvent** object which will be sent via SignalR on the **WartHub**. - -### How to use it +### ⚙️ How it works +WART overrides `OnActionExecuting` and `OnActionExecuted` in a custom base controller. +For every API request/response: +1) Captures request and response data. +2) Wraps them in a `WartEvent`. +3) Publishes it through a SignalR Hub to all connected clients. -To use the WART library, each WebApi controller must extend the **WartController** controller: +## 🚀 Usage +### Basic Setup +Extend your API controllers from `WartController`: ```csharp using WART_Core.Controllers; @@ -40,104 +64,99 @@ using WART_Core.Hubs; [ApiController] [Route("api/[controller]")] public class TestController : WartController -``` - -each controller must implement the following constructor, for example: - -```csharp -public TestController(IHubContext messageHubContext, -ILogger logger) : base(messageHubContext, logger) { + public TestController(IHubContext hubContext, ILogger logger) + : base(hubContext, logger) { } } ``` -WART support JWT bearer authentication on SignalR hub, if you want to use JWT authentication use the following controller extension: - -```csharp -using WART_Core.Controllers; -using WART_Core.Hubs; - -[ApiController] -[Route("api/[controller]")] -public class TestController : WartControllerJwt -``` - -You also need to enable SignalR in the WebAPI solution and map the **WartHub**. -To do this, add the following configurations in the Startup.cs class: +Register WART in `Startup.cs`: ```csharp using WART_Core.Middleware; -``` -In the ConfigureServices section add following: +public void ConfigureServices(IServiceCollection services) +{ + services.AddWartMiddleware(); // No authentication +} -```csharp -services.AddWartMiddleware(); +public void Configure(IApplicationBuilder app) +{ + app.UseWartMiddleware(); +} ``` -or by specifying JWT authentication: - +### Using JWT Authentication ```csharp -services.AddWartMiddleware(hubType:HubType.JwtAuthentication, tokenKey:"password_here"); +services.AddWartMiddleware(hubType: HubType.JwtAuthentication, tokenKey: "your_secret_key"); +app.UseWartMiddleware(HubType.JwtAuthentication); ``` -In the Configure section add the following: +Extend from `WartControllerJwt`: ```csharp -app.UseWartMiddleware(); +public class TestController : WartControllerJwt +{ + public TestController(IHubContext hubContext, ILogger logger) + : base(hubContext, logger) { } +} ``` -or by specifying JWT authentication: +### Custom Hub Names +You can specify custom hub routes: ```csharp -app.UseWartMiddleware(HubType.JwtAuthentication); +app.UseWartMiddleware("customhub"); ``` -Alternatively, it is possible to specify a custom hub name: +### Multiple Hubs +You can configure multiple hubs at once by passing a list of hub names: ```csharp -app.UseWartMiddleware("hubname"); +var hubs = new[] { "orders", "products", "notifications" }; + +app.UseWartMiddleware(hubs); ``` -at this point it will be sufficient to connect via SignalR to the WartHub to receive notifications in real time of any call on the controller endpoints. -For example: +This is useful for separating traffic by domain. + +### Client Example +#### Without authentication: ```csharp var hubConnection = new HubConnectionBuilder() - .WithUrl("http://localhost:52086/warthub") + .WithUrl("http://localhost:5000/warthub") .Build(); - -hubConnection.On("Send", (data) => + +hubConnection.On("Send", data => { - // data is the WartEvent JSON + // 'data' is a WartEvent JSON }); + +await hubConnection.StartAsync(); ``` -or with JWT authentication: +#### With JWT authentication: ```csharp var hubConnection = new HubConnectionBuilder() - .WithUrl($"http://localhost:51392/warthub", options => + .WithUrl("http://localhost:5000/warthub", options => { - options.SkipNegotiation = true; - options.Transports = HttpTransportType.WebSockets; options.AccessTokenProvider = () => Task.FromResult(GenerateToken()); }) .WithAutomaticReconnect() .Build(); - -hubConnection.On("Send", (data) => + +hubConnection.On("Send", data => { - // data is the WartEvent JSON + // Handle WartEvent JSON }); -``` - -In the source code you can find a simple test client and WebApi project. -## Supported Authentication Modes +await hubConnection.StartAsync(); +``` -The project supports three authentication modes for accessing the SignalR Hub: +## 🔐 Supported Authentication Modes | Mode | Description | Hub Class | Required Middleware | |--------------------------|---------------------------------------------------------------------------|----------------------|---------------------------| @@ -147,7 +166,7 @@ The project supports three authentication modes for accessing the SignalR Hub: > ⚙️ Authentication mode is selected through the `HubType` configuration in the application startup. -### Excluding APIs from Event Propagation +### 🚫 Excluding APIs from Event Propagation There might be scenarios where you want to exclude specific APIs from propagating events to connected clients. This can be particularly useful when certain endpoints should not trigger updates, notifications, or other real-time messages through SignalR. To achieve this, you can use a custom filter called `ExcludeWartAttribute`. By decorating the desired API endpoints with this attribute, you can prevent them from being included in the SignalR event propagation logic, for example: ```csharp @@ -164,7 +183,7 @@ public ActionResult Get(int id) } ``` -### SignalR Event Dispatching for Specific Groups +### 👥 Group-based Event Dispatching WART enables sending API events to specific groups in SignalR by specifying the group name in the query string. This approach allows for flexible and targeted event broadcasting, ensuring that only the intended group of clients receives the event. By decorating an API method with `[GroupWart("group_name")]`, it is possible to specify the SignalR group name to which the dispatch of specific events for that API is restricted. This ensures that only the clients subscribed to the specified group ("SampleGroupName") will receive the related events, allowing for targeted, group-based communication in a SignalR environment. @@ -180,24 +199,20 @@ public ActionResult Post([FromBody] TestEntity entity) By appending `?WartGroup=group_name` to the URL, the library enables dispatching events from individual APIs to a specific SignalR group, identified by `group_name`. This allows for granular control over which clients receive the event, leveraging SignalR’s built-in group functionality. -### NuGet - -The library is available on NuGet packetmanager. - -https://www.nuget.org/packages/WART-Core/ - -### Contributing -Thank you for considering to help out with the source code! -If you'd like to contribute, please fork, fix, commit and send a pull request for the maintainers to review and merge into the main code base. +### 📦 NuGet +The library is available on [NuGet](https://www.nuget.org/packages/WART-Core/). -**Getting started with Git and GitHub** +### 🤝 Contributing +Contributions are welcome! +Steps to get started: * [Setting up Git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git) * [Fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) * [Open an issue](https://github.com/engineering87/WART/issues) if you encounter a bug or have a suggestion for improvements/features + * Submit a Pull Request. -### Licensee +### 📄 License WART source code is available under MIT License, see license in the source. -### Contact +### 📬 Contact Please contact at francesco.delre[at]protonmail.com for any details. From dd4ac228bf51c1d2c3267b64aeab113c2caa6bcf Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 23 Aug 2025 20:28:33 +0200 Subject: [PATCH 17/17] chore(core): upgrade to .NET 9 and apply various fixes and improvements --- .../JWT/JwtServiceCollectionExtension.cs | 1 + src/WART-Core/Hubs/WartHub.cs | 4 +- .../WartApplicationBuilderExtension.cs | 73 ++++++++------- .../JsonArrayOrObjectStringConverter.cs | 55 ++++++----- src/WART-Core/Services/WartEventWorker.cs | 2 +- src/WART-Core/WART-Core.csproj | 2 +- .../Controllers/WartBaseControllerTests.cs | 92 +++++++++++++++++++ src/WART-Tests/Entity/WartEventTests.cs | 24 ++--- .../JsonArrayOrObjectStringConverterTests.cs | 66 +++++++++++++ 9 files changed, 245 insertions(+), 74 deletions(-) create mode 100644 src/WART-Tests/Controllers/WartBaseControllerTests.cs create mode 100644 src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs diff --git a/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs b/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs index 168972d..f1526e1 100755 --- a/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs +++ b/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs @@ -68,6 +68,7 @@ public static IServiceCollection AddJwtMiddleware(this IServiceCollection servic ValidateIssuer = false, ValidateActor = false, ValidateLifetime = true, + ValidateIssuerSigningKey = true, IssuerSigningKey = securityKey }; options.Events = new JwtBearerEvents diff --git a/src/WART-Core/Hubs/WartHub.cs b/src/WART-Core/Hubs/WartHub.cs index 4e785ae..f069ca6 100755 --- a/src/WART-Core/Hubs/WartHub.cs +++ b/src/WART-Core/Hubs/WartHub.cs @@ -1,7 +1,7 @@ // (c) 2019 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) -using Microsoft.Extensions.Logging; - +using Microsoft.Extensions.Logging; + namespace WART_Core.Hubs { /// diff --git a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs index 17586d3..a570715 100755 --- a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs +++ b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs @@ -18,6 +18,13 @@ public static class WartApplicationBuilderExtension private static string NormalizeHubPath(string name) => "/" + (name ?? string.Empty).Trim().Trim('/'); + private static IReadOnlyList GetDistinctPaths(IEnumerable hubNameList) + => (hubNameList ?? []) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(NormalizeHubPath) + .Distinct(StringComparer.Ordinal) + .ToList(); + /// /// Configures and adds the WART middleware to the IApplicationBuilder. /// This method sets up the default SignalR hub (warthub) without authentication. @@ -219,45 +226,43 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app { ArgumentNullException.ThrowIfNull(hubNameList); + var paths = GetDistinctPaths(hubNameList); + app.UseForwardedHeaders(); app.UseResponseCompression(); app.UseRouting(); - foreach (var hubName in hubNameList.Distinct()) + switch (hubType) { - switch (hubType) - { - default: - case HubType.NoAuthentication: - { - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub(NormalizeHubPath(hubName)); - }); - break; - } - case HubType.JwtAuthentication: - { - app.UseJwtMiddleware(); - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub(NormalizeHubPath(hubName)); - }); - break; - } - case HubType.CookieAuthentication: - { - app.UseCookieMiddleware(); - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub(NormalizeHubPath(hubName)); - }); - break; - } - } + default: + case HubType.NoAuthentication: + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + foreach (var path in paths) + endpoints.MapHub(path); + }); + break; + + case HubType.JwtAuthentication: + app.UseJwtMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + foreach (var path in paths) + endpoints.MapHub(path); + }); + break; + + case HubType.CookieAuthentication: + app.UseCookieMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + foreach (var path in paths) + endpoints.MapHub(path); + }); + break; } return app; diff --git a/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs index c2c8092..a729a12 100644 --- a/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs +++ b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs @@ -6,45 +6,50 @@ namespace WART_Core.Serialization { + /// + /// A custom JSON converter that allows a string property to accept JSON objects or arrays + /// as raw JSON text. This is useful when the schema is flexible, and a value could be a + /// primitive string or an entire JSON structure (array or object). + /// public class JsonArrayOrObjectStringConverter : JsonConverter { + /// + /// Reads JSON tokens and returns them as a raw JSON string. + /// If the token is a string, returns its value. + /// If it's an object or array, serializes it to a JSON string. + /// public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - return reader.GetString(); + return reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + JsonTokenType.StartObject or JsonTokenType.StartArray => JsonDocument.ParseValue(ref reader).RootElement.GetRawText(), + JsonTokenType.Null => null, + _ => reader.GetString() + }; } + /// + /// Writes a string to the JSON output. + /// If the string contains valid JSON (object or array), it writes it as raw JSON. + /// Otherwise, it writes it as a simple string value. + /// public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) { - if (string.IsNullOrEmpty(value)) + if (string.IsNullOrWhiteSpace(value)) { writer.WriteStringValue(value); return; } - using (JsonDocument doc = JsonDocument.Parse(value)) + try + { + using var doc = JsonDocument.Parse(value); + doc.RootElement.WriteTo(writer); + } + catch { - if (doc.RootElement.ValueKind == JsonValueKind.Array) - { - writer.WriteStartArray(); - foreach (var element in doc.RootElement.EnumerateArray()) - { - element.WriteTo(writer); - } - writer.WriteEndArray(); - } - else if (doc.RootElement.ValueKind == JsonValueKind.Object) - { - writer.WriteStartObject(); - foreach (var property in doc.RootElement.EnumerateObject()) - { - property.WriteTo(writer); - } - writer.WriteEndObject(); - } - else - { - writer.WriteStringValue(value); - } + writer.WriteStringValue(value); } } } diff --git a/src/WART-Core/Services/WartEventWorker.cs b/src/WART-Core/Services/WartEventWorker.cs index 60413a9..c565157 100644 --- a/src/WART-Core/Services/WartEventWorker.cs +++ b/src/WART-Core/Services/WartEventWorker.cs @@ -151,7 +151,7 @@ await _hubContext.Clients // Log the event sent to the group. _logger?.LogInformation("Group: {group}, WartEvent: {wartEvent}", - group, wartEvent); + group, wartEvent.ToString()); } /// diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 73365fc..17026ae 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -14,7 +14,7 @@ 4.0.0.0 4.0.0.0 - 5.4.0 + 6.0.0 icon.png README.md diff --git a/src/WART-Tests/Controllers/WartBaseControllerTests.cs b/src/WART-Tests/Controllers/WartBaseControllerTests.cs new file mode 100644 index 0000000..b5f73b7 --- /dev/null +++ b/src/WART-Tests/Controllers/WartBaseControllerTests.cs @@ -0,0 +1,92 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using WART_Core.Controllers; +using WART_Core.Filters; +using WART_Core.Services; + +namespace WART_Tests.Controllers +{ + public class WartBaseControllerTests + { + public class TestHub : Hub { } + + private class SutController : WartBaseController + { + public SutController(IHubContext hubContext, ILogger logger) : base(hubContext, logger) { } + public IActionResult OkResult() => new OkObjectResult(new { ok = true }); + } + + private (SutController ctrl, ActionExecutingContext aex, ActionExecutedContext aed, WartEventQueueService queue) Arrange(ObjectResult result, IList filters) + { + var services = new ServiceCollection(); + var queue = new WartEventQueueService(); + services.AddSingleton(queue); + var sp = services.BuildServiceProvider(); + + var http = new DefaultHttpContext { RequestServices = sp }; + http.Request.Method = "GET"; + http.Request.Path = "/api/test"; + + var actionCtx = new ActionContext(http, new Microsoft.AspNetCore.Routing.RouteData(), + new ControllerActionDescriptor(), modelState: new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary()); + + var aex = new ActionExecutingContext(actionCtx, new List(), new Dictionary { { "id", 1 } }, controller: null); + var aed = new ActionExecutedContext(actionCtx, filters, controller: null) { Result = result }; + + var hubCtx = new Mock>().Object; + var logger = new Mock().Object; + var ctrl = new SutController(hubCtx, logger); + + return (ctrl, aex, aed, queue); + } + + [Fact] + public void Enqueues_On_ObjectResult_Without_Exclude() + { + var filters = new List(); + var (ctrl, aex, aed, queue) = Arrange(new OkObjectResult(new { x = 1 }), filters); + + ctrl.OnActionExecuting(aex); + ctrl.OnActionExecuted(aed); + + Assert.Equal(1, queue.Count); + Assert.True(queue.TryPeek(out var item)); + Assert.NotNull(item.WartEvent); + Assert.Equal("GET", item.WartEvent.HttpMethod); + Assert.Equal("/api/test", item.WartEvent.HttpPath); + } + + [Fact] + public void DoesNotEnqueue_When_Excluded() + { + var filters = new List { new ExcludeWartAttribute() }; + var (ctrl, aex, aed, queue) = Arrange(new OkObjectResult(new { x = 1 }), filters); + + ctrl.OnActionExecuting(aex); + ctrl.OnActionExecuted(aed); + + Assert.True(queue.IsEmpty); + } + + [Fact] + public void Preserves_Filters_For_Worker_GroupUsage() + { + var filters = new List { new GroupWartAttribute("g1", "g2") }; + var (ctrl, aex, aed, queue) = Arrange(new OkObjectResult(new { x = 1 }), filters); + + ctrl.OnActionExecuting(aex); + ctrl.OnActionExecuted(aed); + + Assert.True(queue.TryPeek(out var item)); + Assert.Contains(item.Filters, f => f is GroupWartAttribute); + } + } +} diff --git a/src/WART-Tests/Entity/WartEventTests.cs b/src/WART-Tests/Entity/WartEventTests.cs index 4fba799..3d2c951 100644 --- a/src/WART-Tests/Entity/WartEventTests.cs +++ b/src/WART-Tests/Entity/WartEventTests.cs @@ -1,6 +1,8 @@ // (c) 2024 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) -namespace WART_Core.Entity.Tests +using WART_Core.Entity; + +namespace WART_Tests.Entity { public class WartEventTests { @@ -17,14 +19,14 @@ public void WartEvent_ConstructorWithParameters_ShouldSetProperties() // Assert Assert.NotEqual(Guid.Empty, wartEvent.EventId); - Assert.NotEqual(default(DateTime), wartEvent.TimeStamp); - Assert.NotEqual(default(DateTime), wartEvent.UtcTimeStamp); + Assert.NotEqual(default, wartEvent.TimeStamp); + Assert.NotEqual(default, wartEvent.UtcTimeStamp); Assert.Equal(httpMethod, wartEvent.HttpMethod); Assert.Equal(httpPath, wartEvent.HttpPath); Assert.Equal(remoteAddress, wartEvent.RemoteAddress); - Assert.Null(wartEvent.JsonRequestPayload); - Assert.Null(wartEvent.JsonResponsePayload); - Assert.Null(wartEvent.ExtraInfo); + Assert.True(string.IsNullOrEmpty(wartEvent.JsonRequestPayload)); + Assert.True(string.IsNullOrEmpty(wartEvent.JsonResponsePayload)); + Assert.True(string.IsNullOrEmpty(wartEvent.ExtraInfo)); } [Fact] @@ -42,14 +44,14 @@ public void WartEvent_ConstructorWithRequestAndResponse_ShouldSetProperties() // Assert Assert.NotEqual(Guid.Empty, wartEvent.EventId); - Assert.NotEqual(default(DateTime), wartEvent.TimeStamp); - Assert.NotEqual(default(DateTime), wartEvent.UtcTimeStamp); + Assert.NotEqual(default, wartEvent.TimeStamp); + Assert.NotEqual(default, wartEvent.UtcTimeStamp); Assert.Equal(httpMethod, wartEvent.HttpMethod); Assert.Equal(httpPath, wartEvent.HttpPath); Assert.Equal(remoteAddress, wartEvent.RemoteAddress); - Assert.NotNull(wartEvent.JsonRequestPayload); - Assert.NotNull(wartEvent.JsonResponsePayload); - Assert.Null(wartEvent.ExtraInfo); + Assert.False(string.IsNullOrEmpty(wartEvent.JsonRequestPayload)); + Assert.False(string.IsNullOrEmpty(wartEvent.JsonResponsePayload)); + Assert.True(string.IsNullOrEmpty(wartEvent.ExtraInfo)); } [Fact] diff --git a/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs b/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs new file mode 100644 index 0000000..5530bac --- /dev/null +++ b/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs @@ -0,0 +1,66 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using System.Text.Json; +using WART_Core.Entity; +using WART_Core.Serialization; + +namespace WART_Tests.Serialization +{ + public class JsonArrayOrObjectStringConverterTests + { + private readonly JsonSerializerOptions _opts = new() + { + Converters = { new JsonArrayOrObjectStringConverter() } + }; + + private class Wrapper { public string Payload { get; set; } } + + [Fact] + public void Write_String_RemainsString() + { + var obj = new Wrapper { Payload = "hello" }; + var json = JsonSerializer.Serialize(obj, _opts); + Assert.Contains("\"Payload\":\"hello\"", json); + } + + [Fact] + public void Write_ObjectString_WritesAsObject() + { + var obj = new Wrapper { Payload = "{\"a\":1}" }; + var json = JsonSerializer.Serialize(obj, _opts); + Assert.Contains("\"Payload\":{\"a\":1}", json); + } + + [Fact] + public void Write_ArrayString_WritesAsArray() + { + var obj = new Wrapper { Payload = "[1,2,3]" }; + var json = JsonSerializer.Serialize(obj, _opts); + Assert.Contains("\"Payload\":[1,2,3]", json); + } + + [Fact] + public void Write_InvalidJson_FallsBackToString() + { + var obj = new Wrapper { Payload = "{not json}" }; + var json = JsonSerializer.Serialize(obj, _opts); + Assert.Contains("\"Payload\":\"{not json}\"", json); + } + + [Fact] + public void Read_ObjectToken_ReturnsRawJson() + { + var json = "{\"Payload\": {\"x\":42}}"; + var wrapper = JsonSerializer.Deserialize(json, _opts); + Assert.Equal("{\"x\":42}", wrapper.Payload); + } + + [Fact] + public void WartEvent_ToString_IsValidJson() + { + var e = new WartEvent("GET", "/api/test", "127.0.0.1") { ExtraInfo = "ok" }; + var json = e.ToString(); + using var _ = JsonDocument.Parse(json); + } + } +}