using System.Net.Http.Headers; using System.Text.Json; using RelayServer.Models; using RelayServer.Services.Crypto; using RelayServer.Services.Data; using RelayServer.Services.Rtc; using WebSocketSharp; using WebSocketSharp.Server; using ErrorEventArgs = WebSocketSharp.ErrorEventArgs; using RelayShared.Services; namespace RelayServer.Services.Chat; /// /// The server-side WebSocket endpoint. Every client connection creates one instance of this /// class. WebSocketSharp owns the lifecycle: it constructs the behavior, calls OnMessage for /// each incoming frame, and calls OnClose when the connection drops. /// /// MESSAGE FLOW (data plane — chat message): /// 1. Client sends a SocketEncryptedMessage with SignalType.ClientEncryptedChat. /// Payload is JSON-serialised ChatMessageContent, encrypted with the server's public key. /// 2. OnMessage parses the JSON, identifies Type, routes to HandleEncryptedChatMessage. /// 3. Permission check via PermissionService.CanSendMessagesAsync. /// 4. Decrypt with ServerPrivateKey → get plaintext JSON. /// 5. Re-encrypt with ChannelDbKey (AES-GCM only, no RSA) → store in channel_messages table. /// 6. For each connected server member: re-encrypt with their client public key, deliver /// via Sessions.SendTo to every one of their active sessions (multi-device). /// 7. If the origin channel has LinkedFileChannelId set, MirrorAttachmentIfNeeded also /// stores+delivers a trimmed copy into the linked File channel. /// /// MESSAGE FLOW (control plane — e.g. CreateChannel): /// 1. Client sends a WsControlMessage with Action=CreateChannel. /// 2. OnMessage sees the "Action" JSON property, routes via DispatchControl. /// 3. Permission check, DB write, then BroadcastChannelList rebuilds the channel list per /// user (because CanPost/CanManage are computed per-user) and pushes it to everyone. /// /// STATE STORES used here: /// - ConnectedClientService: session ↔ username mapping (in-memory, multi-device aware). /// Populated by HandleRegisterKey, cleared by OnClose. /// - RtcChannelPresenceService: session ↔ voice channel mapping. Populated by RtcJoin, /// cleared by RtcLeave / OnClose. /// - SurrealDB tables: channel_messages, channels, server_members, roles, user_roles, /// channel_permissions, client_public_keys, server_encryption_keys, channel_message_edits. /// /// CRITICAL invariant: this class is constructed by WebSocketSharp and has no constructor /// hook for DI, so ALL services are static (set once by Program.cs at boot). /// public class ChatSocketBehavior : WebSocketBehavior { /// Reads/writes the client_public_keys table. Wired by Program.cs at boot. public static ClientKeyService? ClientKeyService { get; set; } /// The permission ladder evaluator. Wired by Program.cs at boot. public static PermissionService? PermissionService { get; set; } /// Base64 RSA public key — clients use this to encrypt outbound payloads to the server. public static string? ServerPublicKey { get; set; } /// Base64 RSA private key — used to decrypt inbound payloads. Never leaves the server. public static string? ServerPrivateKey { get; set; } /// Base64 AES-256 key for at-rest encryption of channel_messages.CipherText rows. public static string? ChannelDbKey { get; set; } /// AES-GCM-only encryption for stored messages. Wired by Program.cs at boot. public static ChannelCryptoService? ChannelCryptoService { get; set; } /// The SurrealDB connection. Wired by Program.cs at boot. public static SurrealDb.Net.SurrealDbClient? Db { get; set; } /// /// WebSocketSharp callback fired for every incoming text frame. Peeks the JSON to identify /// "Action" (control-plane) vs "Type" (data-plane), then routes to the right handler. /// All exceptions are caught and logged — they MUST NOT propagate or WebSocketSharp will /// drop the connection. /// protected override void OnMessage(MessageEventArgs e) { var msg = e.Data; try { using var doc = JsonDocument.Parse(msg); var root = doc.RootElement; if (root.TryGetProperty("Action", out var actionProp)) { var action = (WsAction)actionProp.GetInt32(); var control = JsonSerializer.Deserialize(msg)!; DispatchControl(action, control); return; } if (root.TryGetProperty("Type", out var typeProp)) { var type = (SignalType)typeProp.GetInt32(); switch (type) { case SignalType.EncryptedSignal: HandleEncryptedRtcSignal(msg); return; case SignalType.ClientEncryptedChat: HandleEncryptedChatMessage(msg); return; case SignalType.ClientEditMessage: HandleEditMessage(msg); return; case SignalType.ClientDeleteMessage: HandleDeleteMessage(msg); return; } } Console.WriteLine($"Unrecognised WS message session={ID}: {msg[..Math.Min(120, msg.Length)]}"); } catch (Exception ex) { Console.WriteLine($"WS message error session={ID}: {ex.Message}"); } } /// Switches on WsAction to the matching Handle* method. Pure routing — no I/O. private void DispatchControl(WsAction action, WsControlMessage c) { switch (action) { case WsAction.Authenticate: HandleAuthenticate(c); break; case WsAction.RegisterKey: HandleRegisterKey(c); break; case WsAction.GetServerKey: HandleGetServerKey(); break; case WsAction.GetChannels: HandleGetChannels(); break; case WsAction.GetHistory: HandleGetHistory(c); break; case WsAction.RtcJoin: HandleRtcJoinChannel(c); break; case WsAction.RtcLeave: HandleRtcLeaveChannel(c); break; case WsAction.SendTyping: HandleTyping(c); break; case WsAction.GetEditHistory: HandleGetEditHistory(c); break; case WsAction.CreateChannel: HandleCreateChannel(c); break; case WsAction.DeleteChannel: HandleDeleteChannel(c); break; default: Console.WriteLine($"Unknown WsAction {action} session={ID}"); break; } } /// /// Verifies a Core-issued user token against the Core service. The HTTP call is wrapped in /// try/catch so that a Core outage doesn't drop the chat session — we still ack with /// WsEvent.Authenticated so the rest of the boot handshake can proceed. /// /// NOTE async void here is unavoidable (it's an event handler) but every exception path /// must be caught locally or WebSocketSharp will tear down the session. /// private async void HandleAuthenticate(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.Token)) { Console.WriteLine("Invalid Authenticate payload."); return; } try { 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 resp = await core.PostAsJsonAsync("/server/verify/user", new AuthUserVerify { Username = c.Username, Token = c.Token }); Console.WriteLine($"Auth [{c.Username}]: {await resp.Content.ReadAsStringAsync()}"); } catch (Exception ex) { Console.WriteLine($"Auth failed for {c.Username}: {ex.Message}"); } Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Authenticated, Detail = c.Username })); } /// /// Stores or updates the client's RSA public key in client_public_keys, then registers the /// (sessionId, username) mapping in ConnectedClientService. After this fires the server can /// route encrypted chat messages to this user's connected devices. /// private void HandleRegisterKey(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.PublicKey)) { Console.WriteLine("Invalid RegisterKey payload."); return; } if (ClientKeyService is null) { Console.WriteLine("ClientKeyService null."); return; } RegisterOrUpdateClientKeySync(c.Username, c.PublicKey); ConnectedClientService.Register(ID, c.Username); Console.WriteLine($"Key registered: {c.Username} (session={ID})"); Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = c.Username })); } /// Sends the server's public RSA key. Called once per session right after RegisterKey. private void HandleGetServerKey() { if (string.IsNullOrWhiteSpace(ServerPublicKey)) { Console.WriteLine("Server public key not initialised."); return; } Send(JsonSerializer.Serialize(new ServerPublicKeyMessage { Type = SignalType.ServerPublicKey, PublicKey = ServerPublicKey })); } /// /// Sends a channel list with CanPost/CanManage/visibility resolved for this specific user. /// The username is looked up by session ID so the client never has to spoof it. /// private void HandleGetChannels() { if (Db is null) { Console.WriteLine("Db null."); return; } // Resolve the requesting user so we can compute per-user CanPost for each channel. var username = ConnectedClientService.GetUsernameForSession(ID) ?? string.Empty; var channels = BuildChannelListForUser(username); Send(JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels })); } /// /// Streams the channel's full message history back to the requester. Each message is: /// 1. Decrypted from the channel DB key (ChannelCryptoService.Decrypt). /// 2. Re-encrypted with the requester's public key (E2EeHelper.EncryptForRecipient). /// 3. Sent as an individual SocketEncryptedMessage frame. /// Deleted messages are sent as tombstones (IsDeleted=true, no ciphertext) so the client /// can render a placeholder without trying to decrypt. /// private void HandleGetHistory(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId)) { Console.WriteLine("Invalid GetHistory payload."); return; } if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) return; var targetClient = GetClientPublicKeyByUsernameSync(c.Username); if (targetClient is null) { Console.WriteLine($"No public key for history user {c.Username}"); return; } var messages = GetChannelMessagesSync() .Where(m => m.ChannelId == c.ChannelId) .OrderBy(m => m.CreatedAt) .ToList(); Console.WriteLine($"Sending {messages.Count} history messages to {c.Username}"); foreach (var dbMsg in messages) { var msgId = GetRecordId(dbMsg.Id); if (dbMsg.IsDeleted) { Send(JsonSerializer.Serialize(new SocketEncryptedMessage { Type = SignalType.EncryptedChat, MessageId = msgId, SenderUsername = ExtractUsernameFromUserId(dbMsg.SenderUserId), RecipientUsername = c.Username, ChannelId = c.ChannelId, IsDeleted = true })); continue; } string plainText; try { plainText = ChannelCryptoService.Decrypt(dbMsg.CipherText, dbMsg.Nonce, dbMsg.Tag, ChannelDbKey); } catch (Exception ex) { Console.WriteLine($"History decrypt failed {dbMsg.Id}: {ex.Message}"); continue; } var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey); Send(JsonSerializer.Serialize(new SocketEncryptedMessage { Type = SignalType.EncryptedChat, MessageId = msgId, SenderUsername = ExtractUsernameFromUserId(dbMsg.SenderUserId), RecipientUsername = c.Username, ChannelId = c.ChannelId, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey, IsEdited = dbMsg.EditedAt.HasValue })); } } /// /// Marks the session as present in a voice channel. Gated by CanSpeakAsync — if the user's /// role is denied Speak here we reject with WsEvent.Error and refuse to register presence. /// private void HandleRtcJoinChannel(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId)) { Console.WriteLine("Invalid RtcJoin payload."); return; } if (PermissionService is not null && !PermissionService.CanSpeakAsync(c.Username, c.ChannelId).GetAwaiter().GetResult()) { Console.WriteLine($"RTC join denied (no Speak): user={c.Username}, channel={c.ChannelId}"); Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "You don't have permission to speak in this channel." })); return; } RtcChannelPresenceService.SetUser(ID, c.Username); RtcChannelPresenceService.JoinChannel(ID, c.ChannelId); Console.WriteLine($"RTC join: session={ID}, user={c.Username}, channel={c.ChannelId}"); } /// Clears the session's voice-channel presence. Idempotent — safe to call when not in a channel. private void HandleRtcLeaveChannel(WsControlMessage c) { if (!string.IsNullOrWhiteSpace(c.ChannelId) && RtcChannelPresenceService.IsInChannel(ID, c.ChannelId)) RtcChannelPresenceService.LeaveChannel(ID); Console.WriteLine($"RTC leave: session={ID}, user={c.Username}"); } /// /// Broadcasts "{Username} is typing…" to every connected server member EXCEPT the sender. /// Sender's username comes from ConnectedClientService (not the message payload) so a /// malicious client can't impersonate someone else's typing. /// private void HandleTyping(WsControlMessage c) { var senderUsername = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(senderUsername) || string.IsNullOrWhiteSpace(c.ChannelId)) return; var json = JsonSerializer.Serialize(new SocketTypingEvent { Type = SignalType.TypingIndicator, Username = senderUsername, ChannelId = c.ChannelId }); foreach (var member in GetServerMembersSync()) { var rawUsername = ExtractUsernameFromUserId(member.UserId); if (string.Equals(rawUsername, senderUsername, StringComparison.OrdinalIgnoreCase)) continue; foreach (var sid in ConnectedClientService.GetSessionsForUser(rawUsername)) Sessions.SendTo(json, sid); } } /// /// Streams every prior version of a message back to the requester. Each entry is decrypted /// from the channel key then re-encrypted for the requester's public key. Drives the /// "(edited)" tap-popup on the client. /// private void HandleGetEditHistory(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.MessageId) || string.IsNullOrWhiteSpace(c.Username)) return; if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) return; var targetClient = GetClientPublicKeyByUsernameSync(c.Username); if (targetClient is null) return; var edits = GetChannelMessageEditsSync(c.MessageId) .OrderBy(e => e.EditedAt) .ToList(); var entries = new List(); foreach (var edit in edits) { string plainText; try { plainText = ChannelCryptoService.Decrypt(edit.CipherText, edit.Nonce, edit.Tag, ChannelDbKey); } catch (Exception ex) { Console.WriteLine($"Edit history decrypt failed: {ex.Message}"); continue; } var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey); entries.Add(new SocketEditHistoryEntry { CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey, EditedAt = edit.EditedAt }); } Send(JsonSerializer.Serialize(new SocketEditHistoryResponse { Type = SignalType.EditHistory, MessageId = c.MessageId, Entries = entries })); } /// /// Permission-gated channel creation. On success, broadcasts the new channel list to every /// connected member (computed per-user since CanPost/CanManage depend on the recipient). /// private void HandleCreateChannel(WsControlMessage c) { var username = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(username)) return; if (PermissionService is null || !PermissionService.CanManageChannelsAsync(username).GetAwaiter().GetResult()) { Console.WriteLine($"CreateChannel denied for {username}: insufficient permissions."); Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Permission denied." })); return; } if (string.IsNullOrWhiteSpace(c.ChannelName)) { Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Channel name is required." })); return; } var type = (ChannelType)c.ChannelType; Task.Run(async () => await Db!.Create("channels", new Channels { Name = c.ChannelName, Type = type, Group = c.ChannelGroup ?? string.Empty, CreatedAt = DateTime.UtcNow })).GetAwaiter().GetResult(); Console.WriteLine($"Channel created: {c.ChannelName} ({type}) by {username}"); BroadcastChannelList(); } /// /// Permission-gated soft-delete (sets IsDeleted on the row, doesn't actually remove it). /// Broadcasts a fresh channel list after — clients drop the channel from their sidebar. /// private void HandleDeleteChannel(WsControlMessage c) { var username = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(username)) return; if (PermissionService is null || !PermissionService.CanDeleteChannelAsync(username).GetAwaiter().GetResult()) { Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Permission denied." })); return; } if (string.IsNullOrWhiteSpace(c.ChannelId)) return; var all = GetChannelsSync(); var target = all.FirstOrDefault(ch => GetRecordId(ch.Id) == c.ChannelId); if (target is null) return; target.IsDeleted = true; Task.Run(async () => await Db!.Merge(target)) .GetAwaiter().GetResult(); Console.WriteLine($"Channel deleted: {target.Name} by {username}"); BroadcastChannelList(); } /// /// Relays an encrypted WebRTC SDP/ICE signal to every other session in the same voice /// channel. Decrypts with the server's private key, re-encrypts per-recipient. The server /// never stores RTC signals — pure forwarding. /// private void HandleEncryptedRtcSignal(string msg) { SocketRtcSignalMessage? payload; try { payload = JsonSerializer.Deserialize(msg); } catch { Console.WriteLine("Failed to parse RTC signal."); return; } if (payload is null || string.IsNullOrWhiteSpace(payload.ChannelId)) return; string plainText; try { plainText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey }, ServerPrivateKey); } catch (Exception ex) { Console.WriteLine($"RTC decrypt failed: {ex.Message}"); return; } foreach (var sid in RtcChannelPresenceService.GetSessionsInChannel(payload.ChannelId)) { if (sid == ID) continue; var uname = RtcChannelPresenceService.GetUsernameForSession(sid); if (string.IsNullOrWhiteSpace(uname)) continue; var key = GetClientPublicKeyByUsernameSync(uname); if (key is null) continue; var enc = E2EeHelper.EncryptForRecipient(plainText, key.PublicKey); Sessions.SendTo(JsonSerializer.Serialize(new SocketRtcSignalMessage { Type = SignalType.EncryptedSignal, SenderUsername = payload.SenderUsername, ChannelId = payload.ChannelId, CipherText = enc.CipherText, Nonce = enc.Nonce, Tag = enc.Tag, EncryptedKey = enc.EncryptedKey }), sid); } } /// /// The main chat-message path. Permission gate → server-side decrypt → store with channel /// key → DeliverToServerMembers (per-user re-encrypt + send) → MirrorAttachmentIfNeeded. /// private void HandleEncryptedChatMessage(string msg) { SocketEncryptedMessage? payload; try { payload = JsonSerializer.Deserialize(msg); } catch { Console.WriteLine("Failed to parse chat payload."); return; } if (payload is null || payload.Type != SignalType.ClientEncryptedChat) return; if (!EnsureCoreReady() || !EnsureCryptoReady()) return; // Permission check. var senderUsername = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(senderUsername)) return; if (PermissionService is not null && !PermissionService.CanSendMessagesAsync(senderUsername, payload.ChannelId).GetAwaiter().GetResult()) { Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "You cannot send messages in this channel." })); return; } string plainText; try { plainText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey }, ServerPrivateKey); } catch (Exception ex) { Console.WriteLine($"Chat decrypt failed: {ex.Message}"); return; } Console.WriteLine($"Decrypted chat from {payload.SenderUsername}"); string messageId; try { var dbEnc = ChannelCryptoService!.Encrypt(plainText, ChannelDbKey); var saved = CreateChannelMessageSync(new ChannelMessages { ChannelId = payload.ChannelId, SenderUserId = $"users:{payload.SenderUsername.ToLower()}", CipherText = dbEnc.cipherText, Nonce = dbEnc.nonce, Tag = dbEnc.tag, CreatedAt = DateTime.UtcNow }); messageId = GetRecordId(saved.Id); Console.WriteLine($"Message saved: {messageId}"); } catch (Exception ex) { Console.WriteLine($"Save failed: {ex.Message}"); return; } DeliverToServerMembers(plainText, payload.SenderUsername, payload.ChannelId, messageId, SignalType.EncryptedChat, isEdited: false); MirrorAttachmentIfNeeded(plainText, payload.SenderUsername, payload.ChannelId); } /// /// If the origin channel has LinkedFileChannelId set and the message has a non-gif /// attachment, stores+delivers a trimmed copy ("📎 Shared from #X by Y" + attachment) /// into the linked File channel. No-op for plain text messages. /// private void MirrorAttachmentIfNeeded(string plainText, string senderUsername, string originChannelId) { ChatMessageContent? content; try { content = JsonSerializer.Deserialize(plainText); } catch { return; } if (content is null || string.IsNullOrWhiteSpace(content.AttachmentBase64)) return; // The user wants images, zips, docs — but not gifs (and links/text aren't attachments anyway). var mime = content.AttachmentMimeType ?? string.Empty; if (mime.Equals("image/gif", StringComparison.OrdinalIgnoreCase)) return; var origin = GetChannelsSync().FirstOrDefault(ch => GetRecordId(ch.Id) == originChannelId); if (origin?.LinkedFileChannelId is null) return; var fileChannelId = origin.LinkedFileChannelId; if (originChannelId == fileChannelId) return; var mirror = new ChatMessageContent { Text = $"📎 Shared from #{origin.Name} by {senderUsername}", AttachmentBase64 = content.AttachmentBase64, AttachmentMimeType = content.AttachmentMimeType, AttachmentFileName = content.AttachmentFileName }; var mirrorPlain = JsonSerializer.Serialize(mirror); string mirrorId; try { var dbEnc = ChannelCryptoService!.Encrypt(mirrorPlain, ChannelDbKey); var saved = CreateChannelMessageSync(new ChannelMessages { ChannelId = fileChannelId, SenderUserId = $"users:{senderUsername.ToLower()}", CipherText = dbEnc.cipherText, Nonce = dbEnc.nonce, Tag = dbEnc.tag, CreatedAt = DateTime.UtcNow }); mirrorId = GetRecordId(saved.Id); } catch (Exception ex) { Console.WriteLine($"File mirror save failed: {ex.Message}"); return; } DeliverToServerMembers(mirrorPlain, senderUsername, fileChannelId, mirrorId, SignalType.EncryptedChat, isEdited: false); Console.WriteLine($"Mirrored attachment from {originChannelId} to file channel {fileChannelId}"); } /// /// Ownership-gated edit. Saves the OLD ciphertext as a ChannelMessageEdits row before /// overwriting the current row, so the edit chain is preserved. Broadcasts MessageEdited /// with the new ciphertext so every recipient updates their bubble in place. /// private void HandleEditMessage(string msg) { SocketEncryptedMessage? request; try { request = JsonSerializer.Deserialize(msg); } catch { Console.WriteLine("Failed to parse edit request."); return; } if (request is null || string.IsNullOrWhiteSpace(request.MessageId)) return; if (!EnsureCoreReady() || !EnsureCryptoReady()) return; var senderUsername = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(senderUsername)) return; var existing = GetChannelMessageByIdSync(request.MessageId); if (existing is null) { Console.WriteLine($"Edit: message {request.MessageId} not found."); return; } if (!string.Equals(ExtractUsernameFromUserId(existing.SenderUserId), senderUsername, StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($"Edit denied: {senderUsername} does not own {request.MessageId}."); return; } string newPlainText; try { newPlainText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = request.CipherText, Nonce = request.Nonce, Tag = request.Tag, EncryptedKey = request.EncryptedKey }, ServerPrivateKey); } catch (Exception ex) { Console.WriteLine($"Edit decrypt failed: {ex.Message}"); return; } try { CreateChannelMessageEditSync(new ChannelMessageEdits { MessageId = request.MessageId, CipherText = existing.CipherText, Nonce = existing.Nonce, Tag = existing.Tag, EditedAt = existing.EditedAt ?? existing.CreatedAt }); } catch (Exception ex) { Console.WriteLine($"Edit history save failed: {ex.Message}"); } try { var dbEnc = ChannelCryptoService!.Encrypt(newPlainText, ChannelDbKey); existing.CipherText = dbEnc.cipherText; existing.Nonce = dbEnc.nonce; existing.Tag = dbEnc.tag; existing.EditedAt = DateTime.UtcNow; UpdateChannelMessageSync(existing); Console.WriteLine($"Message {request.MessageId} edited by {senderUsername}."); } catch (Exception ex) { Console.WriteLine($"Edit DB update failed: {ex.Message}"); return; } DeliverToServerMembers(newPlainText, senderUsername, request.ChannelId, request.MessageId, SignalType.MessageEdited, isEdited: true); } /// /// Soft-delete (sets IsDeleted on the row). Allowed for the message author OR anyone with /// ManageMessages permission in the channel. Broadcasts a tombstone event to every /// connected member; their client swaps the bubble to a "deleted" placeholder. /// private void HandleDeleteMessage(string msg) { SocketEncryptedMessage? request; try { request = JsonSerializer.Deserialize(msg); } catch { Console.WriteLine("Failed to parse delete request."); return; } if (request is null || string.IsNullOrWhiteSpace(request.MessageId)) return; if (!EnsureCoreReady()) return; var senderUsername = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(senderUsername)) return; var existing = GetChannelMessageByIdSync(request.MessageId); if (existing is null) return; bool isOwner = string.Equals(ExtractUsernameFromUserId(existing.SenderUserId), senderUsername, StringComparison.OrdinalIgnoreCase); bool canManage = PermissionService?.CanManageMessagesAsync(senderUsername, request.ChannelId).GetAwaiter().GetResult() ?? false; if (!isOwner && !canManage) { Console.WriteLine($"Delete denied: {senderUsername} does not own {request.MessageId}."); return; } try { existing.IsDeleted = true; UpdateChannelMessageSync(existing); Console.WriteLine($"Message {request.MessageId} deleted by {senderUsername}."); } catch (Exception ex) { Console.WriteLine($"Delete DB update failed: {ex.Message}"); return; } var deletedEvent = JsonSerializer.Serialize(new SocketMessageDeletedEvent { Type = SignalType.MessageDeleted, MessageId = request.MessageId, ChannelId = request.ChannelId }); foreach (var member in GetServerMembersSync()) { var rawUsername = ExtractUsernameFromUserId(member.UserId); foreach (var sid in ConnectedClientService.GetSessionsForUser(rawUsername)) Sessions.SendTo(deletedEvent, sid); } } /// /// The fan-out for any chat-message delivery (new send, edit broadcast). For each /// server_members row, looks up active sessions, fetches that user's public key, encrypts /// the plaintext for them, and sends to every one of their sessions (multi-device). /// /// "ProperUsername" is the mixed-case version captured at RegisterKey time, used so the /// client's case-insensitive compare picks up the message instead of dropping it silently. /// private void DeliverToServerMembers( string plainText, string senderUsername, string channelId, string messageId, SignalType signalType, bool isEdited) { foreach (var member in GetServerMembersSync()) { var rawUsername = ExtractUsernameFromUserId(member.UserId); var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername); if (sessionIds.Count == 0) continue; 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); var json = JsonSerializer.Serialize(new SocketEncryptedMessage { Type = signalType, MessageId = messageId, SenderUsername = senderUsername, RecipientUsername = properUsername, ChannelId = channelId, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey, IsEdited = isEdited }); foreach (var sid in sessionIds) Sessions.SendTo(json, sid); } } /// /// Pushes a freshly-built channel list to every connected member. Has to compute the list /// PER user because CanPost/CanManage/visibility are user-specific. Called after Create/Delete. /// private void BroadcastChannelList() { foreach (var member in GetServerMembersSync()) { var rawUsername = ExtractUsernameFromUserId(member.UserId); var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername); if (sessionIds.Count == 0) continue; var properUsername = sessionIds .Select(ConnectedClientService.GetUsernameForSession) .FirstOrDefault(u => u is not null) ?? rawUsername; var channels = BuildChannelListForUser(properUsername); var json = JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels }); foreach (var sid in sessionIds) Sessions.SendTo(json, sid); } } /// /// Resolves the channel list a specific user can see, with CanPost/CanManage flags filled /// in. Visibility (ViewChannel) determines inclusion — denied channels are filtered out. /// private List BuildChannelListForUser(string username) { var rawChannels = GetChannelsSync() .Where(c => !c.IsDeleted) .OrderBy(c => c.CreatedAt) .ToList(); var items = new List(); foreach (var c in rawChannels) { var channelId = GetRecordId(c.Id); // "Visibility" — drop channels this user is not allowed to see. if (PermissionService is not null && !PermissionService.CanViewChannelAsync(username, channelId).GetAwaiter().GetResult()) continue; bool canPost = PermissionService is null || PermissionService.CanSendMessagesAsync(username, channelId).GetAwaiter().GetResult(); bool canManage = PermissionService is not null && (PermissionService.CanDeleteChannelAsync(username).GetAwaiter().GetResult() || PermissionService.CanEditChannelAsync(username).GetAwaiter().GetResult()); items.Add(new ChannelItem { ChannelId = channelId, Name = c.Name, Type = c.Type, Group = c.Group, IsReadOnly = c.IsReadOnly, CanPost = canPost, CanManage = canManage, CreatedAt = c.CreatedAt }); } return items; } /// /// WebSocketSharp callback when the connection drops (clean close OR network drop). Clears /// both presence registries so other clients aren't trying to send to a dead session. /// protected override void OnClose(CloseEventArgs e) { ConnectedClientService.Unregister(ID); RtcChannelPresenceService.RemoveSession(ID); Console.WriteLine($"WS closed: session={ID}, code={e.Code}"); base.OnClose(e); } /// WebSocketSharp callback for socket-level errors. Logged but non-fatal. protected override void OnError(ErrorEventArgs e) { Console.WriteLine($"WS error: session={ID}, message={e.Message}"); base.OnError(e); } // ------------------------------------------------------------------------- // Sync DB shims. WebSocketSharp's handler methods are synchronous, so async DB calls // are wrapped in Task.Run(...).GetAwaiter().GetResult(). Not ideal but pragmatic — the // alternative is refactoring WebSocketSharp's behavior model. // ------------------------------------------------------------------------- private void RegisterOrUpdateClientKeySync(string username, string publicKey) => Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)).GetAwaiter().GetResult(); private List GetChannelsSync() => Task.Run(async () => await Db!.Select("channels")).GetAwaiter().GetResult().ToList(); private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username) => Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username)).GetAwaiter().GetResult(); private List GetChannelMessagesSync() => Task.Run(async () => await Db!.Select("channel_messages")).GetAwaiter().GetResult().ToList(); private ChannelMessages? GetChannelMessageByIdSync(string messageId) => GetChannelMessagesSync().FirstOrDefault(m => GetRecordId(m.Id) == messageId); private ChannelMessages CreateChannelMessageSync(ChannelMessages message) => Task.Run(async () => await Db!.Create("channel_messages", message)).GetAwaiter().GetResult(); private void UpdateChannelMessageSync(ChannelMessages message) => Task.Run(async () => await Db!.Merge(message)).GetAwaiter().GetResult(); private void CreateChannelMessageEditSync(ChannelMessageEdits edit) => Task.Run(async () => await Db!.Create("channel_message_edits", edit)).GetAwaiter().GetResult(); private List GetChannelMessageEditsSync(string messageId) { var all = Task.Run(async () => await Db!.Select("channel_message_edits")) .GetAwaiter().GetResult().ToList(); return all.Where(e => e.MessageId == messageId).ToList(); } private List GetServerMembersSync() => Task.Run(async () => await Db!.Select("server_members")).GetAwaiter().GetResult().ToList(); /// "users:keeper317" → "keeper317". Stored as Surreal record id, displayed as plain name. private static string ExtractUsernameFromUserId(string senderUserId) { if (string.IsNullOrWhiteSpace(senderUserId)) return "Unknown"; var parts = senderUserId.Split(':', 2); return parts.Length == 2 ? parts[1] : senderUserId; } /// SurrealDB's Id object → "table:recordId" string. Used for storing parent refs as strings in child rows. 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; return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}"; } /// Guard: returns true if the DB and key service are both initialised. Logs and returns false otherwise. private bool EnsureCoreReady() { if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; } return true; } /// Guard: returns true if encryption keys + channel crypto service are all set. Logs and returns false otherwise. private bool EnsureCryptoReady() { if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null) { Console.WriteLine("Crypto keys null."); return false; } return true; } }