using System.Security.Claims; using Microsoft.Extensions.Caching.Memory; using PowderCoating.Web.Services; namespace PowderCoating.Web.Middleware; /// /// Updates the OnlineUserTracker for every authenticated page request. /// Throttled to once per 60 seconds per user to avoid excessive work. /// public class OnlineUserMiddleware { private readonly RequestDelegate _next; private readonly IMemoryCache _cache; /// /// Initialises the middleware with the next request delegate in the pipeline. /// /// The next middleware component. /// Shared app cache used for expiring per-user throttle entries. public OnlineUserMiddleware(RequestDelegate next, IMemoryCache cache) { _next = next; _cache = cache; } /// /// Calls the downstream pipeline first, then — after the response is /// produced — records the requesting user as online in the tracker if the /// 60-second throttle window has elapsed. /// /// Tracking runs after the response (rather than before) so that /// slow downstream operations do not delay the response to the client. /// The tracker update is best-effort: if it throws, the exception propagates /// but does not affect the already-sent response. /// /// /// API, hub, and static-file requests are excluded because they are too /// frequent and do not represent meaningful user navigation activity. /// Static files are identified by the presence of a dot in the path /// (e.g. .js, .css), which is a pragmatic heuristic that /// avoids the overhead of checking extensions individually. /// /// /// The current HTTP context. /// /// Scoped service that maintains the in-memory registry of online users. /// public async Task InvokeAsync(HttpContext context, IOnlineUserTracker tracker) { await _next(context); // Skip AJAX/JSON responses — they are not page navigations and would // cause the "current page" to show the polling endpoint (e.g. /InAppNotifications/Recent) // rather than the actual page the user is on. if (context.Response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true) return; // Only track authenticated, non-API, non-asset requests if (!context.User.Identity?.IsAuthenticated ?? true) return; var path = context.Request.Path.Value ?? string.Empty; if (path.StartsWith("/hubs") || path.StartsWith("/api") || path.Contains('.')) // static files have extensions return; var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) return; // Throttle: only update the tracker once per 60 seconds per user. // IMemoryCache automatically expires old entries so the throttle state // does not grow without bound across the lifetime of the process. var now = DateTime.UtcNow; var throttleKey = $"online-user-touch:{userId}"; if (_cache.TryGetValue(throttleKey, out _)) return; _cache.Set(throttleKey, true, TimeSpan.FromSeconds(60)); var email = context.User.FindFirstValue(ClaimTypes.Email) ?? string.Empty; var firstName = context.User.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty; var lastName = context.User.FindFirstValue(ClaimTypes.Surname) ?? string.Empty; var displayName = $"{firstName} {lastName}".Trim(); if (string.IsNullOrEmpty(displayName)) displayName = email; var isSuperAdmin = context.User.IsInRole("SuperAdmin"); tracker.Touch(new OnlineUserEntry { UserId = userId, Email = email, DisplayName = displayName, IsSuperAdmin = isSuperAdmin, CurrentPath = path, IpAddress = context.Connection.RemoteIpAddress?.ToString(), LastSeen = now }); } }