Finished. Have at it.
This commit is contained in:
@@ -5,38 +5,60 @@ namespace RelayServer.Endpoints;
|
|||||||
|
|
||||||
public static class RtcEndpoints
|
public static class RtcEndpoints
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps all RTC-related HTTP endpoints used for joining calls, storing offers and answers,
|
||||||
|
/// writing ICE candidates, and leaving active calls.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="app">The web application to map endpoints onto.</param>
|
||||||
public static void MapRtcEndpoints(this WebApplication app)
|
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) =>
|
app.MapPost("/api/rtc/join", async (RtcJoinRequest request, RtcCallService rtcCallService) =>
|
||||||
{
|
{
|
||||||
return Results.Ok(await rtcCallService.JoinCallAsync(request.ChannelId, request.Username));
|
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) =>
|
app.MapPost("/api/rtc/offer", async (RtcOffer request, RtcCallService rtcCallService) =>
|
||||||
{
|
{
|
||||||
await rtcCallService.WriteOfferAsync(request.ChannelId, request.Username, request.Sdp);
|
await rtcCallService.WriteOfferAsync(request.ChannelId, request.Username, request.Sdp);
|
||||||
return Results.Ok();
|
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) =>
|
app.MapGet("/api/rtc/offer/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
|
||||||
{
|
{
|
||||||
var offer = await rtcCallService.GetOfferAsync(channelId);
|
var offer = await rtcCallService.GetOfferAsync(channelId);
|
||||||
return offer is null ? Results.NotFound() : Results.Ok(offer);
|
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) =>
|
app.MapPost("/api/rtc/answer", async (RtcAnswer request, RtcCallService rtcCallService) =>
|
||||||
{
|
{
|
||||||
await rtcCallService.WriteAnswerAsync(request.ChannelId, request.OfferUser, request.AnswerUser, request.Sdp);
|
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 Results.Ok();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Return all answers stored for the specified channel.
|
||||||
app.MapGet("/api/rtc/answers/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
|
app.MapGet("/api/rtc/answers/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
|
||||||
{
|
{
|
||||||
return Results.Ok(await rtcCallService.GetAnswersAsync(channelId));
|
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) =>
|
app.MapPost("/api/rtc/candidate", async (RtcIceCandidate request, RtcCallService rtcCallService) =>
|
||||||
{
|
{
|
||||||
await rtcCallService.WriteIceCandidateAsync(
|
await rtcCallService.WriteIceCandidateAsync(
|
||||||
@@ -47,16 +69,28 @@ public static class RtcEndpoints
|
|||||||
request.SdpMLineIndex,
|
request.SdpMLineIndex,
|
||||||
request.Direction
|
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 Results.Ok();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Return all ICE candidates stored for the specified channel.
|
||||||
app.MapGet("/api/rtc/candidates/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
|
app.MapGet("/api/rtc/candidates/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
|
||||||
{
|
{
|
||||||
return Results.Ok(await rtcCallService.GetIceCandidatesAsync(channelId));
|
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) =>
|
app.MapPost("/api/rtc/leave", async (RtcLeaveRequest request, RtcCallService rtcCallService) =>
|
||||||
{
|
{
|
||||||
await rtcCallService.LeaveCallAsync(request.ChannelId, request.Username);
|
await rtcCallService.LeaveCallAsync(request.ChannelId, request.Username);
|
||||||
|
|||||||
@@ -1,185 +1,38 @@
|
|||||||
using System.Text.Json;
|
using RelayServer.Endpoints;
|
||||||
using RelayServer.Services;
|
using RelayServer.Services.Chat;
|
||||||
|
using RelayServer.Services.Core;
|
||||||
|
using RelayServer.Services.Data;
|
||||||
using WebSocketSharp.Server;
|
using WebSocketSharp.Server;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
using RelayServer.Models;
|
|
||||||
|
|
||||||
var surrealService = new SurrealService();
|
var surrealService = new SurrealService();
|
||||||
var coreClient = new CoreClientService();
|
var coreClient = new CoreClientService();
|
||||||
var cryptoService = new ChannelCryptoService();
|
var cryptoService = new ChannelCryptoService();
|
||||||
//TODO: Move everything into a MAIN function
|
|
||||||
await using var db = await surrealService.ConnectAsync();
|
await using var db = await surrealService.ConnectAsync();
|
||||||
|
|
||||||
ChatTest.ClientKeyService = new ClientKeyService(db);
|
ChatSocketBehavior.ClientKeyService = new ClientKeyService(db);
|
||||||
ChatTest.Db = db;
|
ChatSocketBehavior.Db = db;
|
||||||
|
ChatSocketBehavior.ChannelCryptoService = cryptoService;
|
||||||
|
|
||||||
|
var bootstrapService = new ServerBootstrapService(db, coreClient, cryptoService);
|
||||||
|
await bootstrapService.InitializeAsync();
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddSignalR();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapGet("/", () => "Server Running!");
|
app.MapGet("/", () => "Server Running!");
|
||||||
app.MapHub<WebRtcHub>("/webrtc");
|
app.MapRtcEndpoints();
|
||||||
|
|
||||||
var wssv = new WebSocketServer("ws://localhost:1337");
|
var wssv = new WebSocketServer("ws://localhost:1337");
|
||||||
wssv.AddWebSocketService<ChatTest>("/");
|
wssv.AddWebSocketService<ChatSocketBehavior>("/");
|
||||||
wssv.Start();
|
wssv.Start();
|
||||||
Console.WriteLine("WebSocket server started");
|
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();
|
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();
|
wssv.Stop();
|
||||||
await app.StopAsync();
|
await app.StopAsync();
|
||||||
return;
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace RelayServer.Services;
|
namespace RelayServer.Services.Chat;
|
||||||
|
|
||||||
public sealed class ChannelCryptoService
|
public sealed class ChannelCryptoService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace RelayServer.Services;
|
|
||||||
|
|
||||||
public class ChannelMessageService
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,30 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using RelayServer.Models;
|
using RelayServer.Models;
|
||||||
|
using RelayServer.Services.Crypto;
|
||||||
|
using RelayServer.Services.Data;
|
||||||
using WebSocketSharp;
|
using WebSocketSharp;
|
||||||
using WebSocketSharp.Server;
|
using WebSocketSharp.Server;
|
||||||
|
|
||||||
namespace RelayServer.Services;
|
namespace RelayServer.Services.Chat;
|
||||||
|
|
||||||
public class ChatTest : WebSocketBehavior
|
/// <summary>
|
||||||
|
/// Handles websocket-based chat operations including client key registration,
|
||||||
|
/// server key retrieval, channel listing, channel history loading, and encrypted
|
||||||
|
/// channel message relay.
|
||||||
|
/// </summary>
|
||||||
|
public class ChatSocketBehavior : WebSocketBehavior
|
||||||
{
|
{
|
||||||
public static ClientKeyService? ClientKeyService { get; set; }
|
public static ClientKeyService? ClientKeyService { get; set; }
|
||||||
public static string? ServerPublicKey { get; set; }
|
public static string? ServerPublicKey { get; set; }
|
||||||
public static string? ServerPrivateKey { get; set; }
|
public static string? ServerPrivateKey { get; set; }
|
||||||
public static string? ChannelDbKey { get; set; }
|
public static string? ChannelDbKey { get; set; }
|
||||||
|
public static ChannelCryptoService? ChannelCryptoService { get; set; }
|
||||||
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
|
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
|
||||||
private static readonly Dictionary<string, string> ActiveRtcOffersByChannel = new();
|
|
||||||
private static readonly HashSet<string> ActiveRtcChannels = new();
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes incoming websocket messages to the appropriate chat handler.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="e">The websocket message event arguments.</param>
|
||||||
protected override void OnMessage(MessageEventArgs e)
|
protected override void OnMessage(MessageEventArgs e)
|
||||||
{
|
{
|
||||||
var msg = e.Data;
|
var msg = e.Data;
|
||||||
@@ -44,25 +54,16 @@ public class ChatTest : WebSocketBehavior
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SocketRtcSignalMessage? rtcProbe = null;
|
HandleEncryptedChatMessage(msg);
|
||||||
try
|
|
||||||
{
|
|
||||||
rtcProbe = JsonSerializer.Deserialize<SocketRtcSignalMessage>(msg);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rtcProbe?.Type == "encrypted_rtc_signal")
|
|
||||||
{
|
|
||||||
HandleEncryptedRtcSignal(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
HandleEncryptedClientMessage(msg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
private static string ExtractUsernameFromUserId(string senderUserId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(senderUserId))
|
if (string.IsNullOrWhiteSpace(senderUserId))
|
||||||
@@ -72,6 +73,10 @@ public class ChatTest : WebSocketBehavior
|
|||||||
return parts.Length == 2 ? parts[1] : senderUserId;
|
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)
|
private void HandleRegisterKey(string msg)
|
||||||
{
|
{
|
||||||
var parts = msg.Split('|', 3);
|
var parts = msg.Split('|', 3);
|
||||||
@@ -91,12 +96,14 @@ public class ChatTest : WebSocketBehavior
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Task.Run(async () => { await ClientKeyService.RegisterOrUpdateKeyAsync(username, publicKey); }).GetAwaiter()
|
RegisterOrUpdateClientKeySync(username, publicKey);
|
||||||
.GetResult();
|
|
||||||
|
|
||||||
Send($"SERVER:REGISTERED_KEY:{username}");
|
Send($"SERVER:REGISTERED_KEY:{username}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the current list of channels to the connected websocket client.
|
||||||
|
/// </summary>
|
||||||
private void HandleGetChannels()
|
private void HandleGetChannels()
|
||||||
{
|
{
|
||||||
if (Db is null)
|
if (Db is null)
|
||||||
@@ -105,9 +112,7 @@ public class ChatTest : WebSocketBehavior
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var channels = Task.Run(async () => await Db.Select<Channels>("channels"))
|
var channels = GetChannelsSync()
|
||||||
.GetAwaiter()
|
|
||||||
.GetResult()
|
|
||||||
.OrderBy(c => c.CreatedAt)
|
.OrderBy(c => c.CreatedAt)
|
||||||
.Select(c => new SocketChannelInfo
|
.Select(c => new SocketChannelInfo
|
||||||
{
|
{
|
||||||
@@ -126,6 +131,9 @@ public class ChatTest : WebSocketBehavior
|
|||||||
Send(JsonSerializer.Serialize(payload));
|
Send(JsonSerializer.Serialize(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the server's public key to the connected websocket client.
|
||||||
|
/// </summary>
|
||||||
private void HandleGetServerKey()
|
private void HandleGetServerKey()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(ServerPublicKey))
|
if (string.IsNullOrWhiteSpace(ServerPublicKey))
|
||||||
@@ -143,7 +151,12 @@ public class ChatTest : WebSocketBehavior
|
|||||||
Send(JsonSerializer.Serialize(payload));
|
Send(JsonSerializer.Serialize(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleEncryptedClientMessage(string msg)
|
/// <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;
|
SocketEncryptedMessage? clientPayload;
|
||||||
|
|
||||||
@@ -160,14 +173,8 @@ public class ChatTest : WebSocketBehavior
|
|||||||
if (clientPayload is null || clientPayload.Type != "client_encrypted_chat")
|
if (clientPayload is null || clientPayload.Type != "client_encrypted_chat")
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (ClientKeyService is null ||
|
if (!EnsureCoreReady() || !EnsureCryptoReady())
|
||||||
Db is null ||
|
|
||||||
string.IsNullOrWhiteSpace(ServerPrivateKey) ||
|
|
||||||
string.IsNullOrWhiteSpace(ChannelDbKey))
|
|
||||||
{
|
|
||||||
Console.WriteLine("Server crypto/database dependencies are not initialized.");
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
string plainText;
|
string plainText;
|
||||||
|
|
||||||
@@ -193,20 +200,17 @@ public class ChatTest : WebSocketBehavior
|
|||||||
Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}");
|
Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var channelCrypto = new ChannelCryptoService();
|
var dbEncrypted = ChannelCryptoService.Encrypt(plainText, ChannelDbKey);
|
||||||
var dbEncrypted = channelCrypto.Encrypt(plainText, ChannelDbKey);
|
|
||||||
|
|
||||||
var savedMessage = Task.Run(async () =>
|
var savedMessage = CreateChannelMessageSync(new ChannelMessages
|
||||||
await Db.Create("channel_messages", new ChannelMessages
|
{
|
||||||
{
|
ChannelId = clientPayload.ChannelId,
|
||||||
ChannelId = clientPayload.ChannelId,
|
SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}",
|
||||||
SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}",
|
CipherText = dbEncrypted.cipherText,
|
||||||
CipherText = dbEncrypted.cipherText,
|
Nonce = dbEncrypted.nonce,
|
||||||
Nonce = dbEncrypted.nonce,
|
Tag = dbEncrypted.tag,
|
||||||
Tag = dbEncrypted.tag,
|
CreatedAt = DateTime.UtcNow
|
||||||
CreatedAt = DateTime.UtcNow
|
});
|
||||||
})
|
|
||||||
).GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
Console.WriteLine($"Live message saved to DB: {JsonSerializer.Serialize(savedMessage)}");
|
Console.WriteLine($"Live message saved to DB: {JsonSerializer.Serialize(savedMessage)}");
|
||||||
}
|
}
|
||||||
@@ -216,9 +220,7 @@ public class ChatTest : WebSocketBehavior
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var allKeys = Task.Run(async () => await ClientKeyService.GetAllAsync())
|
var allKeys = GetAllClientPublicKeysSync();
|
||||||
.GetAwaiter()
|
|
||||||
.GetResult();
|
|
||||||
|
|
||||||
foreach (var client in allKeys)
|
foreach (var client in allKeys)
|
||||||
{
|
{
|
||||||
@@ -242,6 +244,11 @@ public class ChatTest : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
private void HandleGetHistory(string msg)
|
||||||
{
|
{
|
||||||
var parts = msg.Split('|', 3);
|
var parts = msg.Split('|', 3);
|
||||||
@@ -255,17 +262,13 @@ public class ChatTest : WebSocketBehavior
|
|||||||
var username = parts[1];
|
var username = parts[1];
|
||||||
var channelId = parts[2];
|
var channelId = parts[2];
|
||||||
|
|
||||||
if (ClientKeyService is null ||
|
if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey))
|
||||||
Db is null ||
|
|
||||||
string.IsNullOrWhiteSpace(ChannelDbKey))
|
|
||||||
{
|
{
|
||||||
Console.WriteLine("History dependencies are not initialized.");
|
Console.WriteLine("History dependencies are not initialized.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetClient = Task.Run(async () => await ClientKeyService.GetByUsernameAsync(username))
|
var targetClient = GetClientPublicKeyByUsernameSync(username);
|
||||||
.GetAwaiter()
|
|
||||||
.GetResult();
|
|
||||||
|
|
||||||
if (targetClient is null)
|
if (targetClient is null)
|
||||||
{
|
{
|
||||||
@@ -273,9 +276,7 @@ public class ChatTest : WebSocketBehavior
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var allMessages = Task.Run(async () => await Db.Select<ChannelMessages>("channel_messages"))
|
var allMessages = GetChannelMessagesSync();
|
||||||
.GetAwaiter()
|
|
||||||
.GetResult();
|
|
||||||
|
|
||||||
var channelMessages = allMessages
|
var channelMessages = allMessages
|
||||||
.Where(m => m.ChannelId == channelId)
|
.Where(m => m.ChannelId == channelId)
|
||||||
@@ -284,15 +285,13 @@ public class ChatTest : WebSocketBehavior
|
|||||||
|
|
||||||
Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}");
|
Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}");
|
||||||
|
|
||||||
var channelCrypto = new ChannelCryptoService();
|
|
||||||
|
|
||||||
foreach (var dbMessage in channelMessages)
|
foreach (var dbMessage in channelMessages)
|
||||||
{
|
{
|
||||||
string plainText;
|
string plainText;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
plainText = channelCrypto.Decrypt(
|
plainText = ChannelCryptoService.Decrypt(
|
||||||
dbMessage.CipherText,
|
dbMessage.CipherText,
|
||||||
dbMessage.Nonce,
|
dbMessage.Nonce,
|
||||||
dbMessage.Tag,
|
dbMessage.Tag,
|
||||||
@@ -323,6 +322,13 @@ public class ChatTest : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
private static string GetRecordId(object? id)
|
||||||
{
|
{
|
||||||
if (id is null)
|
if (id is null)
|
||||||
@@ -340,154 +346,104 @@ public class ChatTest : WebSocketBehavior
|
|||||||
return $"{table}:{recordId}";
|
return $"{table}:{recordId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleEncryptedRtcSignal(string msg)
|
/// <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)
|
||||||
{
|
{
|
||||||
SocketRtcSignalMessage? clientPayload;
|
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey))
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
clientPayload = JsonSerializer.Deserialize<SocketRtcSignalMessage>(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<RtcSignalMessage>(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())
|
|
||||||
.GetAwaiter()
|
.GetAwaiter()
|
||||||
.GetResult();
|
.GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
if (rtcSignal.Type == "rtc_join")
|
/// <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"))
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult()
|
||||||
|
.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))
|
||||||
|
.GetAwaiter()
|
||||||
|
.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"))
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult()
|
||||||
|
.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))
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureCoreReady()
|
||||||
|
{
|
||||||
|
if (ClientKeyService is null || Db is null)
|
||||||
{
|
{
|
||||||
var joinState = new
|
Console.WriteLine("Core services not initialized.");
|
||||||
{
|
return false;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rtcSignal.Type == "rtc_offer")
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureCryptoReady()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey))
|
||||||
{
|
{
|
||||||
ActiveRtcOffersByChannel[rtcSignal.ChannelId] = plainJson;
|
Console.WriteLine("Crypto keys not initialized.");
|
||||||
ActiveRtcChannels.Add(rtcSignal.ChannelId);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rtcSignal.Type == "rtc_leave")
|
if (ChannelCryptoService is null)
|
||||||
{
|
{
|
||||||
ActiveRtcOffersByChannel.Remove(rtcSignal.ChannelId);
|
Console.WriteLine("ChannelCryptoService is not initialized.");
|
||||||
ActiveRtcChannels.Remove(rtcSignal.ChannelId);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var client in allKeys)
|
return true;
|
||||||
{
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace RelayServer.Services;
|
namespace RelayServer.Services.Core;
|
||||||
|
|
||||||
public sealed class CoreClientService
|
public sealed class CoreClientService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<Servers?> GetServerByNameAsync(string name)
|
||||||
|
{
|
||||||
|
var servers = await _db.Select<Servers>("servers");
|
||||||
|
return servers.FirstOrDefault(x => x.Name == name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ServerMembers?> GetServerMemberByUserIdAsync(string userId)
|
||||||
|
{
|
||||||
|
var members = await _db.Select<ServerMembers>("server_members");
|
||||||
|
return members.FirstOrDefault(x => x.UserId == userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Channels?> GetChannelByNameAsync(string name)
|
||||||
|
{
|
||||||
|
var channels = await _db.Select<Channels>("channels");
|
||||||
|
return channels.FirstOrDefault(x => x.Name == name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ServerEncryptionKeys?> GetLatestServerEncryptionKeyAsync()
|
||||||
|
{
|
||||||
|
var keys = await _db.Select<ServerEncryptionKeys>("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<Channels> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace RelayServer.Services;
|
namespace RelayServer.Services.Crypto;
|
||||||
|
|
||||||
public static class E2EeHelper
|
public static class E2EeHelper
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using RelayServer.Models;
|
using RelayServer.Models;
|
||||||
using SurrealDb.Net;
|
using SurrealDb.Net;
|
||||||
|
|
||||||
namespace RelayServer.Services;
|
namespace RelayServer.Services.Data;
|
||||||
|
|
||||||
public sealed class ClientKeyService
|
public sealed class ClientKeyService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using SurrealDb.Net;
|
using SurrealDb.Net;
|
||||||
using SurrealDb.Net.Models.Auth;
|
using SurrealDb.Net.Models.Auth;
|
||||||
|
|
||||||
namespace RelayServer.Services;
|
namespace RelayServer.Services.Data;
|
||||||
|
|
||||||
public sealed class SurrealService
|
public sealed class SurrealService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,29 @@ public sealed class RtcCallService
|
|||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the specified channel currently has an active RTC call.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel to inspect.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// True if the channel has an active call; otherwise, false.
|
||||||
|
/// </returns>
|
||||||
|
public async Task<bool> HasActiveCallAsync(string channelId)
|
||||||
|
{
|
||||||
|
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls");
|
||||||
|
return activeCalls.Any(x => x.ChannelId == channelId && x.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Joins a user to a channel call and determines whether they should become the offerer
|
||||||
|
/// or join an already active call.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel being joined.</param>
|
||||||
|
/// <param name="username">The user joining the call.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A join response describing whether a call already exists, who the offer user is,
|
||||||
|
/// and whether the caller should act as the offerer.
|
||||||
|
/// </returns>
|
||||||
public async Task<RtcJoinResponse> JoinCallAsync(string channelId, string username)
|
public async Task<RtcJoinResponse> JoinCallAsync(string channelId, string username)
|
||||||
{
|
{
|
||||||
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls");
|
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls");
|
||||||
@@ -54,6 +77,13 @@ public sealed class RtcCallService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel the offer belongs to.</param>
|
||||||
|
/// <param name="username">The user creating the offer.</param>
|
||||||
|
/// <param name="sdp">The SDP offer payload.</param>
|
||||||
public async Task WriteOfferAsync(string channelId, string username, string sdp)
|
public async Task WriteOfferAsync(string channelId, string username, string sdp)
|
||||||
{
|
{
|
||||||
var offers = await _db.Select<RtcOffer>("rtc_offers");
|
var offers = await _db.Select<RtcOffer>("rtc_offers");
|
||||||
@@ -69,14 +99,30 @@ public sealed class RtcCallService
|
|||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow
|
UpdatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
return;
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Sdp = sdp;
|
||||||
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.Merge<RtcOffer, RtcOffer>(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.Sdp = sdp;
|
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls");
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive);
|
||||||
await _db.Merge<RtcOffer, RtcOffer>(existing);
|
if (activeCall is not null)
|
||||||
|
{
|
||||||
|
activeCall.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.Merge<RtcActiveCall, RtcActiveCall>(activeCall);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the most recent SDP offer stored for the specified channel.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel whose offer should be retrieved.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The latest offer for the channel, or null if no offer exists.
|
||||||
|
/// </returns>
|
||||||
public async Task<RtcOffer?> GetOfferAsync(string channelId)
|
public async Task<RtcOffer?> GetOfferAsync(string channelId)
|
||||||
{
|
{
|
||||||
var offers = await _db.Select<RtcOffer>("rtc_offers");
|
var offers = await _db.Select<RtcOffer>("rtc_offers");
|
||||||
@@ -86,6 +132,14 @@ public sealed class RtcCallService
|
|||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a new SDP answer for the specified channel and refreshes the active call timestamp
|
||||||
|
/// when a matching active call exists.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel the answer belongs to.</param>
|
||||||
|
/// <param name="offerUser">The original offer owner.</param>
|
||||||
|
/// <param name="answerUser">The user submitting the answer.</param>
|
||||||
|
/// <param name="sdp">The SDP answer payload.</param>
|
||||||
public async Task WriteAnswerAsync(string channelId, string offerUser, string answerUser, string sdp)
|
public async Task WriteAnswerAsync(string channelId, string offerUser, string answerUser, string sdp)
|
||||||
{
|
{
|
||||||
await _db.Create("rtc_answers", new RtcAnswer
|
await _db.Create("rtc_answers", new RtcAnswer
|
||||||
@@ -96,8 +150,23 @@ public sealed class RtcCallService
|
|||||||
Sdp = sdp,
|
Sdp = sdp,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var activeCalls = await _db.Select<RtcActiveCall>("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<RtcActiveCall, RtcActiveCall>(activeCall);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all answers stored for the specified channel in creation order.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel whose answers should be retrieved.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A list of answers for the channel ordered from oldest to newest.
|
||||||
|
/// </returns>
|
||||||
public async Task<List<RtcAnswer>> GetAnswersAsync(string channelId)
|
public async Task<List<RtcAnswer>> GetAnswersAsync(string channelId)
|
||||||
{
|
{
|
||||||
var answers = await _db.Select<RtcAnswer>("rtc_answers");
|
var answers = await _db.Select<RtcAnswer>("rtc_answers");
|
||||||
@@ -107,7 +176,40 @@ public sealed class RtcCallService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task WriteIceCandidateAsync(string channelId, string username, string candidate, string? sdpMid, int? sdpMLineIndex, string direction)
|
/// <summary>
|
||||||
|
/// Gets the most recent answer stored for the specified channel.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel whose latest answer should be retrieved.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The newest answer for the channel, or null if no answer exists.
|
||||||
|
/// </returns>
|
||||||
|
public async Task<RtcAnswer?> GetLatestAnswerAsync(string channelId)
|
||||||
|
{
|
||||||
|
var answers = await _db.Select<RtcAnswer>("rtc_answers");
|
||||||
|
return answers
|
||||||
|
.Where(x => x.ChannelId == channelId)
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a new ICE candidate entry for the specified channel and user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel the ICE candidate belongs to.</param>
|
||||||
|
/// <param name="username">The user who produced the ICE candidate.</param>
|
||||||
|
/// <param name="candidate">The ICE candidate string.</param>
|
||||||
|
/// <param name="sdpMid">The SDP media identifier for the candidate, if any.</param>
|
||||||
|
/// <param name="sdpMLineIndex">The SDP media line index for the candidate, if any.</param>
|
||||||
|
/// <param name="direction">
|
||||||
|
/// The signaling direction the candidate belongs to, such as offer or answer.
|
||||||
|
/// </param>
|
||||||
|
public async Task WriteIceCandidateAsync(
|
||||||
|
string channelId,
|
||||||
|
string username,
|
||||||
|
string candidate,
|
||||||
|
string? sdpMid,
|
||||||
|
int? sdpMLineIndex,
|
||||||
|
string direction)
|
||||||
{
|
{
|
||||||
await _db.Create("rtc_ice_candidates", new RtcIceCandidate
|
await _db.Create("rtc_ice_candidates", new RtcIceCandidate
|
||||||
{
|
{
|
||||||
@@ -121,6 +223,13 @@ public sealed class RtcCallService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all ICE candidates stored for the specified channel in creation order.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel whose ICE candidates should be retrieved.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A list of ICE candidates for the channel ordered from oldest to newest.
|
||||||
|
/// </returns>
|
||||||
public async Task<List<RtcIceCandidate>> GetIceCandidatesAsync(string channelId)
|
public async Task<List<RtcIceCandidate>> GetIceCandidatesAsync(string channelId)
|
||||||
{
|
{
|
||||||
var candidates = await _db.Select<RtcIceCandidate>("rtc_ice_candidates");
|
var candidates = await _db.Select<RtcIceCandidate>("rtc_ice_candidates");
|
||||||
@@ -130,6 +239,31 @@ public sealed class RtcCallService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets ICE candidates for the specified channel that were created by other users
|
||||||
|
/// and match the requested signaling direction.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel whose ICE candidates should be retrieved.</param>
|
||||||
|
/// <param name="username">The user to exclude from the results.</param>
|
||||||
|
/// <param name="direction">The signaling direction to match.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A list of matching ICE candidates ordered from oldest to newest.
|
||||||
|
/// </returns>
|
||||||
|
public async Task<List<RtcIceCandidate>> GetIceCandidatesForOthersAsync(string channelId, string username, string direction)
|
||||||
|
{
|
||||||
|
var candidates = await _db.Select<RtcIceCandidate>("rtc_ice_candidates");
|
||||||
|
return candidates
|
||||||
|
.Where(x => x.ChannelId == channelId && x.Username != username && x.Direction == direction)
|
||||||
|
.OrderBy(x => x.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Leaves the active call for the specified channel. In the current implementation,
|
||||||
|
/// the call is only marked inactive when the offer user leaves.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelId">The channel whose call should be left.</param>
|
||||||
|
/// <param name="username">The user leaving the call.</param>
|
||||||
public async Task LeaveCallAsync(string channelId, string username)
|
public async Task LeaveCallAsync(string channelId, string username)
|
||||||
{
|
{
|
||||||
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls");
|
var activeCalls = await _db.Select<RtcActiveCall>("rtc_active_calls");
|
||||||
@@ -140,7 +274,6 @@ public sealed class RtcCallService
|
|||||||
|
|
||||||
if (activeCall.OfferUser == username)
|
if (activeCall.OfferUser == username)
|
||||||
{
|
{
|
||||||
//TODO: Fix to only make inactive if all users leave
|
|
||||||
activeCall.IsActive = false;
|
activeCall.IsActive = false;
|
||||||
activeCall.UpdatedAt = DateTime.UtcNow;
|
activeCall.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.Merge<RtcActiveCall, RtcActiveCall>(activeCall);
|
await _db.Merge<RtcActiveCall, RtcActiveCall>(activeCall);
|
||||||
|
|||||||
Reference in New Issue
Block a user