100 lines
4.0 KiB
C#
100 lines
4.0 KiB
C#
using System.Collections.Concurrent;
|
|
|
|
namespace RelayServer.Services.Chat;
|
|
|
|
/// <summary>
|
|
/// Two-way in-memory mapping between WebSocket session IDs and usernames.
|
|
///
|
|
/// Why both directions: when a chat message arrives, we need to look up "which sessions does
|
|
/// this server member have open right now?" (username → sessions) so we can deliver to each
|
|
/// of their devices. When a connection closes, we need to know "which user owned this session?"
|
|
/// (session → username) to clean up correctly.
|
|
///
|
|
/// Multi-device support: one username can have multiple sessions (phone + desktop + web all
|
|
/// connected simultaneously). UsernameToSessions stores a HashSet per username; each lock
|
|
/// is scoped to that specific HashSet so different users never block each other.
|
|
///
|
|
/// Username comparisons are case-insensitive (OrdinalIgnoreCase on the outer dictionary)
|
|
/// because the DB stores usernames lowercase but clients may register with mixed case.
|
|
/// </summary>
|
|
public static class ConnectedClientService
|
|
{
|
|
private static readonly ConcurrentDictionary<string, string> SessionToUsername = new();
|
|
private static readonly ConcurrentDictionary<string, HashSet<string>> UsernameToSessions =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// Associates a session ID with a username. Called from HandleRegisterKey. If the same
|
|
/// session re-registers under a different username (rare — basically only if the client
|
|
/// reauthenticates), the old mapping is cleaned up first to avoid double-bookkeeping.
|
|
/// </summary>
|
|
public static void Register(string sessionId, string username)
|
|
{
|
|
if (SessionToUsername.TryGetValue(sessionId, out var oldUsername) &&
|
|
!string.Equals(oldUsername, username, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
RemoveSessionFromUsername(sessionId, oldUsername);
|
|
}
|
|
|
|
SessionToUsername[sessionId] = username;
|
|
|
|
var sessions = UsernameToSessions.GetOrAdd(
|
|
username,
|
|
_ => new HashSet<string>(StringComparer.Ordinal));
|
|
|
|
lock (sessions)
|
|
sessions.Add(sessionId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a session from both mappings. Called from OnClose. Idempotent — calling for
|
|
/// a session that's already gone is a no-op.
|
|
/// </summary>
|
|
public static void Unregister(string sessionId)
|
|
{
|
|
if (SessionToUsername.TryRemove(sessionId, out var username))
|
|
RemoveSessionFromUsername(sessionId, username);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns every active session ID for a given username (case-insensitive lookup).
|
|
/// Empty collection if the user is offline. Snapshot-safe: the returned list is a copy,
|
|
/// not a live view of the underlying HashSet.
|
|
/// </summary>
|
|
public static IReadOnlyCollection<string> GetSessionsForUser(string username)
|
|
{
|
|
if (UsernameToSessions.TryGetValue(username, out var sessions))
|
|
{
|
|
lock (sessions)
|
|
return sessions.ToList();
|
|
}
|
|
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reverse lookup: which user owns this session? Returns the mixed-case username the
|
|
/// client registered with (preserves casing for display). Null if the session is unknown.
|
|
/// </summary>
|
|
public static string? GetUsernameForSession(string sessionId)
|
|
{
|
|
return SessionToUsername.TryGetValue(sessionId, out var u) ? u : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Internal cleanup: pulls a session out of the username→sessions HashSet, and removes
|
|
/// the username entry entirely if no sessions remain (keeps the dictionary lean).
|
|
/// </summary>
|
|
private static void RemoveSessionFromUsername(string sessionId, string username)
|
|
{
|
|
if (!UsernameToSessions.TryGetValue(username, out var sessions))
|
|
return;
|
|
|
|
lock (sessions)
|
|
{
|
|
sessions.Remove(sessionId);
|
|
if (sessions.Count == 0)
|
|
UsernameToSessions.TryRemove(username, out _);
|
|
}
|
|
}
|
|
} |