Initial commit
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
using System.Security.Claims;
|
||||
using PowderCoating.Web.Services;
|
||||
|
||||
namespace PowderCoating.Web.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the OnlineUserTracker for every authenticated page request.
|
||||
/// Throttled to once per 60 seconds per user to avoid excessive work.
|
||||
/// </summary>
|
||||
public class OnlineUserMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
/// <summary>
|
||||
/// Maps each user ID to the UTC time the tracker was last updated.
|
||||
/// A <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
|
||||
/// is used because multiple concurrent requests from the same user (e.g. polling
|
||||
/// and a page navigation) may race to update the entry. The dictionary is
|
||||
/// static so the throttle window persists across DI scope lifetimes — the
|
||||
/// middleware instance itself may be recreated but the throttle state must not.
|
||||
/// </summary>
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _throttle = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the middleware with the next request delegate in the pipeline.
|
||||
/// </summary>
|
||||
/// <param name="next">The next middleware component.</param>
|
||||
public OnlineUserMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// Tracking runs <em>after</em> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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. <c>.js</c>, <c>.css</c>), which is a pragmatic heuristic that
|
||||
/// avoids the overhead of checking extensions individually.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="context">The current HTTP context.</param>
|
||||
/// <param name="tracker">
|
||||
/// Scoped service that maintains the in-memory registry of online users.
|
||||
/// </param>
|
||||
public async Task InvokeAsync(HttpContext context, IOnlineUserTracker tracker)
|
||||
{
|
||||
await _next(context);
|
||||
|
||||
// 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
|
||||
var now = DateTime.UtcNow;
|
||||
if (_throttle.TryGetValue(userId, out var lastWrite) && (now - lastWrite).TotalSeconds < 60)
|
||||
return;
|
||||
|
||||
_throttle[userId] = now;
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user