Summary Update.

This commit is contained in:
2026-06-06 23:38:50 -04:00
parent dd75ca4b06
commit 2916d17868
30 changed files with 1231 additions and 21 deletions

View File

@@ -3,6 +3,25 @@ using System.Text;
namespace RelayServer.Services.Chat;
/// <summary>
/// AES-GCM-256 only (no RSA). Used exclusively for "at-rest" encryption of channel messages
/// in the SurrealDB channel_messages table.
///
/// Why a separate service from E2EeHelper:
/// - E2EeHelper is for *transit* between a specific sender and a specific recipient — it
/// wraps an ephemeral AES key with the recipient's RSA public key.
/// - ChannelCryptoService is for *storage* — the server is both the encryptor and the
/// decryptor, and it stores the symmetric channel key in server_encryption_keys.KeyBase64.
/// There's no recipient to wrap for.
///
/// Server flow for a chat message:
/// incoming SocketEncryptedMessage (encrypted with server's RSA public key, by client)
/// → E2EeHelper.DecryptForRecipient(serverPrivateKey) → plaintext
/// → ChannelCryptoService.Encrypt(channelDbKey) → stored ciphertext
/// → … later, on history fetch …
/// → ChannelCryptoService.Decrypt(channelDbKey) → plaintext
/// → E2EeHelper.EncryptForRecipient(clientPublicKey) → delivered ciphertext
/// </summary>
public sealed class ChannelCryptoService
{
public string GenerateKey()

View File

@@ -12,19 +12,68 @@ using RelayShared.Services;
namespace RelayServer.Services.Chat;
/// <summary>
/// Handles all WebSocket traffic: authentication, key registration, channel management,
/// encrypted chat relay, message editing/deletion, typing indicators, and edit history.
/// The server-side WebSocket endpoint. Every client connection creates one instance of this
/// class. WebSocketSharp owns the lifecycle: it constructs the behavior, calls OnMessage for
/// each incoming frame, and calls OnClose when the connection drops.
///
/// MESSAGE FLOW (data plane — chat message):
/// 1. Client sends a SocketEncryptedMessage with SignalType.ClientEncryptedChat.
/// Payload is JSON-serialised ChatMessageContent, encrypted with the server's public key.
/// 2. OnMessage parses the JSON, identifies Type, routes to HandleEncryptedChatMessage.
/// 3. Permission check via PermissionService.CanSendMessagesAsync.
/// 4. Decrypt with ServerPrivateKey → get plaintext JSON.
/// 5. Re-encrypt with ChannelDbKey (AES-GCM only, no RSA) → store in channel_messages table.
/// 6. For each connected server member: re-encrypt with their client public key, deliver
/// via Sessions.SendTo to every one of their active sessions (multi-device).
/// 7. If the origin channel has LinkedFileChannelId set, MirrorAttachmentIfNeeded also
/// stores+delivers a trimmed copy into the linked File channel.
///
/// MESSAGE FLOW (control plane — e.g. CreateChannel):
/// 1. Client sends a WsControlMessage with Action=CreateChannel.
/// 2. OnMessage sees the "Action" JSON property, routes via DispatchControl.
/// 3. Permission check, DB write, then BroadcastChannelList rebuilds the channel list per
/// user (because CanPost/CanManage are computed per-user) and pushes it to everyone.
///
/// STATE STORES used here:
/// - ConnectedClientService: session ↔ username mapping (in-memory, multi-device aware).
/// Populated by HandleRegisterKey, cleared by OnClose.
/// - RtcChannelPresenceService: session ↔ voice channel mapping. Populated by RtcJoin,
/// cleared by RtcLeave / OnClose.
/// - SurrealDB tables: channel_messages, channels, server_members, roles, user_roles,
/// channel_permissions, client_public_keys, server_encryption_keys, channel_message_edits.
///
/// CRITICAL invariant: this class is constructed by WebSocketSharp and has no constructor
/// hook for DI, so ALL services are static (set once by Program.cs at boot).
/// </summary>
public class ChatSocketBehavior : WebSocketBehavior
{
/// <summary>Reads/writes the client_public_keys table. Wired by Program.cs at boot.</summary>
public static ClientKeyService? ClientKeyService { get; set; }
/// <summary>The permission ladder evaluator. Wired by Program.cs at boot.</summary>
public static PermissionService? PermissionService { get; set; }
/// <summary>Base64 RSA public key — clients use this to encrypt outbound payloads to the server.</summary>
public static string? ServerPublicKey { get; set; }
/// <summary>Base64 RSA private key — used to decrypt inbound payloads. Never leaves the server.</summary>
public static string? ServerPrivateKey { get; set; }
/// <summary>Base64 AES-256 key for at-rest encryption of channel_messages.CipherText rows.</summary>
public static string? ChannelDbKey { get; set; }
/// <summary>AES-GCM-only encryption for stored messages. Wired by Program.cs at boot.</summary>
public static ChannelCryptoService? ChannelCryptoService { get; set; }
/// <summary>The SurrealDB connection. Wired by Program.cs at boot.</summary>
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
/// <summary>
/// WebSocketSharp callback fired for every incoming text frame. Peeks the JSON to identify
/// "Action" (control-plane) vs "Type" (data-plane), then routes to the right handler.
/// All exceptions are caught and logged — they MUST NOT propagate or WebSocketSharp will
/// drop the connection.
/// </summary>
protected override void OnMessage(MessageEventArgs e)
{
var msg = e.Data;
@@ -62,6 +111,7 @@ public class ChatSocketBehavior : WebSocketBehavior
}
}
/// <summary>Switches on WsAction to the matching Handle* method. Pure routing — no I/O.</summary>
private void DispatchControl(WsAction action, WsControlMessage c)
{
switch (action)
@@ -81,6 +131,14 @@ public class ChatSocketBehavior : WebSocketBehavior
}
}
/// <summary>
/// Verifies a Core-issued user token against the Core service. The HTTP call is wrapped in
/// try/catch so that a Core outage doesn't drop the chat session — we still ack with
/// WsEvent.Authenticated so the rest of the boot handshake can proceed.
///
/// NOTE async void here is unavoidable (it's an event handler) but every exception path
/// must be caught locally or WebSocketSharp will tear down the session.
/// </summary>
private async void HandleAuthenticate(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.Token))
@@ -108,6 +166,11 @@ public class ChatSocketBehavior : WebSocketBehavior
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Authenticated, Detail = c.Username }));
}
/// <summary>
/// Stores or updates the client's RSA public key in client_public_keys, then registers the
/// (sessionId, username) mapping in ConnectedClientService. After this fires the server can
/// route encrypted chat messages to this user's connected devices.
/// </summary>
private void HandleRegisterKey(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.PublicKey))
@@ -125,12 +188,17 @@ public class ChatSocketBehavior : WebSocketBehavior
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = c.Username }));
}
/// <summary>Sends the server's public RSA key. Called once per session right after RegisterKey.</summary>
private void HandleGetServerKey()
{
if (string.IsNullOrWhiteSpace(ServerPublicKey)) { Console.WriteLine("Server public key not initialised."); return; }
Send(JsonSerializer.Serialize(new ServerPublicKeyMessage { Type = SignalType.ServerPublicKey, PublicKey = ServerPublicKey }));
}
/// <summary>
/// Sends a channel list with CanPost/CanManage/visibility resolved for this specific user.
/// The username is looked up by session ID so the client never has to spoof it.
/// </summary>
private void HandleGetChannels()
{
if (Db is null) { Console.WriteLine("Db null."); return; }
@@ -142,6 +210,14 @@ public class ChatSocketBehavior : WebSocketBehavior
Send(JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels }));
}
/// <summary>
/// Streams the channel's full message history back to the requester. Each message is:
/// 1. Decrypted from the channel DB key (ChannelCryptoService.Decrypt).
/// 2. Re-encrypted with the requester's public key (E2EeHelper.EncryptForRecipient).
/// 3. Sent as an individual SocketEncryptedMessage frame.
/// Deleted messages are sent as tombstones (IsDeleted=true, no ciphertext) so the client
/// can render a placeholder without trying to decrypt.
/// </summary>
private void HandleGetHistory(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
@@ -194,6 +270,10 @@ public class ChatSocketBehavior : WebSocketBehavior
}
}
/// <summary>
/// Marks the session as present in a voice channel. Gated by CanSpeakAsync — if the user's
/// role is denied Speak here we reject with WsEvent.Error and refuse to register presence.
/// </summary>
private void HandleRtcJoinChannel(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
@@ -215,6 +295,7 @@ public class ChatSocketBehavior : WebSocketBehavior
Console.WriteLine($"RTC join: session={ID}, user={c.Username}, channel={c.ChannelId}");
}
/// <summary>Clears the session's voice-channel presence. Idempotent — safe to call when not in a channel.</summary>
private void HandleRtcLeaveChannel(WsControlMessage c)
{
if (!string.IsNullOrWhiteSpace(c.ChannelId) && RtcChannelPresenceService.IsInChannel(ID, c.ChannelId))
@@ -222,6 +303,11 @@ public class ChatSocketBehavior : WebSocketBehavior
Console.WriteLine($"RTC leave: session={ID}, user={c.Username}");
}
/// <summary>
/// Broadcasts "{Username} is typing…" to every connected server member EXCEPT the sender.
/// Sender's username comes from ConnectedClientService (not the message payload) so a
/// malicious client can't impersonate someone else's typing.
/// </summary>
private void HandleTyping(WsControlMessage c)
{
var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
@@ -244,6 +330,11 @@ public class ChatSocketBehavior : WebSocketBehavior
}
}
/// <summary>
/// Streams every prior version of a message back to the requester. Each entry is decrypted
/// from the channel key then re-encrypted for the requester's public key. Drives the
/// "(edited)" tap-popup on the client.
/// </summary>
private void HandleGetEditHistory(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.MessageId) || string.IsNullOrWhiteSpace(c.Username)) return;
@@ -283,6 +374,10 @@ public class ChatSocketBehavior : WebSocketBehavior
}));
}
/// <summary>
/// Permission-gated channel creation. On success, broadcasts the new channel list to every
/// connected member (computed per-user since CanPost/CanManage depend on the recipient).
/// </summary>
private void HandleCreateChannel(WsControlMessage c)
{
var username = ConnectedClientService.GetUsernameForSession(ID);
@@ -315,6 +410,10 @@ public class ChatSocketBehavior : WebSocketBehavior
BroadcastChannelList();
}
/// <summary>
/// Permission-gated soft-delete (sets IsDeleted on the row, doesn't actually remove it).
/// Broadcasts a fresh channel list after — clients drop the channel from their sidebar.
/// </summary>
private void HandleDeleteChannel(WsControlMessage c)
{
var username = ConnectedClientService.GetUsernameForSession(ID);
@@ -340,6 +439,11 @@ public class ChatSocketBehavior : WebSocketBehavior
BroadcastChannelList();
}
/// <summary>
/// Relays an encrypted WebRTC SDP/ICE signal to every other session in the same voice
/// channel. Decrypts with the server's private key, re-encrypts per-recipient. The server
/// never stores RTC signals — pure forwarding.
/// </summary>
private void HandleEncryptedRtcSignal(string msg)
{
SocketRtcSignalMessage? payload;
@@ -374,6 +478,10 @@ public class ChatSocketBehavior : WebSocketBehavior
}
}
/// <summary>
/// The main chat-message path. Permission gate → server-side decrypt → store with channel
/// key → DeliverToServerMembers (per-user re-encrypt + send) → MirrorAttachmentIfNeeded.
/// </summary>
private void HandleEncryptedChatMessage(string msg)
{
SocketEncryptedMessage? payload;
@@ -429,6 +537,11 @@ public class ChatSocketBehavior : WebSocketBehavior
MirrorAttachmentIfNeeded(plainText, payload.SenderUsername, payload.ChannelId);
}
/// <summary>
/// If the origin channel has LinkedFileChannelId set and the message has a non-gif
/// attachment, stores+delivers a trimmed copy ("📎 Shared from #X by Y" + attachment)
/// into the linked File channel. No-op for plain text messages.
/// </summary>
private void MirrorAttachmentIfNeeded(string plainText, string senderUsername, string originChannelId)
{
ChatMessageContent? content;
@@ -483,6 +596,11 @@ public class ChatSocketBehavior : WebSocketBehavior
Console.WriteLine($"Mirrored attachment from {originChannelId} to file channel {fileChannelId}");
}
/// <summary>
/// Ownership-gated edit. Saves the OLD ciphertext as a ChannelMessageEdits row before
/// overwriting the current row, so the edit chain is preserved. Broadcasts MessageEdited
/// with the new ciphertext so every recipient updates their bubble in place.
/// </summary>
private void HandleEditMessage(string msg)
{
SocketEncryptedMessage? request;
@@ -542,6 +660,11 @@ public class ChatSocketBehavior : WebSocketBehavior
request.MessageId, SignalType.MessageEdited, isEdited: true);
}
/// <summary>
/// Soft-delete (sets IsDeleted on the row). Allowed for the message author OR anyone with
/// ManageMessages permission in the channel. Broadcasts a tombstone event to every
/// connected member; their client swaps the bubble to a "deleted" placeholder.
/// </summary>
private void HandleDeleteMessage(string msg)
{
SocketEncryptedMessage? request;
@@ -587,6 +710,14 @@ public class ChatSocketBehavior : WebSocketBehavior
}
}
/// <summary>
/// The fan-out for any chat-message delivery (new send, edit broadcast). For each
/// server_members row, looks up active sessions, fetches that user's public key, encrypts
/// the plaintext for them, and sends to every one of their sessions (multi-device).
///
/// "ProperUsername" is the mixed-case version captured at RegisterKey time, used so the
/// client's case-insensitive compare picks up the message instead of dropping it silently.
/// </summary>
private void DeliverToServerMembers(
string plainText, string senderUsername, string channelId,
string messageId, SignalType signalType, bool isEdited)
@@ -619,6 +750,10 @@ public class ChatSocketBehavior : WebSocketBehavior
}
}
/// <summary>
/// Pushes a freshly-built channel list to every connected member. Has to compute the list
/// PER user because CanPost/CanManage/visibility are user-specific. Called after Create/Delete.
/// </summary>
private void BroadcastChannelList()
{
foreach (var member in GetServerMembersSync())
@@ -639,6 +774,10 @@ public class ChatSocketBehavior : WebSocketBehavior
}
}
/// <summary>
/// Resolves the channel list a specific user can see, with CanPost/CanManage flags filled
/// in. Visibility (ViewChannel) determines inclusion — denied channels are filtered out.
/// </summary>
private List<ChannelItem> BuildChannelListForUser(string username)
{
var rawChannels = GetChannelsSync()
@@ -679,6 +818,10 @@ public class ChatSocketBehavior : WebSocketBehavior
return items;
}
/// <summary>
/// WebSocketSharp callback when the connection drops (clean close OR network drop). Clears
/// both presence registries so other clients aren't trying to send to a dead session.
/// </summary>
protected override void OnClose(CloseEventArgs e)
{
ConnectedClientService.Unregister(ID);
@@ -687,12 +830,19 @@ public class ChatSocketBehavior : WebSocketBehavior
base.OnClose(e);
}
/// <summary>WebSocketSharp callback for socket-level errors. Logged but non-fatal.</summary>
protected override void OnError(ErrorEventArgs e)
{
Console.WriteLine($"WS error: session={ID}, message={e.Message}");
base.OnError(e);
}
// -------------------------------------------------------------------------
// Sync DB shims. WebSocketSharp's handler methods are synchronous, so async DB calls
// are wrapped in Task.Run(...).GetAwaiter().GetResult(). Not ideal but pragmatic — the
// alternative is refactoring WebSocketSharp's behavior model.
// -------------------------------------------------------------------------
private void RegisterOrUpdateClientKeySync(string username, string publicKey) =>
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)).GetAwaiter().GetResult();
@@ -727,6 +877,7 @@ public class ChatSocketBehavior : WebSocketBehavior
private List<ServerMembers> GetServerMembersSync() =>
Task.Run(async () => await Db!.Select<ServerMembers>("server_members")).GetAwaiter().GetResult().ToList();
/// <summary>"users:keeper317" → "keeper317". Stored as Surreal record id, displayed as plain name.</summary>
private static string ExtractUsernameFromUserId(string senderUserId)
{
if (string.IsNullOrWhiteSpace(senderUserId)) return "Unknown";
@@ -734,6 +885,7 @@ public class ChatSocketBehavior : WebSocketBehavior
return parts.Length == 2 ? parts[1] : senderUserId;
}
/// <summary>SurrealDB's Id object → "table:recordId" string. Used for storing parent refs as strings in child rows.</summary>
private static string GetRecordId(object? id)
{
if (id is null) return string.Empty;
@@ -743,12 +895,14 @@ public class ChatSocketBehavior : WebSocketBehavior
return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}";
}
/// <summary>Guard: returns true if the DB and key service are both initialised. Logs and returns false otherwise.</summary>
private bool EnsureCoreReady()
{
if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; }
return true;
}
/// <summary>Guard: returns true if encryption keys + channel crypto service are all set. Logs and returns false otherwise.</summary>
private bool EnsureCryptoReady()
{
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null)

