Updated, the update... should be working now hopefully...

This commit is contained in:
2026-05-30 21:11:33 -04:00
parent 1ed3efcc68
commit b62ceb1949
6 changed files with 568 additions and 413 deletions

View File

@@ -137,11 +137,11 @@ public partial class MainPage : ContentPage
await _rtc.PushRtcContextToJsAsync();
});
_socket.SendRaw($"GET_HISTORY|{_username}|{_currentChannelId}");
_socket.SendGetHistory(_currentChannelId);
}
private void HandleEncryptedChat(SocketEncryptedMessage payload) {
if (payload.RecipientUsername != _username)
if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase))
return;
string decryptedText;
@@ -229,7 +229,7 @@ public partial class MainPage : ContentPage
RenderCurrentChannelMessages();
if (!_messagesByChannel.ContainsKey(channel.ChannelId))
_socket.SendRaw($"GET_HISTORY|{_username}|{channel.ChannelId}");
_socket.SendGetHistory(channel.ChannelId);
};
SidebarList.Children.Add(button);

View File

@@ -1,4 +1,4 @@
using System.Text.Json;
using System.Text.Json;
using RelayClient.Crypto;
using RelayShared.Services;
using WebSocketSharp;
@@ -19,7 +19,7 @@ public sealed class RelaySocketClient
public event Action<string>? ServerPublicKeyReceived;
public event Action<string>? Log;
public RelaySocketClient(string username, string url = "ws://192.168.1.85:5001/")
public RelaySocketClient(string username, string url = "ws://127.0.0.1:5001/")
{
_username = username;
_socket = new WebSocket(url);
@@ -32,10 +32,57 @@ public sealed class RelaySocketClient
var publicKey = KeyStorage.LoadPublicKey(_username);
SendRaw($"AUTHENTICATE_USER|{_username}|{MainPage._userToken}");
SendRaw($"REGISTER_KEY|{_username}|{publicKey}");
SendRaw("GET_SERVER_KEY");
SendRaw("GET_CHANNELS");
SendControlMessage(new WsControlMessage
{
Action = WsAction.Authenticate,
Username = _username,
Token = MainPage._userToken
});
SendControlMessage(new WsControlMessage
{
Action = WsAction.RegisterKey,
Username = _username,
PublicKey = publicKey
});
SendControlMessage(new WsControlMessage { Action = WsAction.GetServerKey });
SendControlMessage(new WsControlMessage { Action = WsAction.GetChannels });
}
public void SendControlMessage(WsControlMessage message)
{
SendRaw(JsonSerializer.Serialize(message));
}
public void SendGetHistory(string channelId)
{
SendControlMessage(new WsControlMessage
{
Action = WsAction.GetHistory,
Username = _username,
ChannelId = channelId
});
}
public void SendRtcJoinChannel(string channelId)
{
SendControlMessage(new WsControlMessage
{
Action = WsAction.RtcJoin,
Username = _username,
ChannelId = channelId
});
}
public void SendRtcLeaveChannel(string channelId)
{
SendControlMessage(new WsControlMessage
{
Action = WsAction.RtcLeave,
Username = _username,
ChannelId = channelId
});
}
public void SendRaw(string message)
@@ -59,12 +106,6 @@ public sealed class RelaySocketClient
private void OnMessage(object? sender, MessageEventArgs e)
{
if (e.Data.StartsWith("SERVER:REGISTERED_KEY:"))
{
Log?.Invoke(e.Data);
return;
}
RawMessageReceived?.Invoke(e.Data);
Log?.Invoke($"[{_username}] RAW WS DATA: {e.Data}");
@@ -73,6 +114,31 @@ public sealed class RelaySocketClient
using var doc = JsonDocument.Parse(e.Data);
var root = doc.RootElement;
// Control event responses (WsEvent)
if (root.TryGetProperty("Event", out var eventElement))
{
var wsEvent = (WsEvent)eventElement.GetInt32();
switch (wsEvent)
{
case WsEvent.KeyRegistered:
Log?.Invoke($"[{_username}] Key registered on server.");
return;
case WsEvent.Authenticated:
Log?.Invoke($"[{_username}] Authenticated with server.");
return;
case WsEvent.Error:
var detail = root.TryGetProperty("Detail", out var d) ? d.GetString() : null;
Log?.Invoke($"[{_username}] Server error: {detail}");
return;
}
return;
}
// Data messages (SignalType)
if (!root.TryGetProperty("Type", out var typeElement))
return;

View File

@@ -31,7 +31,7 @@ public sealed class RtcBridgeService
if (string.IsNullOrWhiteSpace(channelId))
return Task.CompletedTask;
_socket.SendRaw($"RTC_JOIN_CHANNEL|{_username}|{channelId}");
_socket.SendRtcJoinChannel(channelId);
return Task.CompletedTask;
}
@@ -42,7 +42,7 @@ public sealed class RtcBridgeService
if (string.IsNullOrWhiteSpace(channelId))
return;
_socket.SendRaw($"RTC_LEAVE_CHANNEL|{_username}|{channelId}");
_socket.SendRtcLeaveChannel(channelId);
}
public void SendRtcSignal(string json)

