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
});
}
}