diff --git a/Directory.Packages.props b/Directory.Packages.props index 361fc59c..4e24d7e0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,11 +7,11 @@ - - - - - + + + + + diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsWolverineExtension.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsWolverineExtension.cs new file mode 100644 index 00000000..01a4274f --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsWolverineExtension.cs @@ -0,0 +1,16 @@ +using Wolverine; +using Wolverine.Attributes; + +[assembly: WolverineModule(typeof(SimpleModule.AuditLogs.AuditLogsWolverineExtension))] + +namespace SimpleModule.AuditLogs; + +#pragma warning disable CA1812 // Instantiated by Wolverine via [WolverineModule] +internal sealed class AuditLogsWolverineExtension : IWolverineExtension +#pragma warning restore CA1812 +{ + public void Configure(WolverineOptions options) + { + options.Discovery.IncludeAssembly(typeof(AuditLogsWolverineExtension).Assembly); + } +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Middleware/AuditMiddleware.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Middleware/AuditMiddleware.cs index 1e3d38a5..2c10d630 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Middleware/AuditMiddleware.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Middleware/AuditMiddleware.cs @@ -6,19 +6,43 @@ using SimpleModule.AuditLogs.Enrichment; using SimpleModule.AuditLogs.Pipeline; using SimpleModule.Settings.Contracts; +using ZiggyCreatures.Caching.Fusion; namespace SimpleModule.AuditLogs.Middleware; -public sealed class AuditMiddleware(RequestDelegate next) +public sealed class AuditMiddleware(RequestDelegate next, IFusionCache cache) { private static readonly string[] ExcludedMethodsForBody = ["HEAD", "OPTIONS"]; + private static readonly string[] DefaultExcludedPaths = + [ + "/health", + "/metrics", + "/_content", + "/js/", + "/css/", + "/favicon", + ]; + + private static readonly FusionCacheEntryOptions AuditConfigCacheOptions = new() + { + Duration = TimeSpan.FromSeconds(60), + }; + + private static readonly AuditRequestSettings FallbackSettings = new( + CaptureHttp: true, + CaptureRequestBodies: true, + CaptureQueryStrings: true, + CaptureUserAgent: false, + ExcludedPaths: DefaultExcludedPaths + ); + private sealed record AuditRequestSettings( bool CaptureHttp, bool CaptureRequestBodies, bool CaptureQueryStrings, bool CaptureUserAgent, - IReadOnlyList ExcludedPaths + string[] ExcludedPaths ); public async Task InvokeAsync(HttpContext context) @@ -30,9 +54,9 @@ public async Task InvokeAsync(HttpContext context) return; } - // Load all settings once at the start (parallel batch loading) - var settings = context.RequestServices.GetService(); - var auditSettings = await LoadSettingsAsync(settings); + // Single composite cache entry — avoids 5 separate FusionCache lookups per + // request and resolves the scoped ISettingsContracts only on cache miss. + var auditSettings = await LoadSettingsAsync(context.RequestServices, cache); // Check if HTTP capture is enabled if (!auditSettings.CaptureHttp) @@ -115,20 +139,24 @@ public async Task InvokeAsync(HttpContext context) channel.Enqueue(entry); } - private static async Task LoadSettingsAsync(ISettingsContracts? settings) + private static async Task LoadSettingsAsync( + IServiceProvider services, + IFusionCache cache + ) { - if (settings is null) - { - return new AuditRequestSettings( - CaptureHttp: true, - CaptureRequestBodies: true, - CaptureQueryStrings: true, - CaptureUserAgent: false, - ExcludedPaths: GetDefaultExcludedPaths() - ); - } + return await cache.GetOrSetAsync( + AuditCacheKeys.RequestConfig, + async (_, _) => + { + var settings = services.GetService(); + return settings is null ? FallbackSettings : await BuildSettingsAsync(settings); + }, + AuditConfigCacheOptions + ); + } - // Fetch all settings in parallel + private static async Task BuildSettingsAsync(ISettingsContracts settings) + { var (captureHttp, captureBody, captureQs, captureUa, excludedPathsRaw) = await LoadAllSettingsAsync(settings); @@ -175,29 +203,29 @@ ISettingsContracts settings return (captureHttp, captureBody, captureQs, captureUa, results[4]); } - private static List ParseExcludedPaths(string? rawPaths) + private static string[] ParseExcludedPaths(string? rawPaths) { - var paths = GetDefaultExcludedPaths(); - if (string.IsNullOrWhiteSpace(rawPaths)) { - return paths; + return DefaultExcludedPaths; } - var configured = rawPaths - .Split(',') - .Select(p => p.Trim()) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .ToList(); + var configured = rawPaths.Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); + if (configured.Length == 0) + { + return DefaultExcludedPaths; + } - paths.AddRange(configured); - return paths; + var merged = new string[DefaultExcludedPaths.Length + configured.Length]; + DefaultExcludedPaths.CopyTo(merged, 0); + configured.CopyTo(merged, DefaultExcludedPaths.Length); + return merged; } - private static List GetDefaultExcludedPaths() => - ["/health", "/metrics", "/_content", "/js/", "/css/", "/favicon"]; - - private static bool IsExcludedPath(string path, IEnumerable configuredPaths) + private static bool IsExcludedPath(string path, string[] configuredPaths) { foreach (var prefix in configuredPaths) { diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditCacheKeys.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditCacheKeys.cs new file mode 100644 index 00000000..989cc3f1 --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditCacheKeys.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.AuditLogs.Pipeline; + +internal static class AuditCacheKeys +{ + public const string RequestConfig = "auditlogs:request-config"; +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs new file mode 100644 index 00000000..0524b3da --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs @@ -0,0 +1,23 @@ +using SimpleModule.Core.Settings; +using SimpleModule.Settings.Contracts.Events; +using ZiggyCreatures.Caching.Fusion; + +namespace SimpleModule.AuditLogs.Pipeline; + +internal static class AuditConfigCacheInvalidator +{ + public static ValueTask Handle(SettingChangedEvent @event, IFusionCache cache) + { + if (@event.Scope != SettingScope.System) + { + return ValueTask.CompletedTask; + } + + if (!@event.Key.StartsWith("auditlogs.", StringComparison.Ordinal)) + { + return ValueTask.CompletedTask; + } + + return cache.RemoveAsync(AuditCacheKeys.RequestConfig); + } +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs index 2673648a..301f1fba 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs @@ -23,25 +23,54 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - if (await channel.Reader.WaitToReadAsync(stoppingToken)) + if (!await channel.Reader.WaitToReadAsync(stoppingToken)) { - batch.Clear(); - var deadline = DateTimeOffset.UtcNow.Add(opts.WriterFlushInterval); - - while ( - batch.Count < opts.WriterBatchSize - && DateTimeOffset.UtcNow < deadline - && channel.Reader.TryRead(out var entry) - ) + break; + } + + batch.Clear(); + + while (batch.Count < opts.WriterBatchSize && channel.Reader.TryRead(out var entry)) + { + batch.Add(entry); + } + + if (batch.Count < opts.WriterBatchSize) + { + using var linger = CancellationTokenSource.CreateLinkedTokenSource( + stoppingToken + ); + linger.CancelAfter(opts.WriterFlushInterval); + + try { - batch.Add(entry); - } + while (batch.Count < opts.WriterBatchSize) + { + if (!await channel.Reader.WaitToReadAsync(linger.Token)) + { + break; + } - if (batch.Count > 0) + while ( + batch.Count < opts.WriterBatchSize + && channel.Reader.TryRead(out var entry) + ) + { + batch.Add(entry); + } + } + } + catch (OperationCanceledException) { - await FlushBatchAsync(batch, stoppingToken); + // Linger deadline OR shutdown — fall through and persist + // whatever was already drained instead of dropping it. } } + + if (batch.Count > 0) + { + await FlushBatchAsync(batch); + } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -64,13 +93,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } if (batch.Count > 0) { - await FlushBatchAsync(batch, CancellationToken.None); + await FlushBatchAsync(batch); } LogStopped(logger); } - private async Task FlushBatchAsync(List batch, CancellationToken ct) + private async Task FlushBatchAsync(List batch) { await using var scope = scopeFactory.CreateAsyncScope(); var contracts = scope.ServiceProvider.GetRequiredService(); diff --git a/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditMiddlewareTests.cs b/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditMiddlewareTests.cs index 60208f3f..211a7e29 100644 --- a/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditMiddlewareTests.cs +++ b/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditMiddlewareTests.cs @@ -1,15 +1,22 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; using NSubstitute; using SimpleModule.AuditLogs.Middleware; using SimpleModule.AuditLogs.Pipeline; using SimpleModule.Core.Settings; using SimpleModule.Settings.Contracts; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.NullObjects; namespace AuditLogs.Tests.Unit; -public class AuditMiddlewareTests +public sealed class AuditMiddlewareTests : IDisposable { + private readonly NullFusionCache _cache = new(Options.Create(new FusionCacheOptions())); + + public void Dispose() => _cache.Dispose(); + [Fact] public async Task InvokeAsync_LoadsSettingsOnce_AndCachesForRequest() { @@ -24,7 +31,7 @@ public async Task InvokeAsync_LoadsSettingsOnce_AndCachesForRequest() // Configure settings to return default values settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns("true"); - var middleware = new AuditMiddleware(next); + var middleware = new AuditMiddleware(next, _cache); // Act await middleware.InvokeAsync(context); @@ -69,7 +76,7 @@ public async Task InvokeAsync_SkipsProcessing_WhenHttpCaptureDisabled() .Returns("false"); settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns("true"); - var middleware = new AuditMiddleware(next); + var middleware = new AuditMiddleware(next, _cache); // Act await middleware.InvokeAsync(context); @@ -95,7 +102,7 @@ public async Task InvokeAsync_SkipsProcessing_ForExcludedPath() settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns("true"); - var middleware = new AuditMiddleware(next); + var middleware = new AuditMiddleware(next, _cache); // Act await middleware.InvokeAsync(context); @@ -134,7 +141,7 @@ public async Task InvokeAsync_ExcludesPath_WhenConfigured() .GetSettingAsync("auditlogs.excluded.paths", Arg.Any()) .Returns("/api/custom-exclude"); - var middleware = new AuditMiddleware(next); + var middleware = new AuditMiddleware(next, _cache); // Act await middleware.InvokeAsync(context); @@ -171,7 +178,7 @@ public async Task InvokeAsync_MergesHardcodedAndConfiguredExcludedPaths() .GetSettingAsync("auditlogs.excluded.paths", Arg.Any()) .Returns("/api/internal"); - var middleware = new AuditMiddleware(next); + var middleware = new AuditMiddleware(next, _cache); // Act - test hardcoded /health path await middleware.InvokeAsync(context); @@ -195,7 +202,7 @@ public async Task InvokeAsync_PathMatching_IsCaseInsensitive() settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns("true"); - var middleware = new AuditMiddleware(next); + var middleware = new AuditMiddleware(next, _cache); // Act await middleware.InvokeAsync(context); @@ -216,7 +223,7 @@ public async Task InvokeAsync_UsesDefaultSettings_WhenSettingsServiceIsNull() context.Request.Method = "GET"; context.RequestServices = CreateServiceProviderWithoutSettings(channel); - var middleware = new AuditMiddleware(next); + var middleware = new AuditMiddleware(next, _cache); // Act await middleware.InvokeAsync(context); @@ -240,7 +247,7 @@ public async Task InvokeAsync_DefaultValues_AreCorrect() settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns((string?)null); // Simulate no setting found - var middleware = new AuditMiddleware(next); + var middleware = new AuditMiddleware(next, _cache); // Act await middleware.InvokeAsync(context); @@ -261,7 +268,7 @@ public async Task InvokeAsync_HandlesNullSettingsGracefully() context.Request.Method = "POST"; context.RequestServices = CreateServiceProviderWithoutSettings(channel); - var middleware = new AuditMiddleware(next); + var middleware = new AuditMiddleware(next, _cache); // Act & Assert - should not throw var action = () => middleware.InvokeAsync(context); diff --git a/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs b/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs index 60dc81d1..9afb8d52 100644 --- a/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs +++ b/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs @@ -19,6 +19,9 @@ public sealed class LocaleResolutionMiddleware( IFusionCache cache ) { + // Bound cache-key size: a hostile client could otherwise pin multi-KB strings. + private const int MaxAcceptLanguageKeyLength = 256; + private static readonly FusionCacheEntryOptions UserLocaleCacheOptions = new() { Duration = TimeSpan.FromMinutes(5), @@ -88,6 +91,11 @@ private async Task ResolveLocaleAsync(HttpContext context) return userLocale; } } + + // Cache the absence so subsequent requests skip the per-request settings + // probe. The cachedHit check above treats empty as "no value" and falls + // through, so this short-circuits only the inner DB/cache lookup. + await cache.SetAsync(cacheKey, string.Empty, UserLocaleCacheOptions); } // No explicit user setting — resolve from Accept-Language header or default @@ -142,6 +150,9 @@ private string GetDefaultLocale() private static string UserLocaleKey(string userId) => string.Concat("locale:user:", userId); - private static string AcceptLanguageKey(string headerValue) => - string.Concat("locale:accept:", headerValue); + private static string AcceptLanguageKey(string headerValue) + { + var len = Math.Min(headerValue.Length, MaxAcceptLanguageKeyLength); + return string.Concat("locale:accept:", headerValue.AsSpan(0, len)); + } } diff --git a/template/SimpleModule.Host/appsettings.Development.json b/template/SimpleModule.Host/appsettings.Development.json index 9df8ab6b..2680e643 100644 --- a/template/SimpleModule.Host/appsettings.Development.json +++ b/template/SimpleModule.Host/appsettings.Development.json @@ -10,7 +10,8 @@ }, "Logging": { "LogLevel": { - "Microsoft.EntityFrameworkCore.Database.Command": "Information" + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "ZiggyCreatures.Caching.Fusion": "Warning" }, "Console": { "FormatterName": "simple", diff --git a/template/SimpleModule.Host/appsettings.json b/template/SimpleModule.Host/appsettings.json index c1ac6311..4a189183 100644 --- a/template/SimpleModule.Host/appsettings.json +++ b/template/SimpleModule.Host/appsettings.json @@ -48,7 +48,9 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "ZiggyCreatures.Caching.Fusion": "Warning" } } }