diff --git a/RelayServer/Endpoints/RtcEndpoints.cs b/RelayServer/Endpoints/RtcEndpoints.cs
index b72c04c..7ec7347 100644
--- a/RelayServer/Endpoints/RtcEndpoints.cs
+++ b/RelayServer/Endpoints/RtcEndpoints.cs
@@ -5,38 +5,60 @@ namespace RelayServer.Endpoints;
public static class RtcEndpoints
{
+ ///
+ /// Maps all RTC-related HTTP endpoints used for joining calls, storing offers and answers,
+ /// writing ICE candidates, and leaving active calls.
+ ///
+ /// The web application to map endpoints onto.
public static void MapRtcEndpoints(this WebApplication app)
{
+ // Join a channel call and determine whether the caller should become the offerer.
app.MapPost("/api/rtc/join", async (RtcJoinRequest request, RtcCallService rtcCallService) =>
{
return Results.Ok(await rtcCallService.JoinCallAsync(request.ChannelId, request.Username));
});
+ // Store or update the current SDP offer for a channel call.
app.MapPost("/api/rtc/offer", async (RtcOffer request, RtcCallService rtcCallService) =>
{
await rtcCallService.WriteOfferAsync(request.ChannelId, request.Username, request.Sdp);
return Results.Ok();
});
- //TODO: Add call for if channelId has active call returning boolean value
+
+ // Return whether the specified channel currently has an active call.
+ app.MapGet("/api/rtc/active/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
+ {
+ return Results.Ok(await rtcCallService.HasActiveCallAsync(channelId));
+ });
+
+ // Return the latest stored SDP offer for the specified channel.
app.MapGet("/api/rtc/offer/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{
var offer = await rtcCallService.GetOfferAsync(channelId);
return offer is null ? Results.NotFound() : Results.Ok(offer);
- //TODO: Needs to include offer data as JSON
});
+ // Store a new SDP answer for the specified channel call.
app.MapPost("/api/rtc/answer", async (RtcAnswer request, RtcCallService rtcCallService) =>
{
await rtcCallService.WriteAnswerAsync(request.ChannelId, request.OfferUser, request.AnswerUser, request.Sdp);
- //TODO: Add call to clients already in call that a new answer has been made with answer details
return Results.Ok();
});
+ // Return all answers stored for the specified channel.
app.MapGet("/api/rtc/answers/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{
return Results.Ok(await rtcCallService.GetAnswersAsync(channelId));
});
+ // Return the latest answer stored for the specified channel.
+ app.MapGet("/api/rtc/answer/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
+ {
+ var answer = await rtcCallService.GetLatestAnswerAsync(channelId);
+ return answer is null ? Results.NotFound() : Results.Ok(answer);
+ });
+
+ // Store a new ICE candidate for the specified channel call.
app.MapPost("/api/rtc/candidate", async (RtcIceCandidate request, RtcCallService rtcCallService) =>
{
await rtcCallService.WriteIceCandidateAsync(
@@ -47,16 +69,28 @@ public static class RtcEndpoints
request.SdpMLineIndex,
request.Direction
);
- //TODO: Add call to clients already in call that a new ICE candidate has been made with ICE candidate details
return Results.Ok();
});
+ // Return all ICE candidates stored for the specified channel.
app.MapGet("/api/rtc/candidates/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{
return Results.Ok(await rtcCallService.GetIceCandidatesAsync(channelId));
});
+ // Return ICE candidates for the specified channel that belong to other users
+ // and match the requested direction.
+ app.MapGet("/api/rtc/candidates/{channelId}/{username}/{direction}", async (
+ string channelId,
+ string username,
+ string direction,
+ RtcCallService rtcCallService) =>
+ {
+ return Results.Ok(await rtcCallService.GetIceCandidatesForOthersAsync(channelId, username, direction));
+ });
+
+ // Leave the active call for the specified channel.
app.MapPost("/api/rtc/leave", async (RtcLeaveRequest request, RtcCallService rtcCallService) =>
{
await rtcCallService.LeaveCallAsync(request.ChannelId, request.Username);
diff --git a/RelayServer/Program.cs b/RelayServer/Program.cs
index f9243ac..1f8142b 100644
--- a/RelayServer/Program.cs
+++ b/RelayServer/Program.cs
@@ -1,185 +1,38 @@
-using System.Text.Json;
-using RelayServer.Services;
+using RelayServer.Endpoints;
+using RelayServer.Services.Chat;
+using RelayServer.Services.Core;
+using RelayServer.Services.Data;
using WebSocketSharp.Server;
-using Microsoft.AspNetCore.SignalR;
-using RelayServer.Models;
var surrealService = new SurrealService();
var coreClient = new CoreClientService();
var cryptoService = new ChannelCryptoService();
-//TODO: Move everything into a MAIN function
+
await using var db = await surrealService.ConnectAsync();
-ChatTest.ClientKeyService = new ClientKeyService(db);
-ChatTest.Db = db;
+ChatSocketBehavior.ClientKeyService = new ClientKeyService(db);
+ChatSocketBehavior.Db = db;
+ChatSocketBehavior.ChannelCryptoService = cryptoService;
+
+var bootstrapService = new ServerBootstrapService(db, coreClient, cryptoService);
+await bootstrapService.InitializeAsync();
var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddSignalR();
-
var app = builder.Build();
+
app.MapGet("/", () => "Server Running!");
-app.MapHub("/webrtc");
+app.MapRtcEndpoints();
var wssv = new WebSocketServer("ws://localhost:1337");
-wssv.AddWebSocketService("/");
+wssv.AddWebSocketService("/");
wssv.Start();
Console.WriteLine("WebSocket server started");
-var keeper = await coreClient.GetUserByUsernameAsync("Keeper317");
-var kira = await coreClient.GetUserByUsernameAsync("Ru_Kira");
-var test = await coreClient.GetUserByUsernameAsync("Test");
-
-if (keeper is null || kira is null || test is null)
-{
- Console.WriteLine("One or more required users do not exist in RelayCore.");
- return;
-}
-
-if (!keeper.Licensed || !kira.Licensed || !test.Licensed)
-{
- Console.WriteLine("One or more required users are not licensed.");
- return;
-}
-
-Console.WriteLine($"Core verified user: {keeper.Username}");
-Console.WriteLine($"Core verified user: {kira.Username}");
-Console.WriteLine($"Core verified user: {test.Username}");
-
-var server = await db.Create("servers", new Servers
-{
- Name = "Test Server",
- OwnerUserId = keeper.Id,
- CreatedAt = DateTime.UtcNow
-});
-
-Console.WriteLine($"Server created: {ToJsonString(server)}");
-//TODO: Removed unused vars
-var keeperMember = await db.Create("server_members", new ServerMembers
-{
- UserId = keeper.Id,
- JoinedAt = DateTime.UtcNow,
- IsOwner = true
-});
-
-var kiraMember = await db.Create("server_members", new ServerMembers
-{
- UserId = kira.Id,
- JoinedAt = DateTime.UtcNow,
- IsOwner = false
-});
-
-var testMember = await db.Create("server_members", new ServerMembers
-{
- UserId = test.Id,
- JoinedAt = DateTime.UtcNow,
- IsOwner = false
-});
-
-Console.WriteLine("Server members created.");
-//TODO: Make channels dynamically addable
-//TODO: Add logic for channel types (ENUM)
-//TODO: Add a test voice channel
-//TODO: Add logic for channel groups for future UI use
-var channel = await db.Create("channels", new Channels
-{
- Name = "general",
- CreatedAt = DateTime.UtcNow
-});
-
-var channel2 = await db.Create("channels", new Channels
-{
- Name = "files",
- CreatedAt = DateTime.UtcNow.Subtract(new TimeSpan(0, 4, 0, 0))
-});
-
-var channel3 = await db.Create("channels", new Channels
-{
- Name = "welcome",
- CreatedAt = DateTime.UtcNow.Subtract(new TimeSpan(1, 4, 4, 4))
-});
-
-Console.WriteLine($"Channel created: {ToJsonString(channel)}");
-Console.WriteLine($"Channel created: {ToJsonString(channel2)}");
-Console.WriteLine($"Channel created: {ToJsonString(channel3)}");
-
-var channelId = GetRecordId(channel.Id);
-var channelId2 = GetRecordId(channel2.Id);
-var channelId3 = GetRecordId(channel3.Id);
-
-Console.WriteLine($"Resolved channelId: {channelId}");
-Console.WriteLine($"Resolved channelId: {channelId2}");
-Console.WriteLine($"Resolved channelId: {channelId3}");
-
-var keyBase64 = cryptoService.GenerateKey();
-var serverKeys = E2EeHelper.GenerateRsaKeyPair();
-
-var serverKey = await db.Create("server_encryption_keys", new ServerEncryptionKeys
-{
- KeyBase64 = keyBase64,
- PublicKey = serverKeys.publicKey,
- PrivateKey = serverKeys.privateKey,
- CreatedAt = DateTime.UtcNow,
- UpdatedAt = DateTime.UtcNow
-});
-
-ChatTest.ServerPublicKey = serverKeys.publicKey;
-ChatTest.ServerPrivateKey = serverKeys.privateKey;
-ChatTest.ChannelDbKey = keyBase64;
-
-Console.WriteLine("Server encryption key created.");
-
await app.StartAsync();
+Console.WriteLine("HTTP API started");
-Console.ReadKey(true); //TODO: Make program stop be a console command rather than just [RETURN]
+Console.ReadKey(true); // TODO: Make program stop be a console command
wssv.Stop();
-await app.StopAsync();
-return;
-
-static string ToJsonString(object? obj)
-{
- return JsonSerializer.Serialize(obj, new JsonSerializerOptions
- {
- WriteIndented = true,
- Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
- });
-}
-
-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}";
-}
-
-//TODO: Cleanup unused code
-public class WebRtcHub : Hub
-{
- public async Task SendOffer(string targetConnectionId, string sdp)
- {
- await Clients.Client(targetConnectionId)
- .SendAsync("ReceiveOffer", Context.ConnectionId, sdp);
- }
-
- public async Task SendAnswer(string targetConnectionId, string sdp)
- {
- await Clients.Client(targetConnectionId)
- .SendAsync("ReceiveAnswer", Context.ConnectionId, sdp);
- }
-
- public async Task SendIceCandidate(string targetConnectionId, string candidate)
- {
- await Clients.Client(targetConnectionId)
- .SendAsync("ReceiveIceCandidate", Context.ConnectionId, candidate);
- }
-}
\ No newline at end of file
+await app.StopAsync();
+return;
\ No newline at end of file
diff --git a/RelayServer/Services/Chat/ChannelCryptoService.cs b/RelayServer/Services/Chat/ChannelCryptoService.cs
index a667754..1df526e 100644
--- a/RelayServer/Services/Chat/ChannelCryptoService.cs
+++ b/RelayServer/Services/Chat/ChannelCryptoService.cs
@@ -1,7 +1,7 @@
using System.Security.Cryptography;
using System.Text;
-namespace RelayServer.Services;
+namespace RelayServer.Services.Chat;
public sealed class ChannelCryptoService
{
diff --git a/RelayServer/Services/Chat/ChannelMessageService.cs b/RelayServer/Services/Chat/ChannelMessageService.cs
deleted file mode 100644
index 3ae8e4b..0000000
--- a/RelayServer/Services/Chat/ChannelMessageService.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace RelayServer.Services;
-
-public class ChannelMessageService
-{
-
-}
\ No newline at end of file
diff --git a/RelayServer/Services/Chat/ChatTest.cs b/RelayServer/Services/Chat/ChatSocketBehavior.cs
similarity index 52%
rename from RelayServer/Services/Chat/ChatTest.cs
rename to RelayServer/Services/Chat/ChatSocketBehavior.cs
index 26cade0..3d9bfa5 100644
--- a/RelayServer/Services/Chat/ChatTest.cs
+++ b/RelayServer/Services/Chat/ChatSocketBehavior.cs
@@ -1,20 +1,30 @@
using System.Text.Json;
using RelayServer.Models;
+using RelayServer.Services.Crypto;
+using RelayServer.Services.Data;
using WebSocketSharp;
using WebSocketSharp.Server;
-namespace RelayServer.Services;
+namespace RelayServer.Services.Chat;
-public class ChatTest : WebSocketBehavior
+///
+/// Handles websocket-based chat operations including client key registration,
+/// server key retrieval, channel listing, channel history loading, and encrypted
+/// channel message relay.
+///
+public class ChatSocketBehavior : WebSocketBehavior
{
public static ClientKeyService? ClientKeyService { get; set; }
public static string? ServerPublicKey { get; set; }
public static string? ServerPrivateKey { get; set; }
public static string? ChannelDbKey { get; set; }
+ public static ChannelCryptoService? ChannelCryptoService { get; set; }
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
- private static readonly Dictionary ActiveRtcOffersByChannel = new();
- private static readonly HashSet ActiveRtcChannels = new();
+ ///
+ /// Routes incoming websocket messages to the appropriate chat handler.
+ ///
+ /// The websocket message event arguments.
protected override void OnMessage(MessageEventArgs e)
{
var msg = e.Data;
@@ -44,25 +54,16 @@ public class ChatTest : WebSocketBehavior
return;
}
- SocketRtcSignalMessage? rtcProbe = null;
- try
- {
- rtcProbe = JsonSerializer.Deserialize(msg);
- }
- catch
- {
- // ignored
- }
-
- if (rtcProbe?.Type == "encrypted_rtc_signal")
- {
- HandleEncryptedRtcSignal(msg);
- return;
- }
-
- HandleEncryptedClientMessage(msg);
+ HandleEncryptedChatMessage(msg);
}
+ ///
+ /// Extracts a display username from a stored user record id value.
+ ///
+ /// The stored sender user id.
+ ///
+ /// The extracted username when possible; otherwise, a fallback value.
+ ///
private static string ExtractUsernameFromUserId(string senderUserId)
{
if (string.IsNullOrWhiteSpace(senderUserId))
@@ -72,6 +73,10 @@ public class ChatTest : WebSocketBehavior
return parts.Length == 2 ? parts[1] : senderUserId;
}
+ ///
+ /// Registers or updates a client's public key from a websocket registration payload.
+ ///
+ /// The raw websocket registration message.
private void HandleRegisterKey(string msg)
{
var parts = msg.Split('|', 3);
@@ -91,12 +96,14 @@ public class ChatTest : WebSocketBehavior
return;
}
- Task.Run(async () => { await ClientKeyService.RegisterOrUpdateKeyAsync(username, publicKey); }).GetAwaiter()
- .GetResult();
+ RegisterOrUpdateClientKeySync(username, publicKey);
Send($"SERVER:REGISTERED_KEY:{username}");
}
+ ///
+ /// Sends the current list of channels to the connected websocket client.
+ ///
private void HandleGetChannels()
{
if (Db is null)
@@ -105,9 +112,7 @@ public class ChatTest : WebSocketBehavior
return;
}
- var channels = Task.Run(async () => await Db.Select("channels"))
- .GetAwaiter()
- .GetResult()
+ var channels = GetChannelsSync()
.OrderBy(c => c.CreatedAt)
.Select(c => new SocketChannelInfo
{
@@ -126,6 +131,9 @@ public class ChatTest : WebSocketBehavior
Send(JsonSerializer.Serialize(payload));
}
+ ///
+ /// Sends the server's public key to the connected websocket client.
+ ///
private void HandleGetServerKey()
{
if (string.IsNullOrWhiteSpace(ServerPublicKey))
@@ -143,7 +151,12 @@ public class ChatTest : WebSocketBehavior
Send(JsonSerializer.Serialize(payload));
}
- private void HandleEncryptedClientMessage(string msg)
+ ///
+ /// Decrypts an incoming encrypted chat payload, stores it in the database,
+ /// and rebroadcasts it to connected clients encrypted with each client's public key.
+ ///
+ /// The raw encrypted chat websocket message.
+ private void HandleEncryptedChatMessage(string msg)
{
SocketEncryptedMessage? clientPayload;
@@ -160,14 +173,8 @@ public class ChatTest : WebSocketBehavior
if (clientPayload is null || clientPayload.Type != "client_encrypted_chat")
return;
- if (ClientKeyService is null ||
- Db is null ||
- string.IsNullOrWhiteSpace(ServerPrivateKey) ||
- string.IsNullOrWhiteSpace(ChannelDbKey))
- {
- Console.WriteLine("Server crypto/database dependencies are not initialized.");
+ if (!EnsureCoreReady() || !EnsureCryptoReady())
return;
- }
string plainText;
@@ -193,20 +200,17 @@ public class ChatTest : WebSocketBehavior
Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}");
try
{
- var channelCrypto = new ChannelCryptoService();
- var dbEncrypted = channelCrypto.Encrypt(plainText, ChannelDbKey);
+ var dbEncrypted = ChannelCryptoService.Encrypt(plainText, ChannelDbKey);
- var savedMessage = Task.Run(async () =>
- await Db.Create("channel_messages", new ChannelMessages
- {
- ChannelId = clientPayload.ChannelId,
- SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}",
- CipherText = dbEncrypted.cipherText,
- Nonce = dbEncrypted.nonce,
- Tag = dbEncrypted.tag,
- CreatedAt = DateTime.UtcNow
- })
- ).GetAwaiter().GetResult();
+ var savedMessage = CreateChannelMessageSync(new ChannelMessages
+ {
+ ChannelId = clientPayload.ChannelId,
+ SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}",
+ CipherText = dbEncrypted.cipherText,
+ Nonce = dbEncrypted.nonce,
+ Tag = dbEncrypted.tag,
+ CreatedAt = DateTime.UtcNow
+ });
Console.WriteLine($"Live message saved to DB: {JsonSerializer.Serialize(savedMessage)}");
}
@@ -216,9 +220,7 @@ public class ChatTest : WebSocketBehavior
return;
}
- var allKeys = Task.Run(async () => await ClientKeyService.GetAllAsync())
- .GetAwaiter()
- .GetResult();
+ var allKeys = GetAllClientPublicKeysSync();
foreach (var client in allKeys)
{
@@ -242,6 +244,11 @@ public class ChatTest : WebSocketBehavior
}
}
+ ///
+ /// Loads stored channel history for a specific user and channel, decrypts it from
+ /// database storage format, and sends it back encrypted for the requesting client.
+ ///
+ /// The raw history request websocket message.
private void HandleGetHistory(string msg)
{
var parts = msg.Split('|', 3);
@@ -255,17 +262,13 @@ public class ChatTest : WebSocketBehavior
var username = parts[1];
var channelId = parts[2];
- if (ClientKeyService is null ||
- Db is null ||
- string.IsNullOrWhiteSpace(ChannelDbKey))
+ if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey))
{
Console.WriteLine("History dependencies are not initialized.");
return;
}
- var targetClient = Task.Run(async () => await ClientKeyService.GetByUsernameAsync(username))
- .GetAwaiter()
- .GetResult();
+ var targetClient = GetClientPublicKeyByUsernameSync(username);
if (targetClient is null)
{
@@ -273,9 +276,7 @@ public class ChatTest : WebSocketBehavior
return;
}
- var allMessages = Task.Run(async () => await Db.Select("channel_messages"))
- .GetAwaiter()
- .GetResult();
+ var allMessages = GetChannelMessagesSync();
var channelMessages = allMessages
.Where(m => m.ChannelId == channelId)
@@ -284,15 +285,13 @@ public class ChatTest : WebSocketBehavior
Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}");
- var channelCrypto = new ChannelCryptoService();
-
foreach (var dbMessage in channelMessages)
{
string plainText;
try
{
- plainText = channelCrypto.Decrypt(
+ plainText = ChannelCryptoService.Decrypt(
dbMessage.CipherText,
dbMessage.Nonce,
dbMessage.Tag,
@@ -323,6 +322,13 @@ public class ChatTest : WebSocketBehavior
}
}
+ ///
+ /// Converts a SurrealDB record id object into a table:id string representation.
+ ///
+ /// The raw record id object.
+ ///
+ /// A formatted record id string, or an empty string if the input is null.
+ ///
private static string GetRecordId(object? id)
{
if (id is null)
@@ -339,155 +345,105 @@ public class ChatTest : WebSocketBehavior
return $"{table}:{recordId}";
}
-
- private void HandleEncryptedRtcSignal(string msg)
+
+ ///
+ /// Synchronously registers or updates a stored client public key using the async key service.
+ ///
+ /// The client username.
+ /// The client's public key.
+ private void RegisterOrUpdateClientKeySync(string username, string publicKey)
{
- SocketRtcSignalMessage? clientPayload;
-
- try
- {
- clientPayload = JsonSerializer.Deserialize(msg);
- }
- catch
- {
- Console.WriteLine("Failed to parse encrypted RTC signal payload.");
- return;
- }
-
- if (clientPayload is null || clientPayload.Type != "encrypted_rtc_signal")
- return;
-
- if (ClientKeyService is null || string.IsNullOrWhiteSpace(ServerPrivateKey))
- {
- Console.WriteLine("Server RTC crypto dependencies are not initialized.");
- return;
- }
-
- string plainJson;
-
- try
- {
- plainJson = E2EeHelper.DecryptForRecipient(
- new EncryptedPayload
- {
- CipherText = clientPayload.CipherText,
- Nonce = clientPayload.Nonce,
- Tag = clientPayload.Tag,
- EncryptedKey = clientPayload.EncryptedKey
- },
- ServerPrivateKey
- );
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Failed to decrypt RTC signal payload: {ex.Message}");
- return;
- }
-
- RtcSignalMessage? rtcSignal;
-
- try
- {
- rtcSignal = JsonSerializer.Deserialize(plainJson);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Failed to parse decrypted RTC signal JSON: {ex.Message}");
- return;
- }
-
- if (rtcSignal is null)
- return;
-
- var allKeys = Task.Run(async () => await ClientKeyService.GetAllAsync())
+ Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey))
.GetAwaiter()
.GetResult();
+ }
- if (rtcSignal.Type == "rtc_join")
+ ///
+ /// Synchronously loads all channels from the database.
+ ///
+ /// A list of channel records.
+ private List GetChannelsSync()
+ {
+ return Task.Run(async () => await Db!.Select("channels"))
+ .GetAwaiter()
+ .GetResult()
+ .ToList();
+ }
+
+ ///
+ /// Synchronously gets the stored public key record for the specified user.
+ ///
+ /// The username to look up.
+ ///
+ /// The matching client public key record, or null if none exists.
+ ///
+ private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username)
+ {
+ return Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username))
+ .GetAwaiter()
+ .GetResult();
+ }
+
+ ///
+ /// Synchronously loads all stored client public key records.
+ ///
+ /// A list of all client public key records.
+ private List GetAllClientPublicKeysSync()
+ {
+ return Task.Run(async () => await ClientKeyService!.GetAllAsync())
+ .GetAwaiter()
+ .GetResult();
+ }
+
+ ///
+ /// Synchronously loads all stored channel messages from the database.
+ ///
+ /// A list of channel message records.
+ private List GetChannelMessagesSync()
+ {
+ return Task.Run(async () => await Db!.Select("channel_messages"))
+ .GetAwaiter()
+ .GetResult()
+ .ToList();
+ }
+
+ ///
+ /// Synchronously creates a new channel message record in the database.
+ ///
+ /// The message record to create.
+ /// The created channel message record.
+ private ChannelMessages CreateChannelMessageSync(ChannelMessages message)
+ {
+ return Task.Run(async () => await Db!.Create("channel_messages", message))
+ .GetAwaiter()
+ .GetResult();
+ }
+
+ private bool EnsureCoreReady()
+ {
+ if (ClientKeyService is null || Db is null)
{
- var joinState = new
- {
- type = "rtc_join_state",
- from = "server",
- channelId = rtcSignal.ChannelId,
- isInitiator = !ActiveRtcOffersByChannel.ContainsKey(rtcSignal.ChannelId)
- };
-
- var senderClient = allKeys.FirstOrDefault(x => x.Username == clientPayload.SenderUsername);
- if (senderClient is null)
- {
- Console.WriteLine($"No client key found for RTC join sender {clientPayload.SenderUsername}");
- return;
- }
-
- var joinStateJson = JsonSerializer.Serialize(joinState);
- var encryptedJoinState = E2EeHelper.EncryptForRecipient(joinStateJson, senderClient.PublicKey);
-
- var joinStateOutbound = new SocketRtcSignalMessage
- {
- Type = "encrypted_rtc_signal",
- SenderUsername = "server",
- ChannelId = clientPayload.ChannelId,
- CipherText = encryptedJoinState.CipherText,
- Nonce = encryptedJoinState.Nonce,
- Tag = encryptedJoinState.Tag,
- EncryptedKey = encryptedJoinState.EncryptedKey
- };
-
- Send(JsonSerializer.Serialize(joinStateOutbound));
-
- if (ActiveRtcOffersByChannel.TryGetValue(rtcSignal.ChannelId, out var storedOfferJson))
- {
- var encryptedStoredOffer = E2EeHelper.EncryptForRecipient(storedOfferJson, senderClient.PublicKey);
-
- var storedOfferOutbound = new SocketRtcSignalMessage
- {
- Type = "encrypted_rtc_signal",
- SenderUsername = "server",
- ChannelId = clientPayload.ChannelId,
- CipherText = encryptedStoredOffer.CipherText,
- Nonce = encryptedStoredOffer.Nonce,
- Tag = encryptedStoredOffer.Tag,
- EncryptedKey = encryptedStoredOffer.EncryptedKey
- };
-
- Send(JsonSerializer.Serialize(storedOfferOutbound));
- }
-
- return;
+ Console.WriteLine("Core services not initialized.");
+ return false;
}
- if (rtcSignal.Type == "rtc_offer")
+ return true;
+ }
+
+ private bool EnsureCryptoReady()
+ {
+ if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey))
{
- ActiveRtcOffersByChannel[rtcSignal.ChannelId] = plainJson;
- ActiveRtcChannels.Add(rtcSignal.ChannelId);
+ Console.WriteLine("Crypto keys not initialized.");
+ return false;
}
- if (rtcSignal.Type == "rtc_leave")
+ if (ChannelCryptoService is null)
{
- ActiveRtcOffersByChannel.Remove(rtcSignal.ChannelId);
- ActiveRtcChannels.Remove(rtcSignal.ChannelId);
+ Console.WriteLine("ChannelCryptoService is not initialized.");
+ return false;
}
- foreach (var client in allKeys)
- {
- if (client.Username == clientPayload.SenderUsername)
- continue;
-
- var encrypted = E2EeHelper.EncryptForRecipient(plainJson, client.PublicKey);
-
- var outbound = new SocketRtcSignalMessage
- {
- Type = "encrypted_rtc_signal",
- SenderUsername = clientPayload.SenderUsername,
- ChannelId = clientPayload.ChannelId,
- CipherText = encrypted.CipherText,
- Nonce = encrypted.Nonce,
- Tag = encrypted.Tag,
- EncryptedKey = encrypted.EncryptedKey
- };
-
- Sessions.Broadcast(JsonSerializer.Serialize(outbound));
- }
+ return true;
}
}
\ No newline at end of file
diff --git a/RelayServer/Services/Core/CoreClientService.cs b/RelayServer/Services/Core/CoreClientService.cs
index b452dbb..4af29bb 100644
--- a/RelayServer/Services/Core/CoreClientService.cs
+++ b/RelayServer/Services/Core/CoreClientService.cs
@@ -1,4 +1,4 @@
-namespace RelayServer.Services;
+namespace RelayServer.Services.Core;
public sealed class CoreClientService
{
diff --git a/RelayServer/Services/Core/ServerBootstrapService.cs b/RelayServer/Services/Core/ServerBootstrapService.cs
index 62ec730..d8b3641 100644
--- a/RelayServer/Services/Core/ServerBootstrapService.cs
+++ b/RelayServer/Services/Core/ServerBootstrapService.cs
@@ -1,6 +1,195 @@
-namespace RelayServer.Services;
+using System.Text.Json;
+using RelayServer.Models;
+using RelayServer.Services.Chat;
+using RelayServer.Services.Crypto;
+using SurrealDb.Net;
-public class ServerBootstrapService
+namespace RelayServer.Services.Core;
+
+public sealed class ServerBootstrapService
{
+ // TODO: Make channels dynamically addable
+ // TODO: Add logic for channel types (ENUM)
+ // TODO: Add logic for channel groups for future UI use
+ private readonly SurrealDbClient _db;
+ private readonly CoreClientService _coreClient;
+ private readonly ChannelCryptoService _cryptoService;
+
+ public ServerBootstrapService(
+ SurrealDbClient db,
+ CoreClientService coreClient,
+ ChannelCryptoService cryptoService)
+ {
+ _db = db;
+ _coreClient = coreClient;
+ _cryptoService = cryptoService;
+ }
+
+ public async Task InitializeAsync()
+ {
+ var keeper = await _coreClient.GetUserByUsernameAsync("Keeper317");
+ var kira = await _coreClient.GetUserByUsernameAsync("Ru_Kira");
+ var test = await _coreClient.GetUserByUsernameAsync("Test");
+
+ if (keeper is null || kira is null || test is null)
+ throw new InvalidOperationException("One or more required users do not exist in RelayCore.");
+
+ if (!keeper.Licensed || !kira.Licensed || !test.Licensed)
+ throw new InvalidOperationException("One or more required users are not licensed.");
+
+ Console.WriteLine($"Core verified user: {keeper.Username}");
+ Console.WriteLine($"Core verified user: {kira.Username}");
+ Console.WriteLine($"Core verified user: {test.Username}");
+
+ var server = await GetServerByNameAsync("Test Server");
+
+ if (server is null)
+ {
+ server = await _db.Create("servers", new Servers
+ {
+ Name = "Test Server",
+ OwnerUserId = keeper.Id,
+ CreatedAt = DateTime.UtcNow
+ });
+
+ Console.WriteLine($"Server created: {ToJsonString(server)}");
+ }
+ else
+ {
+ Console.WriteLine($"Server already exists: {ToJsonString(server)}");
+ }
+
+ await EnsureServerMemberAsync(keeper.Id, true);
+ await EnsureServerMemberAsync(kira.Id, false);
+ await EnsureServerMemberAsync(test.Id, false);
+
+ Console.WriteLine("Server members ensured.");
+
+ var channel = await EnsureChannelAsync("general", DateTime.UtcNow);
+ var channel2 = await EnsureChannelAsync("files", DateTime.UtcNow.Subtract(new TimeSpan(0, 4, 0, 0)));
+ var channel3 = await EnsureChannelAsync("welcome", DateTime.UtcNow.Subtract(new TimeSpan(1, 4, 4, 4)));
+ var channel4 = await EnsureChannelAsync("voice-general", DateTime.UtcNow.Subtract(new TimeSpan(0, 2, 0, 0)));
+
+ Console.WriteLine($"Resolved channelId: {GetRecordId(channel.Id)}");
+ Console.WriteLine($"Resolved channelId: {GetRecordId(channel2.Id)}");
+ Console.WriteLine($"Resolved channelId: {GetRecordId(channel3.Id)}");
+ Console.WriteLine($"Resolved channelId: {GetRecordId(channel4.Id)}");
+
+ var existingKey = await GetLatestServerEncryptionKeyAsync();
+
+ if (existingKey is null)
+ {
+ var keyBase64 = _cryptoService.GenerateKey();
+ var serverKeys = E2EeHelper.GenerateRsaKeyPair();
+
+ existingKey = await _db.Create("server_encryption_keys", new ServerEncryptionKeys
+ {
+ KeyBase64 = keyBase64,
+ PublicKey = serverKeys.publicKey,
+ PrivateKey = serverKeys.privateKey,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ });
+
+ Console.WriteLine("Server encryption key created.");
+ }
+ else
+ {
+ Console.WriteLine("Server encryption key already exists.");
+ }
+
+ ChatSocketBehavior.ServerPublicKey = existingKey.PublicKey;
+ ChatSocketBehavior.ServerPrivateKey = existingKey.PrivateKey;
+ ChatSocketBehavior.ChannelDbKey = existingKey.KeyBase64;
+ }
+
+ private static string ToJsonString(object? obj)
+ {
+ return JsonSerializer.Serialize(obj, new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ });
+ }
+
+ 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 async Task GetServerByNameAsync(string name)
+ {
+ var servers = await _db.Select("servers");
+ return servers.FirstOrDefault(x => x.Name == name);
+ }
+
+ private async Task GetServerMemberByUserIdAsync(string userId)
+ {
+ var members = await _db.Select("server_members");
+ return members.FirstOrDefault(x => x.UserId == userId);
+ }
+
+ private async Task GetChannelByNameAsync(string name)
+ {
+ var channels = await _db.Select("channels");
+ return channels.FirstOrDefault(x => x.Name == name);
+ }
+
+ private async Task GetLatestServerEncryptionKeyAsync()
+ {
+ var keys = await _db.Select("server_encryption_keys");
+ return keys
+ .OrderByDescending(x => x.CreatedAt)
+ .FirstOrDefault();
+ }
+
+ private async Task EnsureServerMemberAsync(string userId, bool isOwner)
+ {
+ var existing = await GetServerMemberByUserIdAsync(userId);
+ if (existing is not null)
+ {
+ Console.WriteLine($"Server member already exists for {userId}");
+ return;
+ }
+
+ await _db.Create("server_members", new ServerMembers
+ {
+ UserId = userId,
+ JoinedAt = DateTime.UtcNow,
+ IsOwner = isOwner
+ });
+
+ Console.WriteLine($"Server member created for {userId}");
+ }
+
+ private async Task EnsureChannelAsync(string name, DateTime createdAt)
+ {
+ var existing = await GetChannelByNameAsync(name);
+ if (existing is not null)
+ {
+ Console.WriteLine($"Channel already exists: {name}");
+ return existing;
+ }
+
+ var channel = await _db.Create("channels", new Channels
+ {
+ Name = name,
+ CreatedAt = createdAt
+ });
+
+ Console.WriteLine($"Channel created: {ToJsonString(channel)}");
+ return channel;
+ }
}
\ No newline at end of file
diff --git a/RelayServer/Services/Crypto/E2EeHelper.cs b/RelayServer/Services/Crypto/E2EeHelper.cs
index 35a0547..a26e64e 100644
--- a/RelayServer/Services/Crypto/E2EeHelper.cs
+++ b/RelayServer/Services/Crypto/E2EeHelper.cs
@@ -1,7 +1,7 @@
using System.Security.Cryptography;
using System.Text;
-namespace RelayServer.Services;
+namespace RelayServer.Services.Crypto;
public static class E2EeHelper
{
diff --git a/RelayServer/Services/Data/ClientKeyService.cs b/RelayServer/Services/Data/ClientKeyService.cs
index 8f2ab2f..eda55ca 100644
--- a/RelayServer/Services/Data/ClientKeyService.cs
+++ b/RelayServer/Services/Data/ClientKeyService.cs
@@ -1,7 +1,7 @@
using RelayServer.Models;
using SurrealDb.Net;
-namespace RelayServer.Services;
+namespace RelayServer.Services.Data;
public sealed class ClientKeyService
{
diff --git a/RelayServer/Services/Data/SurrealService.cs b/RelayServer/Services/Data/SurrealService.cs
index 9efb2fd..acd7337 100644
--- a/RelayServer/Services/Data/SurrealService.cs
+++ b/RelayServer/Services/Data/SurrealService.cs
@@ -1,7 +1,7 @@
using SurrealDb.Net;
using SurrealDb.Net.Models.Auth;
-namespace RelayServer.Services;
+namespace RelayServer.Services.Data;
public sealed class SurrealService
{
diff --git a/RelayServer/Services/Rtc/RtcCallService.cs b/RelayServer/Services/Rtc/RtcCallService.cs
index aa7aa28..56fa854 100644
--- a/RelayServer/Services/Rtc/RtcCallService.cs
+++ b/RelayServer/Services/Rtc/RtcCallService.cs
@@ -12,6 +12,29 @@ public sealed class RtcCallService
_db = db;
}
+ ///
+ /// Checks whether the specified channel currently has an active RTC call.
+ ///
+ /// The channel to inspect.
+ ///
+ /// True if the channel has an active call; otherwise, false.
+ ///
+ public async Task HasActiveCallAsync(string channelId)
+ {
+ var activeCalls = await _db.Select("rtc_active_calls");
+ return activeCalls.Any(x => x.ChannelId == channelId && x.IsActive);
+ }
+
+ ///
+ /// Joins a user to a channel call and determines whether they should become the offerer
+ /// or join an already active call.
+ ///
+ /// The channel being joined.
+ /// The user joining the call.
+ ///
+ /// A join response describing whether a call already exists, who the offer user is,
+ /// and whether the caller should act as the offerer.
+ ///
public async Task JoinCallAsync(string channelId, string username)
{
var activeCalls = await _db.Select("rtc_active_calls");
@@ -54,6 +77,13 @@ public sealed class RtcCallService
};
}
+ ///
+ /// Creates or updates the current SDP offer for a user in the specified channel.
+ /// Also refreshes the active call timestamp when a matching active call exists.
+ ///
+ /// The channel the offer belongs to.
+ /// The user creating the offer.
+ /// The SDP offer payload.
public async Task WriteOfferAsync(string channelId, string username, string sdp)
{
var offers = await _db.Select("rtc_offers");
@@ -69,14 +99,30 @@ public sealed class RtcCallService
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
- return;
+ }
+ else
+ {
+ existing.Sdp = sdp;
+ existing.UpdatedAt = DateTime.UtcNow;
+ await _db.Merge(existing);
}
- existing.Sdp = sdp;
- existing.UpdatedAt = DateTime.UtcNow;
- await _db.Merge(existing);
+ var activeCalls = await _db.Select("rtc_active_calls");
+ var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive);
+ if (activeCall is not null)
+ {
+ activeCall.UpdatedAt = DateTime.UtcNow;
+ await _db.Merge(activeCall);
+ }
}
+ ///
+ /// Gets the most recent SDP offer stored for the specified channel.
+ ///
+ /// The channel whose offer should be retrieved.
+ ///
+ /// The latest offer for the channel, or null if no offer exists.
+ ///
public async Task GetOfferAsync(string channelId)
{
var offers = await _db.Select("rtc_offers");
@@ -86,6 +132,14 @@ public sealed class RtcCallService
.FirstOrDefault();
}
+ ///
+ /// Writes a new SDP answer for the specified channel and refreshes the active call timestamp
+ /// when a matching active call exists.
+ ///
+ /// The channel the answer belongs to.
+ /// The original offer owner.
+ /// The user submitting the answer.
+ /// The SDP answer payload.
public async Task WriteAnswerAsync(string channelId, string offerUser, string answerUser, string sdp)
{
await _db.Create("rtc_answers", new RtcAnswer
@@ -96,8 +150,23 @@ public sealed class RtcCallService
Sdp = sdp,
CreatedAt = DateTime.UtcNow
});
+
+ var activeCalls = await _db.Select("rtc_active_calls");
+ var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive);
+ if (activeCall is not null)
+ {
+ activeCall.UpdatedAt = DateTime.UtcNow;
+ await _db.Merge(activeCall);
+ }
}
+ ///
+ /// Gets all answers stored for the specified channel in creation order.
+ ///
+ /// The channel whose answers should be retrieved.
+ ///
+ /// A list of answers for the channel ordered from oldest to newest.
+ ///
public async Task> GetAnswersAsync(string channelId)
{
var answers = await _db.Select("rtc_answers");
@@ -107,7 +176,40 @@ public sealed class RtcCallService
.ToList();
}
- public async Task WriteIceCandidateAsync(string channelId, string username, string candidate, string? sdpMid, int? sdpMLineIndex, string direction)
+ ///
+ /// Gets the most recent answer stored for the specified channel.
+ ///
+ /// The channel whose latest answer should be retrieved.
+ ///
+ /// The newest answer for the channel, or null if no answer exists.
+ ///
+ public async Task GetLatestAnswerAsync(string channelId)
+ {
+ var answers = await _db.Select("rtc_answers");
+ return answers
+ .Where(x => x.ChannelId == channelId)
+ .OrderByDescending(x => x.CreatedAt)
+ .FirstOrDefault();
+ }
+
+ ///
+ /// Writes a new ICE candidate entry for the specified channel and user.
+ ///
+ /// The channel the ICE candidate belongs to.
+ /// The user who produced the ICE candidate.
+ /// The ICE candidate string.
+ /// The SDP media identifier for the candidate, if any.
+ /// The SDP media line index for the candidate, if any.
+ ///
+ /// The signaling direction the candidate belongs to, such as offer or answer.
+ ///
+ public async Task WriteIceCandidateAsync(
+ string channelId,
+ string username,
+ string candidate,
+ string? sdpMid,
+ int? sdpMLineIndex,
+ string direction)
{
await _db.Create("rtc_ice_candidates", new RtcIceCandidate
{
@@ -121,6 +223,13 @@ public sealed class RtcCallService
});
}
+ ///
+ /// Gets all ICE candidates stored for the specified channel in creation order.
+ ///
+ /// The channel whose ICE candidates should be retrieved.
+ ///
+ /// A list of ICE candidates for the channel ordered from oldest to newest.
+ ///
public async Task> GetIceCandidatesAsync(string channelId)
{
var candidates = await _db.Select("rtc_ice_candidates");
@@ -130,6 +239,31 @@ public sealed class RtcCallService
.ToList();
}
+ ///
+ /// Gets ICE candidates for the specified channel that were created by other users
+ /// and match the requested signaling direction.
+ ///
+ /// The channel whose ICE candidates should be retrieved.
+ /// The user to exclude from the results.
+ /// The signaling direction to match.
+ ///
+ /// A list of matching ICE candidates ordered from oldest to newest.
+ ///
+ public async Task> GetIceCandidatesForOthersAsync(string channelId, string username, string direction)
+ {
+ var candidates = await _db.Select("rtc_ice_candidates");
+ return candidates
+ .Where(x => x.ChannelId == channelId && x.Username != username && x.Direction == direction)
+ .OrderBy(x => x.CreatedAt)
+ .ToList();
+ }
+
+ ///
+ /// Leaves the active call for the specified channel. In the current implementation,
+ /// the call is only marked inactive when the offer user leaves.
+ ///
+ /// The channel whose call should be left.
+ /// The user leaving the call.
public async Task LeaveCallAsync(string channelId, string username)
{
var activeCalls = await _db.Select("rtc_active_calls");
@@ -140,7 +274,6 @@ public sealed class RtcCallService
if (activeCall.OfferUser == username)
{
- //TODO: Fix to only make inactive if all users leave
activeCall.IsActive = false;
activeCall.UpdatedAt = DateTime.UtcNow;
await _db.Merge(activeCall);