From b62ceb19499f4181e493b61780fc4c0b6481b69b Mon Sep 17 00:00:00 2001 From: RuKira Date: Sat, 30 May 2026 21:11:33 -0400 Subject: [PATCH] Updated, the update... should be working now hopefully... --- RelayClient/MainPage.xaml.cs | 6 +- RelayClient/Services/RelaySocketClient.cs | 92 +- RelayClient/Services/RtcBridgeService.cs | 4 +- .../Services/Chat/ChatSocketBehavior.cs | 789 +++++++++--------- .../Services/Chat/ConnectedClientService.cs | 56 ++ RelayShared/Services/WsControlMessage.cs | 34 + 6 files changed, 568 insertions(+), 413 deletions(-) create mode 100644 RelayServer/Services/Chat/ConnectedClientService.cs create mode 100644 RelayShared/Services/WsControlMessage.cs diff --git a/RelayClient/MainPage.xaml.cs b/RelayClient/MainPage.xaml.cs index 02902e2..61feae7 100644 --- a/RelayClient/MainPage.xaml.cs +++ b/RelayClient/MainPage.xaml.cs @@ -137,11 +137,11 @@ public partial class MainPage : ContentPage await _rtc.PushRtcContextToJsAsync(); }); - _socket.SendRaw($"GET_HISTORY|{_username}|{_currentChannelId}"); + _socket.SendGetHistory(_currentChannelId); } private void HandleEncryptedChat(SocketEncryptedMessage payload) { - if (payload.RecipientUsername != _username) + if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return; string decryptedText; @@ -229,7 +229,7 @@ public partial class MainPage : ContentPage RenderCurrentChannelMessages(); if (!_messagesByChannel.ContainsKey(channel.ChannelId)) - _socket.SendRaw($"GET_HISTORY|{_username}|{channel.ChannelId}"); + _socket.SendGetHistory(channel.ChannelId); }; SidebarList.Children.Add(button); diff --git a/RelayClient/Services/RelaySocketClient.cs b/RelayClient/Services/RelaySocketClient.cs index 75a12fc..e1b111b 100644 --- a/RelayClient/Services/RelaySocketClient.cs +++ b/RelayClient/Services/RelaySocketClient.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using RelayClient.Crypto; using RelayShared.Services; using WebSocketSharp; @@ -19,7 +19,7 @@ public sealed class RelaySocketClient public event Action? ServerPublicKeyReceived; public event Action? Log; - public RelaySocketClient(string username, string url = "ws://192.168.1.85:5001/") + public RelaySocketClient(string username, string url = "ws://127.0.0.1:5001/") { _username = username; _socket = new WebSocket(url); @@ -32,10 +32,57 @@ public sealed class RelaySocketClient var publicKey = KeyStorage.LoadPublicKey(_username); - SendRaw($"AUTHENTICATE_USER|{_username}|{MainPage._userToken}"); - SendRaw($"REGISTER_KEY|{_username}|{publicKey}"); - SendRaw("GET_SERVER_KEY"); - SendRaw("GET_CHANNELS"); + SendControlMessage(new WsControlMessage + { + Action = WsAction.Authenticate, + Username = _username, + Token = MainPage._userToken + }); + + SendControlMessage(new WsControlMessage + { + Action = WsAction.RegisterKey, + Username = _username, + PublicKey = publicKey + }); + + SendControlMessage(new WsControlMessage { Action = WsAction.GetServerKey }); + SendControlMessage(new WsControlMessage { Action = WsAction.GetChannels }); + } + + public void SendControlMessage(WsControlMessage message) + { + SendRaw(JsonSerializer.Serialize(message)); + } + + public void SendGetHistory(string channelId) + { + SendControlMessage(new WsControlMessage + { + Action = WsAction.GetHistory, + Username = _username, + ChannelId = channelId + }); + } + + public void SendRtcJoinChannel(string channelId) + { + SendControlMessage(new WsControlMessage + { + Action = WsAction.RtcJoin, + Username = _username, + ChannelId = channelId + }); + } + + public void SendRtcLeaveChannel(string channelId) + { + SendControlMessage(new WsControlMessage + { + Action = WsAction.RtcLeave, + Username = _username, + ChannelId = channelId + }); } public void SendRaw(string message) @@ -59,12 +106,6 @@ public sealed class RelaySocketClient private void OnMessage(object? sender, MessageEventArgs e) { - if (e.Data.StartsWith("SERVER:REGISTERED_KEY:")) - { - Log?.Invoke(e.Data); - return; - } - RawMessageReceived?.Invoke(e.Data); Log?.Invoke($"[{_username}] RAW WS DATA: {e.Data}"); @@ -73,6 +114,31 @@ public sealed class RelaySocketClient using var doc = JsonDocument.Parse(e.Data); var root = doc.RootElement; + // Control event responses (WsEvent) + if (root.TryGetProperty("Event", out var eventElement)) + { + var wsEvent = (WsEvent)eventElement.GetInt32(); + + switch (wsEvent) + { + case WsEvent.KeyRegistered: + Log?.Invoke($"[{_username}] Key registered on server."); + return; + + case WsEvent.Authenticated: + Log?.Invoke($"[{_username}] Authenticated with server."); + return; + + case WsEvent.Error: + var detail = root.TryGetProperty("Detail", out var d) ? d.GetString() : null; + Log?.Invoke($"[{_username}] Server error: {detail}"); + return; + } + + return; + } + + // Data messages (SignalType) if (!root.TryGetProperty("Type", out var typeElement)) return; @@ -125,4 +191,4 @@ public sealed class RelaySocketClient Log?.Invoke($"[{_username}] failed to process websocket message: {ex.Message}"); } } -} \ No newline at end of file +} diff --git a/RelayClient/Services/RtcBridgeService.cs b/RelayClient/Services/RtcBridgeService.cs index aa8d7d3..2a4687f 100644 --- a/RelayClient/Services/RtcBridgeService.cs +++ b/RelayClient/Services/RtcBridgeService.cs @@ -31,7 +31,7 @@ public sealed class RtcBridgeService if (string.IsNullOrWhiteSpace(channelId)) return Task.CompletedTask; - _socket.SendRaw($"RTC_JOIN_CHANNEL|{_username}|{channelId}"); + _socket.SendRtcJoinChannel(channelId); return Task.CompletedTask; } @@ -42,7 +42,7 @@ public sealed class RtcBridgeService if (string.IsNullOrWhiteSpace(channelId)) return; - _socket.SendRaw($"RTC_LEAVE_CHANNEL|{_username}|{channelId}"); + _socket.SendRtcLeaveChannel(channelId); } public void SendRtcSignal(string json) diff --git a/RelayServer/Services/Chat/ChatSocketBehavior.cs b/RelayServer/Services/Chat/ChatSocketBehavior.cs index ccfbf3b..8b995dd 100644 --- a/RelayServer/Services/Chat/ChatSocketBehavior.cs +++ b/RelayServer/Services/Chat/ChatSocketBehavior.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Text.Json; using RelayServer.Models; using RelayServer.Services.Crypto; @@ -12,9 +12,9 @@ using RelayShared.Services; namespace RelayServer.Services.Chat; /// -/// Handles websocket-based chat operations including client key registration, -/// server key retrieval, channel listing, channel history loading, and encrypted -/// channel message relay. +/// Handles websocket-based chat operations including authentication, client key +/// registration, server key retrieval, channel listing, channel history loading, +/// and encrypted channel message relay. /// public class ChatSocketBehavior : WebSocketBehavior { @@ -26,116 +26,328 @@ public class ChatSocketBehavior : WebSocketBehavior public static SurrealDb.Net.SurrealDbClient? Db { get; set; } /// - /// Routes incoming websocket messages to the appropriate chat handler. + /// Routes incoming websocket messages to the appropriate handler via JSON dispatch. + /// Control messages carry an Action property; data messages carry a Type property. /// - /// The websocket message event arguments. protected override void OnMessage(MessageEventArgs e) { var msg = e.Data; - Console.WriteLine(msg); - if (msg.StartsWith("REGISTER_KEY|")) - { - HandleRegisterKey(msg); - return; - } - - if (msg.StartsWith("AUTHENTICATE_USER")) - { - HandleAuth(msg); - return; - } - - if (msg == "GET_SERVER_KEY") - { - HandleGetServerKey(); - return; - } - - if (msg == "GET_CHANNELS") - { - HandleGetChannels(); - return; - } - - if (msg.StartsWith("GET_HISTORY|")) - { - HandleGetHistory(msg); - return; - } - - if (msg.StartsWith("RTC_JOIN_CHANNEL|")) - { - HandleRtcJoinChannel(msg); - return; - } - - if (msg.StartsWith("RTC_LEAVE_CHANNEL|")) - { - HandleRtcLeaveChannel(msg); - return; - } - - if (IsEncryptedRtcSignal(msg)) - { - HandleEncryptedRtcSignal(msg); - return; - } - - HandleEncryptedChatMessage(msg); - } - - private static bool IsEncryptedRtcSignal(string msg) - { try { using var doc = JsonDocument.Parse(msg); var root = doc.RootElement; - if (!root.TryGetProperty("Type", out var typeProp)) - return false; + if (root.TryGetProperty("Action", out var actionProp)) + { + var action = (WsAction)actionProp.GetInt32(); + var control = JsonSerializer.Deserialize(msg)!; + DispatchControl(action, control); + return; + } - var type = (SignalType)typeProp.GetInt32(); + if (root.TryGetProperty("Type", out var typeProp)) + { + var type = (SignalType)typeProp.GetInt32(); - return type == SignalType.EncryptedSignal; + switch (type) + { + case SignalType.EncryptedSignal: + HandleEncryptedRtcSignal(msg); + return; + + case SignalType.ClientEncryptedChat: + HandleEncryptedChatMessage(msg); + return; + } + } + + Console.WriteLine($"Unrecognised WebSocket message from session={ID}: {msg[..Math.Min(200, msg.Length)]}"); } - catch + catch (Exception ex) { - return false; + Console.WriteLine($"WebSocket message error: session={ID}, error={ex.Message}"); } } - private async void HandleAuth(string msg) + /// + /// Dispatches a control message to the correct handler based on its action. + /// + private void DispatchControl(WsAction action, WsControlMessage control) { - var parts = msg.Split('|', 3); - - if (parts.Length < 3) + switch (action) { - Console.WriteLine("Invalid AUTHENTICATE_USERS payload."); + case WsAction.Authenticate: + HandleAuthenticate(control); + break; + + case WsAction.RegisterKey: + HandleRegisterKey(control); + break; + + case WsAction.GetServerKey: + HandleGetServerKey(); + break; + + case WsAction.GetChannels: + HandleGetChannels(); + break; + + case WsAction.GetHistory: + HandleGetHistory(control); + break; + + case WsAction.RtcJoin: + HandleRtcJoinChannel(control); + break; + + case WsAction.RtcLeave: + HandleRtcLeaveChannel(control); + break; + + default: + Console.WriteLine($"Unknown WsAction {action} from session={ID}"); + break; + } + } + + // ------------------------------------------------------------------------- + // Control handlers + // ------------------------------------------------------------------------- + + /// + /// Verifies a user token with the Core service. The HTTP call is wrapped in + /// a try-catch so that a network failure never crashes the WebSocket session. + /// + private async void HandleAuthenticate(WsControlMessage control) + { + if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.Token)) + { + Console.WriteLine("Invalid Authenticate payload."); return; } - var username = parts[1]; - var token = parts[2]; - - // HttpClient core = new HttpClient{BaseAddress = new Uri("http://127.0.0.1:1337")}; - HttpClient core = new HttpClient{BaseAddress = new Uri("http://192.168.1.85:1337")}; - core.DefaultRequestHeaders.Accept.Clear(); - core.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - core.DefaultRequestHeaders.Add("User-Agent", "RelayServer"); - - HttpResponseMessage response = await core.PostAsJsonAsync("/server/verify/user", new AuthUserVerify + try { - Username = username, - Token = token - }); - - Console.WriteLine(response.Content.ReadAsStringAsync().Result); + using var core = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:1337") }; + core.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + core.DefaultRequestHeaders.Add("User-Agent", "RelayServer"); + var response = await core.PostAsJsonAsync("/server/verify/user", new AuthUserVerify + { + Username = control.Username, + Token = control.Token + }); + + Console.WriteLine($"Auth response for {control.Username}: {await response.Content.ReadAsStringAsync()}"); + } + catch (Exception ex) + { + Console.WriteLine($"Auth verification failed for {control.Username}: {ex.Message}"); + } + + var result = new WsEventMessage { Event = WsEvent.Authenticated, Detail = control.Username }; + Send(JsonSerializer.Serialize(result)); } + + /// + /// Stores (or updates) the client's public key and registers the session in + /// so targeted delivery can resolve session ids. + /// + private void HandleRegisterKey(WsControlMessage control) + { + if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.PublicKey)) + { + Console.WriteLine("Invalid RegisterKey payload."); + return; + } + + if (ClientKeyService is null) + { + Console.WriteLine("ClientKeyService is not initialized."); + return; + } + + RegisterOrUpdateClientKeySync(control.Username, control.PublicKey); + ConnectedClientService.Register(ID, control.Username); + + Console.WriteLine($"Registered key and session for {control.Username} (session={ID})"); + + var result = new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = control.Username }; + Send(JsonSerializer.Serialize(result)); + } + + /// + /// Sends the server's public key to the requesting client. + /// + private void HandleGetServerKey() + { + if (string.IsNullOrWhiteSpace(ServerPublicKey)) + { + Console.WriteLine("Server public key is not initialized."); + return; + } + + var payload = new ServerPublicKeyMessage + { + Type = SignalType.ServerPublicKey, + PublicKey = ServerPublicKey + }; + + Send(JsonSerializer.Serialize(payload)); + } + + /// + /// Sends the current list of channels to the connected client. + /// + private void HandleGetChannels() + { + if (Db is null) + { + Console.WriteLine("Db is not initialized."); + return; + } + + var channels = GetChannelsSync() + .OrderBy(c => c.CreatedAt) + .Select(c => new ChannelItem + { + ChannelId = GetRecordId(c.Id), + Name = c.Name, + CreatedAt = c.CreatedAt + }) + .ToList(); + + var payload = new SocketChannelList + { + Type = SignalType.ChannelList, + Channels = channels + }; + + Send(JsonSerializer.Serialize(payload)); + } + + /// + /// Loads stored channel history for a specific channel, decrypts it from + /// database storage format, and sends it back encrypted for the requesting client. + /// + private void HandleGetHistory(WsControlMessage control) + { + var username = control.Username; + var channelId = control.ChannelId; + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(channelId)) + { + Console.WriteLine("Invalid GetHistory payload."); + return; + } + + if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) + { + Console.WriteLine("History dependencies are not initialized."); + return; + } + + var targetClient = GetClientPublicKeyByUsernameSync(username); + + if (targetClient is null) + { + Console.WriteLine($"No public key found for history request user {username}"); + return; + } + + var allMessages = GetChannelMessagesSync(); + + var channelMessages = allMessages + .Where(m => m.ChannelId == channelId) + .OrderBy(m => m.CreatedAt) + .ToList(); + + Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}"); + + foreach (var dbMessage in channelMessages) + { + string plainText; + + try + { + plainText = ChannelCryptoService.Decrypt( + dbMessage.CipherText, + dbMessage.Nonce, + dbMessage.Tag, + ChannelDbKey + ); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to decrypt DB history row {dbMessage.Id}: {ex.Message}"); + continue; + } + + var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey); + + var outbound = new SocketEncryptedMessage + { + Type = SignalType.EncryptedChat, + SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId), + RecipientUsername = username, + ChannelId = channelId, + CipherText = encrypted.CipherText, + Nonce = encrypted.Nonce, + Tag = encrypted.Tag, + EncryptedKey = encrypted.EncryptedKey + }; + + Send(JsonSerializer.Serialize(outbound)); + } + } + + private void HandleRtcJoinChannel(WsControlMessage control) + { + var username = control.Username; + var channelId = control.ChannelId; + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(channelId)) + { + Console.WriteLine("Invalid RtcJoin payload."); + return; + } + + RtcChannelPresenceService.SetUser(ID, username); + RtcChannelPresenceService.JoinChannel(ID, channelId); + + Console.WriteLine($"RTC presence joined: session={ID}, user={username}, channel={channelId}"); + } + + private void HandleRtcLeaveChannel(WsControlMessage control) + { + var username = control.Username; + var channelId = control.ChannelId; + + if (string.IsNullOrWhiteSpace(channelId)) + { + Console.WriteLine("Invalid RtcLeave payload."); + return; + } + + if (RtcChannelPresenceService.IsInChannel(ID, channelId)) + RtcChannelPresenceService.LeaveChannel(ID); + + Console.WriteLine($"RTC presence left: session={ID}, user={username}, channel={channelId}"); + } + + // ------------------------------------------------------------------------- + // Data message handlers + // ------------------------------------------------------------------------- + + /// + /// Decrypts an incoming encrypted RTC signal and re-encrypts it for every + /// other session in the same RTC channel. + /// private void HandleEncryptedRtcSignal(string msg) { Console.WriteLine("RTC SIGNAL HIT"); + SocketRtcSignalMessage? clientPayload; try @@ -211,123 +423,12 @@ public class ChatSocketBehavior : WebSocketBehavior Console.WriteLine($"Forwarded encrypted RTC signal from {clientPayload.SenderUsername} to channel {clientPayload.ChannelId}"); } - - /// - /// - /// - /// - protected override void OnClose(CloseEventArgs e) - { - RtcChannelPresenceService.RemoveSession(ID); - Console.WriteLine($"WebSocket closed: session={ID}, code={e.Code}, reason={e.Reason}"); - base.OnClose(e); - } - - protected override void OnError(ErrorEventArgs e) - { - Console.WriteLine($"WebSocket error: session={ID}, message={e.Message}"); - base.OnError(e); - } /// - /// Extracts a display username from a stored user record id value. + /// Decrypts an incoming encrypted chat message, stores it in the database, + /// then re-encrypts and delivers it individually to every connected server member. + /// Messages are never broadcast — each recipient receives their own encrypted copy. /// - /// The stored sender user id. - /// - /// The extracted username when possible; otherwise, a fallback value. - /// - private static string ExtractUsernameFromUserId(string senderUserId) - { - if (string.IsNullOrWhiteSpace(senderUserId)) - return "Unknown"; - - var parts = senderUserId.Split(':', 2); - return parts.Length == 2 ? parts[1] : senderUserId; - } - - /// - /// Registers or updates a client's public key from a websocket registration payload. - /// - /// The raw websocket registration message. - private void HandleRegisterKey(string msg) - { - var parts = msg.Split('|', 3); - - if (parts.Length < 3) - { - Console.WriteLine("Invalid REGISTER_KEY payload."); - return; - } - - var username = parts[1]; - var publicKey = parts[2]; - - if (ClientKeyService is null) - { - Console.WriteLine("ClientKeyService is not initialized."); - return; - } - - RegisterOrUpdateClientKeySync(username, publicKey); - - Send($"SERVER:REGISTERED_KEY:{username}"); - } - - /// - /// Sends the current list of channels to the connected websocket client. - /// - private void HandleGetChannels() - { - if (Db is null) - { - Console.WriteLine("Db is not initialized."); - return; - } - //TODO: Update to include ChannelType and Group String on channels - var channels = GetChannelsSync() - .OrderBy(c => c.CreatedAt) - .Select(c => new ChannelItem() - { - ChannelId = GetRecordId(c.Id), - Name = c.Name, - CreatedAt = c.CreatedAt - }) - .ToList(); - - var payload = new SocketChannelList - { - Type = SignalType.ChannelList, - Channels = channels - }; - - Send(JsonSerializer.Serialize(payload)); - } - - /// - /// Sends the server's public key to the connected websocket client. - /// - private void HandleGetServerKey() - { - if (string.IsNullOrWhiteSpace(ServerPublicKey)) - { - Console.WriteLine("Server public key is not initialized."); - return; - } - - var payload = new ServerPublicKeyMessage - { - Type = SignalType.ServerPublicKey, - PublicKey = ServerPublicKey - }; - - Send(JsonSerializer.Serialize(payload)); - } - - /// - /// Decrypts an incoming encrypted chat payload, stores it in the database, - /// and rebroadcasts it to connected clients encrypted with each client's public key. - /// - /// The raw encrypted chat websocket message. private void HandleEncryptedChatMessage(string msg) { SocketEncryptedMessage? clientPayload; @@ -370,9 +471,10 @@ public class ChatSocketBehavior : WebSocketBehavior } Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}"); + try { - var dbEncrypted = ChannelCryptoService.Encrypt(plainText, ChannelDbKey); + var dbEncrypted = ChannelCryptoService!.Encrypt(plainText, ChannelDbKey); var savedMessage = CreateChannelMessageSync(new ChannelMessages { @@ -392,19 +494,40 @@ public class ChatSocketBehavior : WebSocketBehavior return; } - var allKeys = GetAllClientPublicKeysSync(); + // Deliver to every connected server member individually. + var members = GetServerMembersSync(); - foreach (var client in allKeys) + foreach (var member in members) { - var encrypted = E2EeHelper.EncryptForRecipient(plainText, client.PublicKey); + // Derive the lowercase username from the stored record id (e.g. "users:keeper317"). + var rawUsername = ExtractUsernameFromUserId(member.UserId); - Console.WriteLine($"Encrypting outbound message from {clientPayload.SenderUsername} for {client.Username}"); + // Find all active sessions for this member (supports multi-device). + var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername); + if (sessionIds.Count == 0) + continue; + + // Resolve the correctly-cased username as the client registered it. + var properUsername = sessionIds + .Select(ConnectedClientService.GetUsernameForSession) + .FirstOrDefault(u => u is not null) ?? rawUsername; + + var clientKey = GetClientPublicKeyByUsernameSync(properUsername); + if (clientKey is null) + { + Console.WriteLine($"No public key for {properUsername}, skipping."); + continue; + } + + var encrypted = E2EeHelper.EncryptForRecipient(plainText, clientKey.PublicKey); + + Console.WriteLine($"Encrypting outbound message from {clientPayload.SenderUsername} for {properUsername}"); var outbound = new SocketEncryptedMessage { Type = SignalType.EncryptedChat, SenderUsername = clientPayload.SenderUsername, - RecipientUsername = client.Username, + RecipientUsername = properUsername, ChannelId = clientPayload.ChannelId, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, @@ -412,117 +535,35 @@ public class ChatSocketBehavior : WebSocketBehavior EncryptedKey = encrypted.EncryptedKey }; - Sessions.Broadcast(JsonSerializer.Serialize(outbound)); + var json = JsonSerializer.Serialize(outbound); + + foreach (var sessionId in sessionIds) + Sessions.SendTo(json, sessionId); } } - /// - /// Loads stored channel history for a specific user and channel, decrypts it from - /// database storage format, and sends it back encrypted for the requesting client. - /// - /// The raw history request websocket message. - private void HandleGetHistory(string msg) + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + protected override void OnClose(CloseEventArgs e) { - var parts = msg.Split('|', 3); - - if (parts.Length < 3) - { - Console.WriteLine("Invalid GET_HISTORY payload."); - return; - } - - var username = parts[1]; - var channelId = parts[2]; - - if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) - { - Console.WriteLine("History dependencies are not initialized."); - return; - } - - var targetClient = GetClientPublicKeyByUsernameSync(username); - - if (targetClient is null) - { - Console.WriteLine($"No public key found for history request user {username}"); - return; - } - - var allMessages = GetChannelMessagesSync(); - - var channelMessages = allMessages - .Where(m => m.ChannelId == channelId) - .OrderBy(m => m.CreatedAt) - .ToList(); - - Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}"); - - foreach (var dbMessage in channelMessages) - { - string plainText; - - try - { - plainText = ChannelCryptoService.Decrypt( - dbMessage.CipherText, - dbMessage.Nonce, - dbMessage.Tag, - ChannelDbKey - ); - } - catch (Exception ex) - { - Console.WriteLine($"Failed to decrypt DB history row {dbMessage.Id}: {ex.Message}"); - continue; - } - - var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey); - - var outbound = new SocketEncryptedMessage - { - Type = SignalType.EncryptedChat, - SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId), - RecipientUsername = username, - ChannelId = channelId, - CipherText = encrypted.CipherText, - Nonce = encrypted.Nonce, - Tag = encrypted.Tag, - EncryptedKey = encrypted.EncryptedKey - }; - - Send(JsonSerializer.Serialize(outbound)); - } + ConnectedClientService.Unregister(ID); + RtcChannelPresenceService.RemoveSession(ID); + Console.WriteLine($"WebSocket closed: session={ID}, code={e.Code}, reason={e.Reason}"); + base.OnClose(e); } - /// - /// Converts a SurrealDB record id object into a table:id string representation. - /// - /// The raw record id object. - /// - /// A formatted record id string, or an empty string if the input is null. - /// - private static string GetRecordId(object? id) + protected override void OnError(ErrorEventArgs e) { - if (id is null) - return string.Empty; - - var json = JsonSerializer.Serialize(id); - - using var doc = JsonDocument.Parse(json); - - var root = doc.RootElement; - - var recordId = root.GetProperty("Id").GetString() ?? string.Empty; - var table = root.GetProperty("Table").GetString() ?? string.Empty; - - return $"{table}:{recordId}"; + Console.WriteLine($"WebSocket error: session={ID}, message={e.Message}"); + base.OnError(e); } - - /// - /// Synchronously registers or updates a stored client public key using the async key service. - /// - /// The client username. - /// The client's public key. + + // ------------------------------------------------------------------------- + // Sync DB helpers + // ------------------------------------------------------------------------- + private void RegisterOrUpdateClientKeySync(string username, string publicKey) { Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)) @@ -530,10 +571,6 @@ public class ChatSocketBehavior : WebSocketBehavior .GetResult(); } - /// - /// Synchronously loads all channels from the database. - /// - /// A list of channel records. private List GetChannelsSync() { return Task.Run(async () => await Db!.Select("channels")) @@ -542,13 +579,6 @@ public class ChatSocketBehavior : WebSocketBehavior .ToList(); } - /// - /// Synchronously gets the stored public key record for the specified user. - /// - /// The username to look up. - /// - /// The matching client public key record, or null if none exists. - /// private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username) { return Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username)) @@ -556,21 +586,6 @@ public class ChatSocketBehavior : WebSocketBehavior .GetResult(); } - /// - /// Synchronously loads all stored client public key records. - /// - /// A list of all client public key records. - private List GetAllClientPublicKeysSync() - { - return Task.Run(async () => await ClientKeyService!.GetAllAsync()) - .GetAwaiter() - .GetResult(); - } - - /// - /// Synchronously loads all stored channel messages from the database. - /// - /// A list of channel message records. private List GetChannelMessagesSync() { return Task.Run(async () => await Db!.Select("channel_messages")) @@ -579,22 +594,56 @@ public class ChatSocketBehavior : WebSocketBehavior .ToList(); } - /// - /// Synchronously creates a new channel message record in the database. - /// - /// The message record to create. - /// The created channel message record. private ChannelMessages CreateChannelMessageSync(ChannelMessages message) { return Task.Run(async () => await Db!.Create("channel_messages", message)) .GetAwaiter() .GetResult(); } - + + private List GetServerMembersSync() + { + return Task.Run(async () => await Db!.Select("server_members")) + .GetAwaiter() + .GetResult() + .ToList(); + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + /// - /// + /// Extracts a display username from a stored user record id value + /// (e.g. "users:keeper317" → "keeper317"). /// - /// + private static string ExtractUsernameFromUserId(string senderUserId) + { + if (string.IsNullOrWhiteSpace(senderUserId)) + return "Unknown"; + + var parts = senderUserId.Split(':', 2); + return parts.Length == 2 ? parts[1] : senderUserId; + } + + /// + /// Converts a SurrealDB record id object into a "table:id" string. + /// + private static string GetRecordId(object? id) + { + if (id is null) + return string.Empty; + + var json = JsonSerializer.Serialize(id); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var recordId = root.GetProperty("Id").GetString() ?? string.Empty; + var table = root.GetProperty("Table").GetString() ?? string.Empty; + + return $"{table}:{recordId}"; + } + private bool EnsureCoreReady() { if (ClientKeyService is null || Db is null) @@ -605,11 +654,7 @@ public class ChatSocketBehavior : WebSocketBehavior return true; } - - /// - /// - /// - /// + private bool EnsureCryptoReady() { if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey)) @@ -626,50 +671,4 @@ public class ChatSocketBehavior : WebSocketBehavior return true; } - - /// - /// - /// - /// - private void HandleRtcJoinChannel(string msg) - { - var parts = msg.Split('|', 3); - if (parts.Length < 3) - { - Console.WriteLine("Invalid RTC_JOIN_CHANNEL payload."); - return; - } - - var username = parts[1]; - var channelId = parts[2]; - - RtcChannelPresenceService.SetUser(ID, username); - RtcChannelPresenceService.JoinChannel(ID, channelId); - - Console.WriteLine($"RTC presence joined: session={ID}, user={username}, channel={channelId}"); - } - - /// - /// - /// - /// - private void HandleRtcLeaveChannel(string msg) - { - var parts = msg.Split('|', 3); - if (parts.Length < 3) - { - Console.WriteLine("Invalid RTC_LEAVE_CHANNEL payload."); - return; - } - - var username = parts[1]; - var channelId = parts[2]; - - if (RtcChannelPresenceService.IsInChannel(ID, channelId)) - { - RtcChannelPresenceService.LeaveChannel(ID); - } - - Console.WriteLine($"RTC presence left: session={ID}, user={username}, channel={channelId}"); - } -} \ No newline at end of file +} diff --git a/RelayServer/Services/Chat/ConnectedClientService.cs b/RelayServer/Services/Chat/ConnectedClientService.cs new file mode 100644 index 0000000..13f8eb9 --- /dev/null +++ b/RelayServer/Services/Chat/ConnectedClientService.cs @@ -0,0 +1,56 @@ +using System.Collections.Concurrent; + +namespace RelayServer.Services.Chat; + +public static class ConnectedClientService +{ + private static readonly ConcurrentDictionary SessionToUsername = new(); + private static readonly ConcurrentDictionary> UsernameToSessions = + new(StringComparer.OrdinalIgnoreCase); + + 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(StringComparer.Ordinal)); + lock (sessions) + sessions.Add(sessionId); + } + + public static void Unregister(string sessionId) + { + if (SessionToUsername.TryRemove(sessionId, out var username)) + RemoveSessionFromUsername(sessionId, username); + } + + public static IReadOnlyCollection GetSessionsForUser(string username) + { + if (UsernameToSessions.TryGetValue(username, out var sessions)) + lock (sessions) + return sessions.ToList(); + + return Array.Empty(); + } + + public static string? GetUsernameForSession(string sessionId) => + SessionToUsername.TryGetValue(sessionId, out var u) ? u : null; + + 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 _); + } + } +} diff --git a/RelayShared/Services/WsControlMessage.cs b/RelayShared/Services/WsControlMessage.cs new file mode 100644 index 0000000..d1a38f5 --- /dev/null +++ b/RelayShared/Services/WsControlMessage.cs @@ -0,0 +1,34 @@ +namespace RelayShared.Services; + +public enum WsAction +{ + Authenticate, + RegisterKey, + GetServerKey, + GetChannels, + GetHistory, + RtcJoin, + RtcLeave +} + +public enum WsEvent +{ + Authenticated, + KeyRegistered, + Error +} + +public sealed class WsControlMessage +{ + public WsAction Action { get; set; } + public string? Username { get; set; } + public string? Token { get; set; } + public string? ChannelId { get; set; } + public string? PublicKey { get; set; } +} + +public sealed class WsEventMessage +{ + public WsEvent Event { get; set; } + public string? Detail { get; set; } +}