916 lines
41 KiB
C#
916 lines
41 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public class ChatSocketBehavior : WebSocketBehavior
|
|
{
|
|
/// <summary>Reads/writes the client_public_keys table. Wired by Program.cs at boot.</summary>
|
|
public static ClientKeyService? ClientKeyService { get; set; }
|
|
|
|
/// <summary>The permission ladder evaluator. Wired by Program.cs at boot.</summary>
|
|
public static PermissionService? PermissionService { get; set; }
|
|
|
|
/// <summary>Base64 RSA public key — clients use this to encrypt outbound payloads to the server.</summary>
|
|
public static string? ServerPublicKey { get; set; }
|
|
|
|
/// <summary>Base64 RSA private key — used to decrypt inbound payloads. Never leaves the server.</summary>
|
|
public static string? ServerPrivateKey { get; set; }
|
|
|
|
/// <summary>Base64 AES-256 key for at-rest encryption of channel_messages.CipherText rows.</summary>
|
|
public static string? ChannelDbKey { get; set; }
|
|
|
|
/// <summary>AES-GCM-only encryption for stored messages. Wired by Program.cs at boot.</summary>
|
|
public static ChannelCryptoService? ChannelCryptoService { get; set; }
|
|
|
|
/// <summary>The SurrealDB connection. Wired by Program.cs at boot.</summary>
|
|
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<WsControlMessage>(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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>Switches on WsAction to the matching Handle* method. Pure routing — no I/O.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 }));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 }));
|
|
}
|
|
|
|
/// <summary>Sends the server's public RSA key. Called once per session right after RegisterKey.</summary>
|
|
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 }));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 }));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
}));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>Clears the session's voice-channel presence. Idempotent — safe to call when not in a channel.</summary>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<SocketEditHistoryEntry>();
|
|
|
|
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
|
|
}));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<Channels, Channels>(target))
|
|
.GetAwaiter().GetResult();
|
|
|
|
Console.WriteLine($"Channel deleted: {target.Name} by {username}");
|
|
BroadcastChannelList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void HandleEncryptedRtcSignal(string msg)
|
|
{
|
|
SocketRtcSignalMessage? payload;
|
|
try { payload = JsonSerializer.Deserialize<SocketRtcSignalMessage>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The main chat-message path. Permission gate → server-side decrypt → store with channel
|
|
/// key → DeliverToServerMembers (per-user re-encrypt + send) → MirrorAttachmentIfNeeded.
|
|
/// </summary>
|
|
private void HandleEncryptedChatMessage(string msg)
|
|
{
|
|
SocketEncryptedMessage? payload;
|
|
try { payload = JsonSerializer.Deserialize<SocketEncryptedMessage>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void MirrorAttachmentIfNeeded(string plainText, string senderUsername, string originChannelId)
|
|
{
|
|
ChatMessageContent? content;
|
|
try { content = JsonSerializer.Deserialize<ChatMessageContent>(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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void HandleEditMessage(string msg)
|
|
{
|
|
SocketEncryptedMessage? request;
|
|
try { request = JsonSerializer.Deserialize<SocketEncryptedMessage>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void HandleDeleteMessage(string msg)
|
|
{
|
|
SocketEncryptedMessage? request;
|
|
try { request = JsonSerializer.Deserialize<SocketEncryptedMessage>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the channel list a specific user can see, with CanPost/CanManage flags filled
|
|
/// in. Visibility (ViewChannel) determines inclusion — denied channels are filtered out.
|
|
/// </summary>
|
|
private List<ChannelItem> BuildChannelListForUser(string username)
|
|
{
|
|
var rawChannels = GetChannelsSync()
|
|
.Where(c => !c.IsDeleted)
|
|
.OrderBy(c => c.CreatedAt)
|
|
.ToList();
|
|
|
|
var items = new List<ChannelItem>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
protected override void OnClose(CloseEventArgs e)
|
|
{
|
|
ConnectedClientService.Unregister(ID);
|
|
RtcChannelPresenceService.RemoveSession(ID);
|
|
Console.WriteLine($"WS closed: session={ID}, code={e.Code}");
|
|
base.OnClose(e);
|
|
}
|
|
|
|
/// <summary>WebSocketSharp callback for socket-level errors. Logged but non-fatal.</summary>
|
|
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<Channels> GetChannelsSync() =>
|
|
Task.Run(async () => await Db!.Select<Channels>("channels")).GetAwaiter().GetResult().ToList();
|
|
|
|
private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username) =>
|
|
Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username)).GetAwaiter().GetResult();
|
|
|
|
private List<ChannelMessages> GetChannelMessagesSync() =>
|
|
Task.Run(async () => await Db!.Select<ChannelMessages>("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<ChannelMessages, ChannelMessages>(message)).GetAwaiter().GetResult();
|
|
|
|
private void CreateChannelMessageEditSync(ChannelMessageEdits edit) =>
|
|
Task.Run(async () => await Db!.Create("channel_message_edits", edit)).GetAwaiter().GetResult();
|
|
|
|
private List<ChannelMessageEdits> GetChannelMessageEditsSync(string messageId)
|
|
{
|
|
var all = Task.Run(async () => await Db!.Select<ChannelMessageEdits>("channel_message_edits"))
|
|
.GetAwaiter().GetResult().ToList();
|
|
return all.Where(e => e.MessageId == messageId).ToList();
|
|
}
|
|
|
|
private List<ServerMembers> GetServerMembersSync() =>
|
|
Task.Run(async () => await Db!.Select<ServerMembers>("server_members")).GetAwaiter().GetResult().ToList();
|
|
|
|
/// <summary>"users:keeper317" → "keeper317". Stored as Surreal record id, displayed as plain name.</summary>
|
|
private static string ExtractUsernameFromUserId(string senderUserId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(senderUserId)) return "Unknown";
|
|
var parts = senderUserId.Split(':', 2);
|
|
return parts.Length == 2 ? parts[1] : senderUserId;
|
|
}
|
|
|
|
/// <summary>SurrealDB's Id object → "table:recordId" string. Used for storing parent refs as strings in child rows.</summary>
|
|
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()}";
|
|
}
|
|
|
|
/// <summary>Guard: returns true if the DB and key service are both initialised. Logs and returns false otherwise.</summary>
|
|
private bool EnsureCoreReady()
|
|
{
|
|
if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; }
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Guard: returns true if encryption keys + channel crypto service are all set. Logs and returns false otherwise.</summary>
|
|
private bool EnsureCryptoReady()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null)
|
|
{
|
|
Console.WriteLine("Crypto keys null.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|