357ef84001
The notification bell polls /InAppNotifications/Recent (a JSON endpoint) every time it loads. Because the middleware throttles updates to once per 60s, the update fired on whichever request first arrived after the throttle expired — usually the bell poll rather than a real page navigation. Fix: skip any response whose Content-Type is application/json so only full page navigations update the current-page field. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
99 lines
4.2 KiB
C#
99 lines
4.2 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
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;
|
|
private readonly IMemoryCache _cache;
|
|
|
|
/// <summary>
|
|
/// Initialises the middleware with the next request delegate in the pipeline.
|
|
/// </summary>
|
|
/// <param name="next">The next middleware component.</param>
|
|
/// <param name="cache">Shared app cache used for expiring per-user throttle entries.</param>
|
|
public OnlineUserMiddleware(RequestDelegate next, IMemoryCache cache)
|
|
{
|
|
_next = next;
|
|
_cache = cache;
|
|
}
|
|
|
|
/// <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);
|
|
|
|
// 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
|
|
});
|
|
}
|
|
}
|