View File

@@ -2,12 +2,32 @@ using System.Collections.Concurrent;
namespace RelayServer.Services.Chat;
/// <summary>
/// Two-way in-memory mapping between WebSocket session IDs and usernames.
///
/// Why both directions: when a chat message arrives, we need to look up "which sessions does
/// this server member have open right now?" (username → sessions) so we can deliver to each
/// of their devices. When a connection closes, we need to know "which user owned this session?"
/// (session → username) to clean up correctly.
///
/// Multi-device support: one username can have multiple sessions (phone + desktop + web all
/// connected simultaneously). UsernameToSessions stores a HashSet per username; each lock
/// is scoped to that specific HashSet so different users never block each other.
///
/// Username comparisons are case-insensitive (OrdinalIgnoreCase on the outer dictionary)
/// because the DB stores usernames lowercase but clients may register with mixed case.
/// </summary>
public static class ConnectedClientService
{
private static readonly ConcurrentDictionary<string, string> SessionToUsername = new();
private static readonly ConcurrentDictionary<string, HashSet<string>> UsernameToSessions =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Associates a session ID with a username. Called from HandleRegisterKey. If the same
/// session re-registers under a different username (rare — basically only if the client
/// reauthenticates), the old mapping is cleaned up first to avoid double-bookkeeping.
/// </summary>
public static void Register(string sessionId, string username)
{
if (SessionToUsername.TryGetValue(sessionId, out var oldUsername) &&
@@ -26,12 +46,21 @@ public static class ConnectedClientService
sessions.Add(sessionId);
}
/// <summary>
/// Removes a session from both mappings. Called from OnClose. Idempotent — calling for
/// a session that's already gone is a no-op.
/// </summary>
public static void Unregister(string sessionId)
{
if (SessionToUsername.TryRemove(sessionId, out var username))
RemoveSessionFromUsername(sessionId, username);
}
/// <summary>
/// Returns every active session ID for a given username (case-insensitive lookup).
/// Empty collection if the user is offline. Snapshot-safe: the returned list is a copy,
/// not a live view of the underlying HashSet.
/// </summary>
public static IReadOnlyCollection<string> GetSessionsForUser(string username)
{
if (UsernameToSessions.TryGetValue(username, out var sessions))
@@ -43,11 +72,19 @@ public static class ConnectedClientService
return Array.Empty<string>();
}
/// <summary>
/// Reverse lookup: which user owns this session? Returns the mixed-case username the
/// client registered with (preserves casing for display). Null if the session is unknown.
/// </summary>
public static string? GetUsernameForSession(string sessionId)
{
return SessionToUsername.TryGetValue(sessionId, out var u) ? u : null;
}
/// <summary>
/// Internal cleanup: pulls a session out of the username→sessions HashSet, and removes
/// the username entry entirely if no sessions remain (keeps the dictionary lean).
/// </summary>
private static void RemoveSessionFromUsername(string sessionId, string username)
{
if (!UsernameToSessions.TryGetValue(username, out var sessions))

View File

@@ -7,6 +7,28 @@ using SurrealDb.Net;
namespace RelayServer.Services.Core;
/// <summary>
/// Idempotent server setup. Runs once at boot from Program.cs.
///
/// Each "Ensure*" helper either inserts a missing row or patches an existing one so the
/// declared state matches the code. Running this twice in a row is a no-op.
///
/// What it provisions:
/// - Verifies the three test users exist via CoreClientService (currently a hardcoded stub).
/// - Creates the "Test Server" row in the servers table if missing.
/// - Adds those users to server_members, with Keeper317 as IsOwner=true.
/// - Creates the four premade channels with correct ChannelType and IsReadOnly flags:
/// welcome (Text, read-only) general (Text)
/// files (File, read-only) voice-general (Voice)
/// - Links #general → #files so attachments posted in #general auto-mirror to #files.
/// - Creates the three roles: Admin (all perms), Moderator (manage messages), Member (read+send).
/// - Assigns exactly one role per user (Keeper→Admin, Kira→Moderator, Test→Member).
/// SetUserRoleAsync DELETES stale assignments to guarantee single-role-per-user.
/// - Writes channel_permissions overrides explicitly denying Members SendMessages in
/// #welcome and #files.
/// - Generates the server's RSA keypair + the channel AES key on first boot, stores both
/// in server_encryption_keys, and copies them into ChatSocketBehavior's static fields.
/// </summary>
public sealed class ServerBootstrapService
{
private readonly SurrealDbClient _db;

View File

@@ -3,6 +3,26 @@ using System.Text;
namespace RelayServer.Services.Crypto;
/// <summary>
/// Hybrid RSA-2048 + AES-GCM-256 encryption. Used for any payload that needs to be
/// readable by exactly one party (the holder of a specific RSA private key).
///
/// Encrypt:
/// 1. Generate a fresh 256-bit AES key and 96-bit nonce.
/// 2. Encrypt the plaintext with AES-GCM → CipherText + Tag (auth tag, 128-bit).
/// 3. Encrypt the AES key with the recipient's RSA public key (OAEP-SHA256).
/// 4. Return all four as base64 strings in an EncryptedPayload.
///
/// Decrypt: reverse — RSA-decrypt the AES key, then AES-GCM-decrypt the ciphertext.
///
/// Why hybrid: RSA can only encrypt small inputs (~190 bytes for 2048-bit OAEP-SHA256).
/// Wrapping a symmetric key with RSA lets us encrypt arbitrarily large payloads while
/// still using the recipient's RSA keypair as the access mechanism. This is the same
/// design as PGP, TLS handshakes, etc.
///
/// The identical implementation exists in RelayClient.Crypto.E2EeHelper — they're
/// mirrored on both ends so any payload encrypted on one side decrypts on the other.
/// </summary>
public static class E2EeHelper
{
public static (string publicKey, string privateKey) GenerateRsaKeyPair()

View File

@@ -12,6 +12,10 @@ public sealed class PermissionService
_db = db;
}
/// <summary>
/// Owners/admins always allowed. Non-admins blocked from read-only channels (#welcome,
/// #files). Everyone else passes through the normal channel-level Deny → Allow → role ladder.
/// </summary>
public async Task<bool> CanSendMessagesAsync(string username, string channelId)
{
if (await IsOwnerOrAdminAsync(username))
@@ -23,39 +27,57 @@ public sealed class PermissionService
return await HasPermissionAsync(username, channelId, PermissionFlags.SendMessages);
}
/// <summary>Server-wide ability to create channels. Gates the "+" button on the sidebar.</summary>
public async Task<bool> CanManageChannelsAsync(string username) =>
await IsOwnerOrAdminAsync(username) ||
await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels);
/// <summary>Per-channel ability to delete/edit OTHER people's messages. Authors can always delete their own.</summary>
public async Task<bool> CanManageMessagesAsync(string username, string channelId) =>
await IsOwnerOrAdminAsync(username) ||
await HasPermissionAsync(username, channelId, PermissionFlags.ManageMessages);
/// <summary>Convenience query — exposes the owner-or-admin shortcut as a public method.</summary>
public async Task<bool> IsAdministratorAsync(string username) =>
await IsOwnerOrAdminAsync(username);
/// <summary>
/// "Visibility" — default-allow. Only blocks if a channel-level Deny mask explicitly
/// removes ViewChannel for the user's role. Owners/admins bypass.
/// </summary>
public async Task<bool> CanViewChannelAsync(string username, string channelId)
{
if (await IsOwnerOrAdminAsync(username)) return true;
return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.ViewChannel);
}
/// <summary>
/// Voice-channel Speak. Default-allow. Blocked by channel-level Deny. Used at RtcJoin
/// time so denied users can't even register voice presence.
/// </summary>
public async Task<bool> CanSpeakAsync(string username, string channelId)
{
if (await IsOwnerOrAdminAsync(username)) return true;
return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.Speak);
}
/// <summary>Server-wide ability to delete channels. ManageChannels OR explicit DeleteChannel.</summary>
public async Task<bool> CanDeleteChannelAsync(string username) =>
await IsOwnerOrAdminAsync(username) ||
await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) ||
await HasGlobalPermissionAsync(username, PermissionFlags.DeleteChannel);
/// <summary>Server-wide ability to edit channels. ManageChannels OR explicit EditChannel.</summary>
public async Task<bool> CanEditChannelAsync(string username) =>
await IsOwnerOrAdminAsync(username) ||
await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) ||
await HasGlobalPermissionAsync(username, PermissionFlags.EditChannel);
/// <summary>
/// Step 1 of the ladder: owner flag OR Administrator permission on any assigned role.
/// Owner check goes first because it doesn't require roles to be seeded — server owner
/// is authoritative regardless of role-table state.
/// </summary>
private async Task<bool> IsOwnerOrAdminAsync(string username)
{
if (await IsServerOwnerAsync(username))
@@ -65,6 +87,13 @@ public sealed class PermissionService
return roles.Any(r => r.Permissions.HasFlag(PermissionFlags.Administrator));
}
/// <summary>
/// The canonical permission ladder for per-channel checks:
/// 1. Owner/admin → true.
/// 2. Channel-level Deny mask for any of the user's roles → false (Deny wins).
/// 3. Channel-level Allow mask for any of the user's roles → true.
/// 4. Base role permissions → fallback.
/// </summary>
private async Task<bool> HasPermissionAsync(
string username, string channelId, PermissionFlags flag)
{
@@ -86,6 +115,10 @@ public sealed class PermissionService
return userRoles.Any(r => r.Permissions.HasFlag(flag));
}
/// <summary>
/// Server-wide (not channel-scoped) permission check. Used for things like ManageChannels
/// where there's no specific channel context. Admin flag short-circuits.
/// </summary>
private async Task<bool> HasGlobalPermissionAsync(string username, PermissionFlags flag)
{
var roles = await GetUserRolesAsync(username);
@@ -94,6 +127,10 @@ public sealed class PermissionService
r.Permissions.HasFlag(flag));
}
/// <summary>
/// "Was this permission explicitly denied here?" — used by default-allow permissions
/// (ViewChannel, Speak) which only become restrictive when there's a Deny override.
/// </summary>
private async Task<bool> IsDeniedByChannelAsync(string username, string channelId, PermissionFlags flag)
{
var userRoles = await GetUserRolesAsync(username);
@@ -107,6 +144,10 @@ public sealed class PermissionService
.Any(co => co.Deny.HasFlag(flag));
}
/// <summary>
/// Checks ServerMembers.IsOwner directly. This is the authoritative ownership test —
/// independent of the role table, so ownership keeps working even if roles aren't seeded.
/// </summary>
private async Task<bool> IsServerOwnerAsync(string username)
{
var userId = $"users:{username.ToLower()}";
@@ -116,6 +157,11 @@ public sealed class PermissionService
m.IsOwner);
}
/// <summary>
/// Loads every Role row currently assigned to the user via UserRoles. Empty list if the
/// user has no role assignments (which means they implicitly fail every permission check
/// unless they happen to be the server owner).
/// </summary>
private async Task<List<Roles>> GetUserRolesAsync(string username)
{
var userId = $"users:{username.ToLower()}";
@@ -134,12 +180,14 @@ public sealed class PermissionService
.ToList();
}
/// <summary>Loads every channel_permissions override row for a channel (all roles, all flags).</summary>
private async Task<List<ChannelPermissions>> GetChannelPermissionsAsync(string channelId)
{
var all = await _db.Select<ChannelPermissions>("channel_permissions");
return all.Where(cp => cp.ChannelId == channelId).ToList();
}
/// <summary>True if the channel's IsReadOnly flag is set on its row in the channels table.</summary>
private async Task<bool> IsChannelReadOnlyAsync(string channelId)
{
var channels = await _db.Select<Channels>("channels");
@@ -147,6 +195,7 @@ public sealed class PermissionService
return channel?.IsReadOnly ?? false;
}
/// <summary>SurrealDB's Id object → "table:id" string. Local copy because PermissionService isn't a friend of ChatSocketBehavior.</summary>
private static string GetRecordIdString(object? id)
{
if (id is null) return string.Empty;