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;
}
}