View File

@@ -1,4 +1,4 @@
using System.Net.Http.Headers;
using System.Net.Http.Headers;
using System.Text.Json;
using RelayServer.Models;
using RelayServer.Services.Crypto;
@@ -12,9 +12,9 @@ using RelayShared.Services;
namespace RelayServer.Services.Chat;
/// <summary>
/// Handles websocket-based chat operations including client key registration,
/// server key retrieval, channel listing, channel history loading, and encrypted
/// channel message relay.
/// Handles websocket-based chat operations including authentication, client key
/// registration, server key retrieval, channel listing, channel history loading,
/// and encrypted channel message relay.
/// </summary>
public class ChatSocketBehavior : WebSocketBehavior
{
@@ -26,116 +26,328 @@ public class ChatSocketBehavior : WebSocketBehavior
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
/// <summary>
/// Routes incoming websocket messages to the appropriate chat handler.
/// Routes incoming websocket messages to the appropriate handler via JSON dispatch.
/// Control messages carry an <c>Action</c> property; data messages carry a <c>Type</c> property.
/// </summary>
/// <param name="e">The websocket message event arguments.</param>
protected override void OnMessage(MessageEventArgs e)
{
var msg = e.Data;
Console.WriteLine(msg);
if (msg.StartsWith("REGISTER_KEY|"))
{
HandleRegisterKey(msg);
return;
}
if (msg.StartsWith("AUTHENTICATE_USER"))
{
HandleAuth(msg);
return;
}
if (msg == "GET_SERVER_KEY")
{
HandleGetServerKey();
return;
}
if (msg == "GET_CHANNELS")
{
HandleGetChannels();
return;
}
if (msg.StartsWith("GET_HISTORY|"))
{
HandleGetHistory(msg);
return;
}
if (msg.StartsWith("RTC_JOIN_CHANNEL|"))
{
HandleRtcJoinChannel(msg);
return;
}
if (msg.StartsWith("RTC_LEAVE_CHANNEL|"))
{
HandleRtcLeaveChannel(msg);
return;
}
if (IsEncryptedRtcSignal(msg))
{
HandleEncryptedRtcSignal(msg);
return;
}
HandleEncryptedChatMessage(msg);
}
private static bool IsEncryptedRtcSignal(string msg)
{
try
{
using var doc = JsonDocument.Parse(msg);
var root = doc.RootElement;
if (!root.TryGetProperty("Type", out var typeProp))
return false;
var type = (SignalType)typeProp.GetInt32();
return type == SignalType.EncryptedSignal;
}
catch
if (root.TryGetProperty("Action", out var actionProp))
{
return false;
}
}
private async void HandleAuth(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid AUTHENTICATE_USERS payload.");
var action = (WsAction)actionProp.GetInt32();
var control = JsonSerializer.Deserialize<WsControlMessage>(msg)!;
DispatchControl(action, control);
return;
}
var username = parts[1];
var token = parts[2];
if (root.TryGetProperty("Type", out var typeProp))
{
var type = (SignalType)typeProp.GetInt32();
// HttpClient core = new HttpClient{BaseAddress = new Uri("http://127.0.0.1:1337")};
HttpClient core = new HttpClient{BaseAddress = new Uri("http://192.168.1.85:1337")};
core.DefaultRequestHeaders.Accept.Clear();
switch (type)
{
case SignalType.EncryptedSignal:
HandleEncryptedRtcSignal(msg);
return;
case SignalType.ClientEncryptedChat:
HandleEncryptedChatMessage(msg);
return;
}
}
Console.WriteLine($"Unrecognised WebSocket message from session={ID}: {msg[..Math.Min(200, msg.Length)]}");
}
catch (Exception ex)
{
Console.WriteLine($"WebSocket message error: session={ID}, error={ex.Message}");
}
}
/// <summary>
/// Dispatches a control message to the correct handler based on its action.
/// </summary>
private void DispatchControl(WsAction action, WsControlMessage control)
{
switch (action)
{
case WsAction.Authenticate:
HandleAuthenticate(control);
break;
case WsAction.RegisterKey:
HandleRegisterKey(control);
break;
case WsAction.GetServerKey:
HandleGetServerKey();
break;
case WsAction.GetChannels:
HandleGetChannels();
break;
case WsAction.GetHistory:
HandleGetHistory(control);
break;
case WsAction.RtcJoin:
HandleRtcJoinChannel(control);
break;
case WsAction.RtcLeave:
HandleRtcLeaveChannel(control);
break;
default:
Console.WriteLine($"Unknown WsAction {action} from session={ID}");
break;
}
}
// -------------------------------------------------------------------------
// Control handlers
// -------------------------------------------------------------------------
/// <summary>
/// Verifies a user token with the Core service. The HTTP call is wrapped in
/// a try-catch so that a network failure never crashes the WebSocket session.
/// </summary>
private async void HandleAuthenticate(WsControlMessage control)
{
if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.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");
HttpResponseMessage response = await core.PostAsJsonAsync("/server/verify/user", new AuthUserVerify
var response = await core.PostAsJsonAsync("/server/verify/user", new AuthUserVerify
{
Username = username,
Token = token
Username = control.Username,
Token = control.Token
});
Console.WriteLine(response.Content.ReadAsStringAsync().Result);
Console.WriteLine($"Auth response for {control.Username}: {await response.Content.ReadAsStringAsync()}");
}
catch (Exception ex)
{
Console.WriteLine($"Auth verification failed for {control.Username}: {ex.Message}");
}
var result = new WsEventMessage { Event = WsEvent.Authenticated, Detail = control.Username };
Send(JsonSerializer.Serialize(result));
}
/// <summary>
/// Stores (or updates) the client's public key and registers the session in
/// <see cref="ConnectedClientService"/> so targeted delivery can resolve session ids.
/// </summary>
private void HandleRegisterKey(WsControlMessage control)
{
if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.PublicKey))
{
Console.WriteLine("Invalid RegisterKey payload.");
return;
}
if (ClientKeyService is null)
{
Console.WriteLine("ClientKeyService is not initialized.");
return;
}
RegisterOrUpdateClientKeySync(control.Username, control.PublicKey);
ConnectedClientService.Register(ID, control.Username);
Console.WriteLine($"Registered key and session for {control.Username} (session={ID})");
var result = new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = control.Username };
Send(JsonSerializer.Serialize(result));
}
/// <summary>
/// Sends the server's public key to the requesting client.
/// </summary>
private void HandleGetServerKey()
{
if (string.IsNullOrWhiteSpace(ServerPublicKey))
{
Console.WriteLine("Server public key is not initialized.");
return;
}
var payload = new ServerPublicKeyMessage
{
Type = SignalType.ServerPublicKey,
PublicKey = ServerPublicKey
};
Send(JsonSerializer.Serialize(payload));
}
/// <summary>
/// Sends the current list of channels to the connected client.
/// </summary>
private void HandleGetChannels()
{
if (Db is null)
{
Console.WriteLine("Db is not initialized.");
return;
}
var channels = GetChannelsSync()
.OrderBy(c => c.CreatedAt)
.Select(c => new ChannelItem
{
ChannelId = GetRecordId(c.Id),
Name = c.Name,
CreatedAt = c.CreatedAt
})
.ToList();
var payload = new SocketChannelList
{
Type = SignalType.ChannelList,
Channels = channels
};
Send(JsonSerializer.Serialize(payload));
}
/// <summary>
/// Loads stored channel history for a specific channel, decrypts it from
/// database storage format, and sends it back encrypted for the requesting client.
/// </summary>
private void HandleGetHistory(WsControlMessage control)
{
var username = control.Username;
var channelId = control.ChannelId;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(channelId))
{
Console.WriteLine("Invalid GetHistory payload.");
return;
}
if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey))
{
Console.WriteLine("History dependencies are not initialized.");
return;
}
var targetClient = GetClientPublicKeyByUsernameSync(username);
if (targetClient is null)
{
Console.WriteLine($"No public key found for history request user {username}");
return;
}
var allMessages = GetChannelMessagesSync();
var channelMessages = allMessages
.Where(m => m.ChannelId == channelId)
.OrderBy(m => m.CreatedAt)
.ToList();
Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}");
foreach (var dbMessage in channelMessages)
{
string plainText;
try
{
plainText = ChannelCryptoService.Decrypt(
dbMessage.CipherText,
dbMessage.Nonce,
dbMessage.Tag,
ChannelDbKey
);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to decrypt DB history row {dbMessage.Id}: {ex.Message}");
continue;
}
var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey);
var outbound = new SocketEncryptedMessage
{
Type = SignalType.EncryptedChat,
SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId),
RecipientUsername = username,
ChannelId = channelId,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
Send(JsonSerializer.Serialize(outbound));
}
}
private void HandleRtcJoinChannel(WsControlMessage control)
{
var username = control.Username;
var channelId = control.ChannelId;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(channelId))
{
Console.WriteLine("Invalid RtcJoin payload.");
return;
}
RtcChannelPresenceService.SetUser(ID, username);
RtcChannelPresenceService.JoinChannel(ID, channelId);
Console.WriteLine($"RTC presence joined: session={ID}, user={username}, channel={channelId}");
}
private void HandleRtcLeaveChannel(WsControlMessage control)
{
var username = control.Username;
var channelId = control.ChannelId;
if (string.IsNullOrWhiteSpace(channelId))
{
Console.WriteLine("Invalid RtcLeave payload.");
return;
}
if (RtcChannelPresenceService.IsInChannel(ID, channelId))
RtcChannelPresenceService.LeaveChannel(ID);
Console.WriteLine($"RTC presence left: session={ID}, user={username}, channel={channelId}");
}
// -------------------------------------------------------------------------
// Data message handlers
// -------------------------------------------------------------------------
/// <summary>
/// Decrypts an incoming encrypted RTC signal and re-encrypts it for every
/// other session in the same RTC channel.
/// </summary>
private void HandleEncryptedRtcSignal(string msg)
{
Console.WriteLine("RTC SIGNAL HIT");
SocketRtcSignalMessage? clientPayload;
try
@@ -213,121 +425,10 @@ public class ChatSocketBehavior : WebSocketBehavior
}
/// <summary>
///
/// Decrypts an incoming encrypted chat message, stores it in the database,
/// then re-encrypts and delivers it individually to every connected server member.
/// Messages are never broadcast — each recipient receives their own encrypted copy.
/// </summary>
/// <param name="e"></param>
protected override void OnClose(CloseEventArgs e)
{
RtcChannelPresenceService.RemoveSession(ID);
Console.WriteLine($"WebSocket closed: session={ID}, code={e.Code}, reason={e.Reason}");
base.OnClose(e);
}
protected override void OnError(ErrorEventArgs e)
{
Console.WriteLine($"WebSocket error: session={ID}, message={e.Message}");
base.OnError(e);
}
/// <summary>
/// Extracts a display username from a stored user record id value.
/// </summary>
/// <param name="senderUserId">The stored sender user id.</param>
/// <returns>
/// The extracted username when possible; otherwise, a fallback value.
/// </returns>
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>
/// Registers or updates a client's public key from a websocket registration payload.
/// </summary>
/// <param name="msg">The raw websocket registration message.</param>
private void HandleRegisterKey(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid REGISTER_KEY payload.");
return;
}
var username = parts[1];
var publicKey = parts[2];
if (ClientKeyService is null)
{
Console.WriteLine("ClientKeyService is not initialized.");
return;
}
RegisterOrUpdateClientKeySync(username, publicKey);
Send($"SERVER:REGISTERED_KEY:{username}");
}
/// <summary>
/// Sends the current list of channels to the connected websocket client.
/// </summary>
private void HandleGetChannels()
{
if (Db is null)
{
Console.WriteLine("Db is not initialized.");
return;
}
//TODO: Update to include ChannelType and Group String on channels
var channels = GetChannelsSync()
.OrderBy(c => c.CreatedAt)
.Select(c => new ChannelItem()
{
ChannelId = GetRecordId(c.Id),
Name = c.Name,
CreatedAt = c.CreatedAt
})
.ToList();
var payload = new SocketChannelList
{
Type = SignalType.ChannelList,
Channels = channels
};
Send(JsonSerializer.Serialize(payload));
}
/// <summary>
/// Sends the server's public key to the connected websocket client.
/// </summary>
private void HandleGetServerKey()
{
if (string.IsNullOrWhiteSpace(ServerPublicKey))
{
Console.WriteLine("Server public key is not initialized.");
return;
}
var payload = new ServerPublicKeyMessage
{
Type = SignalType.ServerPublicKey,
PublicKey = ServerPublicKey
};
Send(JsonSerializer.Serialize(payload));
}
/// <summary>
/// Decrypts an incoming encrypted chat payload, stores it in the database,
/// and rebroadcasts it to connected clients encrypted with each client's public key.
/// </summary>
/// <param name="msg">The raw encrypted chat websocket message.</param>
private void HandleEncryptedChatMessage(string msg)
{
SocketEncryptedMessage? clientPayload;
@@ -370,9 +471,10 @@ public class ChatSocketBehavior : WebSocketBehavior
}
Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}");
try
{
var dbEncrypted = ChannelCryptoService.Encrypt(plainText, ChannelDbKey);
var dbEncrypted = ChannelCryptoService!.Encrypt(plainText, ChannelDbKey);
var savedMessage = CreateChannelMessageSync(new ChannelMessages
{
@@ -392,19 +494,40 @@ public class ChatSocketBehavior : WebSocketBehavior
return;
}
var allKeys = GetAllClientPublicKeysSync();
// Deliver to every connected server member individually.
var members = GetServerMembersSync();
foreach (var client in allKeys)
foreach (var member in members)
{
var encrypted = E2EeHelper.EncryptForRecipient(plainText, client.PublicKey);
// Derive the lowercase username from the stored record id (e.g. "users:keeper317").
var rawUsername = ExtractUsernameFromUserId(member.UserId);
Console.WriteLine($"Encrypting outbound message from {clientPayload.SenderUsername} for {client.Username}");
// Find all active sessions for this member (supports multi-device).
var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername);
if (sessionIds.Count == 0)
continue;
// Resolve the correctly-cased username as the client registered it.
var properUsername = sessionIds
.Select(ConnectedClientService.GetUsernameForSession)
.FirstOrDefault(u => u is not null) ?? rawUsername;
var clientKey = GetClientPublicKeyByUsernameSync(properUsername);
if (clientKey is null)
{
Console.WriteLine($"No public key for {properUsername}, skipping.");
continue;
}
var encrypted = E2EeHelper.EncryptForRecipient(plainText, clientKey.PublicKey);
Console.WriteLine($"Encrypting outbound message from {clientPayload.SenderUsername} for {properUsername}");
var outbound = new SocketEncryptedMessage
{
Type = SignalType.EncryptedChat,
SenderUsername = clientPayload.SenderUsername,
RecipientUsername = client.Username,
RecipientUsername = properUsername,
ChannelId = clientPayload.ChannelId,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
@@ -412,117 +535,35 @@ public class ChatSocketBehavior : WebSocketBehavior
EncryptedKey = encrypted.EncryptedKey
};
Sessions.Broadcast(JsonSerializer.Serialize(outbound));
var json = JsonSerializer.Serialize(outbound);
foreach (var sessionId in sessionIds)
Sessions.SendTo(json, sessionId);
}
}
/// <summary>
/// Loads stored channel history for a specific user and channel, decrypts it from
/// database storage format, and sends it back encrypted for the requesting client.
/// </summary>
/// <param name="msg">The raw history request websocket message.</param>
private void HandleGetHistory(string msg)
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
protected override void OnClose(CloseEventArgs e)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid GET_HISTORY payload.");
return;
ConnectedClientService.Unregister(ID);
RtcChannelPresenceService.RemoveSession(ID);
Console.WriteLine($"WebSocket closed: session={ID}, code={e.Code}, reason={e.Reason}");
base.OnClose(e);
}
var username = parts[1];
var channelId = parts[2];
if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey))
protected override void OnError(ErrorEventArgs e)
{
Console.WriteLine("History dependencies are not initialized.");
return;
Console.WriteLine($"WebSocket error: session={ID}, message={e.Message}");
base.OnError(e);
}
var targetClient = GetClientPublicKeyByUsernameSync(username);
// -------------------------------------------------------------------------
// Sync DB helpers
// -------------------------------------------------------------------------
if (targetClient is null)
{
Console.WriteLine($"No public key found for history request user {username}");
return;
}
var allMessages = GetChannelMessagesSync();
var channelMessages = allMessages
.Where(m => m.ChannelId == channelId)
.OrderBy(m => m.CreatedAt)
.ToList();
Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}");
foreach (var dbMessage in channelMessages)
{
string plainText;
try
{
plainText = ChannelCryptoService.Decrypt(
dbMessage.CipherText,
dbMessage.Nonce,
dbMessage.Tag,
ChannelDbKey
);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to decrypt DB history row {dbMessage.Id}: {ex.Message}");
continue;
}
var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey);
var outbound = new SocketEncryptedMessage
{
Type = SignalType.EncryptedChat,
SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId),
RecipientUsername = username,
ChannelId = channelId,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
Send(JsonSerializer.Serialize(outbound));
}
}
/// <summary>
/// Converts a SurrealDB record id object into a table:id string representation.
/// </summary>
/// <param name="id">The raw record id object.</param>
/// <returns>
/// A formatted record id string, or an empty string if the input is null.
/// </returns>
private static string GetRecordId(object? id)
{
if (id is null)
return string.Empty;
var json = JsonSerializer.Serialize(id);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var recordId = root.GetProperty("Id").GetString() ?? string.Empty;
var table = root.GetProperty("Table").GetString() ?? string.Empty;
return $"{table}:{recordId}";
}
/// <summary>
/// Synchronously registers or updates a stored client public key using the async key service.
/// </summary>
/// <param name="username">The client username.</param>
/// <param name="publicKey">The client's public key.</param>
private void RegisterOrUpdateClientKeySync(string username, string publicKey)
{
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey))
@@ -530,10 +571,6 @@ public class ChatSocketBehavior : WebSocketBehavior
.GetResult();
}
/// <summary>
/// Synchronously loads all channels from the database.
/// </summary>
/// <returns>A list of channel records.</returns>
private List<Channels> GetChannelsSync()
{
return Task.Run(async () => await Db!.Select<Channels>("channels"))
@@ -542,13 +579,6 @@ public class ChatSocketBehavior : WebSocketBehavior
.ToList();
}
/// <summary>
/// Synchronously gets the stored public key record for the specified user.
/// </summary>
/// <param name="username">The username to look up.</param>
/// <returns>
/// The matching client public key record, or null if none exists.
/// </returns>
private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username)
{
return Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username))
@@ -556,21 +586,6 @@ public class ChatSocketBehavior : WebSocketBehavior
.GetResult();
}
/// <summary>
/// Synchronously loads all stored client public key records.
/// </summary>
/// <returns>A list of all client public key records.</returns>
private List<ClientPublicKeys> GetAllClientPublicKeysSync()
{
return Task.Run(async () => await ClientKeyService!.GetAllAsync())
.GetAwaiter()
.GetResult();
}
/// <summary>
/// Synchronously loads all stored channel messages from the database.
/// </summary>
/// <returns>A list of channel message records.</returns>
private List<ChannelMessages> GetChannelMessagesSync()
{
return Task.Run(async () => await Db!.Select<ChannelMessages>("channel_messages"))
@@ -579,11 +594,6 @@ public class ChatSocketBehavior : WebSocketBehavior
.ToList();
}
/// <summary>
/// Synchronously creates a new channel message record in the database.
/// </summary>
/// <param name="message">The message record to create.</param>
/// <returns>The created channel message record.</returns>
private ChannelMessages CreateChannelMessageSync(ChannelMessages message)
{
return Task.Run(async () => await Db!.Create("channel_messages", message))
@@ -591,10 +601,49 @@ public class ChatSocketBehavior : WebSocketBehavior
.GetResult();
}
private List<ServerMembers> GetServerMembersSync()
{
return Task.Run(async () => await Db!.Select<ServerMembers>("server_members"))
.GetAwaiter()
.GetResult()
.ToList();
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
/// <summary>
///
/// Extracts a display username from a stored user record id value
/// (e.g. "users:keeper317" → "keeper317").
/// </summary>
/// <returns></returns>
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>
/// Converts a SurrealDB record id object into a "table:id" string.
/// </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;
var recordId = root.GetProperty("Id").GetString() ?? string.Empty;
var table = root.GetProperty("Table").GetString() ?? string.Empty;
return $"{table}:{recordId}";
}
private bool EnsureCoreReady()
{
if (ClientKeyService is null || Db is null)
@@ -606,10 +655,6 @@ public class ChatSocketBehavior : WebSocketBehavior
return true;
}
/// <summary>
///
/// </summary>
/// <returns></returns>
private bool EnsureCryptoReady()
{
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey))
@@ -626,50 +671,4 @@ public class ChatSocketBehavior : WebSocketBehavior
return true;
}
/// <summary>
///
/// </summary>
/// <param name="msg"></param>
private void HandleRtcJoinChannel(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid RTC_JOIN_CHANNEL payload.");
return;
}
var username = parts[1];
var channelId = parts[2];
RtcChannelPresenceService.SetUser(ID, username);
RtcChannelPresenceService.JoinChannel(ID, channelId);
Console.WriteLine($"RTC presence joined: session={ID}, user={username}, channel={channelId}");
}
/// <summary>
///
/// </summary>
/// <param name="msg"></param>
private void HandleRtcLeaveChannel(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid RTC_LEAVE_CHANNEL payload.");
return;
}
var username = parts[1];
var channelId = parts[2];
if (RtcChannelPresenceService.IsInChannel(ID, channelId))
{
RtcChannelPresenceService.LeaveChannel(ID);
}
Console.WriteLine($"RTC presence left: session={ID}, user={username}, channel={channelId}");
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
namespace RelayServer.Services.Chat;
public static class ConnectedClientService
{
private static readonly ConcurrentDictionary<string, string> SessionToUsername = new();
private static readonly ConcurrentDictionary<string, HashSet<string>> UsernameToSessions =
new(StringComparer.OrdinalIgnoreCase);
public static void Register(string sessionId, string username)
{
if (SessionToUsername.TryGetValue(sessionId, out var oldUsername) &&
!string.Equals(oldUsername, username, StringComparison.OrdinalIgnoreCase))
{
RemoveSessionFromUsername(sessionId, oldUsername);
}
SessionToUsername[sessionId] = username;
var sessions = UsernameToSessions.GetOrAdd(username, _ => new HashSet<string>(StringComparer.Ordinal));
lock (sessions)
sessions.Add(sessionId);
}
public static void Unregister(string sessionId)
{
if (SessionToUsername.TryRemove(sessionId, out var username))
RemoveSessionFromUsername(sessionId, username);
}
public static IReadOnlyCollection<string> GetSessionsForUser(string username)
{
if (UsernameToSessions.TryGetValue(username, out var sessions))
lock (sessions)
return sessions.ToList();
return Array.Empty<string>();
}
public static string? GetUsernameForSession(string sessionId) =>
SessionToUsername.TryGetValue(sessionId, out var u) ? u : null;
private static void RemoveSessionFromUsername(string sessionId, string username)
{
if (!UsernameToSessions.TryGetValue(username, out var sessions))
return;
lock (sessions)
{
sessions.Remove(sessionId);
if (sessions.Count == 0)
UsernameToSessions.TryRemove(username, out _);
}
}
}

View File

@@ -0,0 +1,34 @@
namespace RelayShared.Services;
public enum WsAction
{
Authenticate,
RegisterKey,
GetServerKey,
GetChannels,
GetHistory,
RtcJoin,
RtcLeave
}
public enum WsEvent
{
Authenticated,
KeyRegistered,
Error
}
public sealed class WsControlMessage
{
public WsAction Action { get; set; }
public string? Username { get; set; }
public string? Token { get; set; }
public string? ChannelId { get; set; }
public string? PublicKey { get; set; }
}
public sealed class WsEventMessage
{
public WsEvent Event { get; set; }
public string? Detail { get; set; }
}