diff --git a/RelayClient/Crypto/E2EeHelper.cs b/RelayClient/Crypto/E2EeHelper.cs index 917e7c9..dcc99f3 100644 --- a/RelayClient/Crypto/E2EeHelper.cs +++ b/RelayClient/Crypto/E2EeHelper.cs @@ -3,8 +3,14 @@ using System.Text; namespace RelayClient.Crypto; +/// +/// Client-side mirror of RelayServer.Services.Crypto.E2EeHelper. Identical algorithms + +/// key formats so blobs round-trip cleanly between server and client. +/// See the server class for full algorithm details. +/// public static class E2EeHelper { + /// Generates a fresh RSA-2048 keypair. Called once per user on first launch and persisted via KeyStorage. public static (string publicKey, string privateKey) GenerateRsaKeyPair() { using var rsa = RSA.Create(2048); @@ -15,6 +21,11 @@ public static class E2EeHelper ); } + /// + /// Hybrid encrypts a plaintext string for a specific recipient: fresh AES-256 key encrypts + /// the payload (AES-GCM), then RSA-OAEP-SHA256 wraps the AES key with the recipient's + /// public key. Returns base64-encoded fields ready to ship in a SocketEncryptedMessage. + /// public static EncryptedPayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64) { byte[] aesKey = RandomNumberGenerator.GetBytes(32); @@ -44,6 +55,11 @@ public static class E2EeHelper }; } + /// + /// Reverse of EncryptForRecipient: RSA-decrypt the AES key with the recipient's private + /// key, then AES-GCM-decrypt the ciphertext. Throws on tampered/corrupt payloads + /// (auth tag mismatch). Returns the original UTF-8 plaintext string. + /// public static string DecryptForRecipient(EncryptedPayload payload, string recipientPrivateKeyBase64) { byte[] aesKey; @@ -69,6 +85,7 @@ public static class E2EeHelper } } +/// The 4-tuple ciphertext bundle. Same shape on both client and server; matches SocketEncryptedMessage's encrypted fields. public class EncryptedPayload { public required string CipherText { get; set; } diff --git a/RelayClient/Crypto/KeyStorage.cs b/RelayClient/Crypto/KeyStorage.cs index 502a254..73bc12a 100644 --- a/RelayClient/Crypto/KeyStorage.cs +++ b/RelayClient/Crypto/KeyStorage.cs @@ -1,7 +1,17 @@ namespace RelayClient.Crypto; +/// +/// Per-user RSA keypair persistence. Keys live as base64-encoded files in +/// {AppData}/keys/{username}.{public|private}.key +/// +/// Plaintext on disk. For now this is fine because the only attack model is "someone else +/// has access to your filesystem" — at which point everything is compromised. A future +/// enhancement could encrypt the private key with a passphrase derived from the user's +/// password, similar to how SSH/PGP do it. +/// public static class KeyStorage { + /// Returns (and creates if needed) the per-app keys directory. private static string GetKeyFolder() { var folder = Path.Combine(FileSystem.AppDataDirectory, "keys"); @@ -9,29 +19,34 @@ public static class KeyStorage return folder; } + /// Writes the base64 RSA private key to disk. Used at first-launch after GenerateRsaKeyPair. public static void SavePrivateKey(string username, string privateKey) { File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"), privateKey); } + /// Writes the base64 RSA public key to disk. Sent to the server via WsAction.RegisterKey. public static void SavePublicKey(string username, string publicKey) { File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"), publicKey); } + /// Reads the user's RSA private key. Used by TryDecryptAndParseContent on every inbound message. public static string LoadPrivateKey(string username) { return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key")); } + /// Reads the user's RSA public key. Used during the boot handshake to send to the server. public static string LoadPublicKey(string username) { return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key")); } + /// True if BOTH halves of the user's keypair already exist on disk. False means we need to generate. public static bool HasKeys(string username) { return File.Exists(Path.Combine(GetKeyFolder(), $"{username}.private.key")) && File.Exists(Path.Combine(GetKeyFolder(), $"{username}.public.key")); } -} \ No newline at end of file +} diff --git a/RelayClient/Helpers/EmbedHelper.cs b/RelayClient/Helpers/EmbedHelper.cs index 38c4516..25caaf4 100644 --- a/RelayClient/Helpers/EmbedHelper.cs +++ b/RelayClient/Helpers/EmbedHelper.cs @@ -22,12 +22,18 @@ public static class EmbedHelper private static readonly HashSet ImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"]; + /// Extracts every distinct http/https URL from message text. De-duped so multiple occurrences don't double-embed. public static List DetectUrls(string text) { if (string.IsNullOrWhiteSpace(text)) return []; return UrlPattern.Matches(text).Select(m => m.Value).Distinct().ToList(); } + /// + /// Dispatcher: classifies each URL and delegates to the appropriate Build* method. + /// Order matters — jump links and YouTube/Vimeo IDs are checked before the generic + /// image-extension and link-card paths so the more specific embed wins. + /// public static List BuildEmbeds(string text) { var views = new List(); @@ -51,6 +57,10 @@ public static class EmbedHelper return views; } + /// + /// Decodes a base64 attachment to bytes and renders it as an inline Image. Used by + /// MainPage.BuildBubbleContent when a message has an image attachment. + /// public static View BuildBase64ImageEmbed(string base64, string fileName) { try @@ -86,6 +96,10 @@ public static class EmbedHelper } } + /// + /// Renders a non-image attachment as a tappable card. Tap → writes the bytes to a temp + /// file and hands off to the system handler via Launcher.OpenAsync. + /// public static View BuildFileCard(string base64, string fileName, string mimeType) { var label = new Label @@ -122,6 +136,7 @@ public static class EmbedHelper }; } + /// Direct image URL → inline Image (loaded async by MAUI from the URI). Tap opens in browser. private static View BuildImageEmbed(string url) { var image = new Image @@ -146,6 +161,11 @@ public static class EmbedHelper }; } + /// + /// Builds the "💬 Jump to linked message" card for relay://jump URLs. The actual tap + /// handler is wired by MainPage.WireJumpLinks because it needs access to the message + /// bubble dictionary that EmbedHelper doesn't know about. + /// private static View BuildJumpCard(string relayUrl) { var label = new Label @@ -167,9 +187,15 @@ public static class EmbedHelper }; } + /// Attached property that stores the relay:// URL on the jump label so MainPage.WireJumpLinks can find it. public static readonly BindableProperty JumpUrlProperty = BindableProperty.CreateAttached("JumpUrl", typeof(string), typeof(EmbedHelper), null); + /// + /// Generic URL card. Starts with just the URL itself; spawns a background task to fetch + /// OG meta tags from the page and append a title/description/preview-image when the + /// response arrives. The whole card is tappable to open the URL in the browser. + /// private static View BuildLinkCard(string url) { var displayUrl = url.Length > 55 ? url[..52] + "…" : url; @@ -246,6 +272,10 @@ public static class EmbedHelper private sealed record OgData(string? Title, string? Description, string? ImageUrl); + /// + /// 4-second-budget HTTP GET + regex extract of og:title, og:description, og:image meta + /// tags from a page's HTML. Returns null on any failure (so the link card just stays bare). + /// private static async Task FetchOgTagsAsync(string url) { try @@ -283,6 +313,7 @@ public static class EmbedHelper return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()) : null; } + /// True if the URL's path ends with a known image extension. Used to choose between BuildImageEmbed and BuildLinkCard. private static bool IsImageUrl(string url) { try @@ -298,6 +329,7 @@ public static class EmbedHelper @"(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{6,})", RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// Extracts the 11-char video ID from any YouTube URL form (watch, youtu.be, embed, shorts, /v/). private static bool TryGetYouTubeId(string url, out string id) { var match = YouTubePattern.Match(url); @@ -314,6 +346,7 @@ public static class EmbedHelper @"vimeo\.com/(?:video/|channels/[^/]+/|groups/[^/]+/videos/)?(\d{6,})", RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// Extracts the numeric video ID from Vimeo URLs. Handles vimeo.com/{id}, /video/{id}, channels/x/{id}, groups/x/videos/{id}. private static bool TryGetVimeoId(string url, out string id) { var match = VimeoPattern.Match(url); @@ -326,6 +359,7 @@ public static class EmbedHelper return false; } + /// YouTube embed card. Thumbnail comes from img.youtube.com; player swaps to the youtube.com/embed/ URL on tap. private static View BuildYouTubeCard(string url, string videoId) => BuildVideoCardWithEmbed( providerLabel: "🎬 YouTube", @@ -334,6 +368,7 @@ public static class EmbedHelper thumbnailUrl: $"https://img.youtube.com/vi/{videoId}/hqdefault.jpg", embedUrl: $"https://www.youtube.com/embed/{videoId}?autoplay=1&rel=0"); + /// Vimeo embed card. No thumbnail (Vimeo's API requires OAuth); placeholder stays black with a play badge until tap. private static View BuildVimeoCard(string url, string videoId) => BuildVideoCardWithEmbed( providerLabel: "🎬 Vimeo", @@ -342,6 +377,11 @@ public static class EmbedHelper thumbnailUrl: null, // Vimeo thumbs require an API call; skip and show a black placeholder embedUrl: $"https://player.vimeo.com/video/{videoId}?autoplay=1"); + /// + /// The lazy-swap player. Default content is BuildThumbnailPlaceholder (cheap — no WebView + /// spawned). On tap, the ContentView's content swaps to a WebView pointing at embedUrl. + /// Means 50 videos in scrollback = 50 thumbnails, not 50 WebViews. + /// private static View BuildVideoCardWithEmbed( string providerLabel, Color providerColor, @@ -399,6 +439,10 @@ public static class EmbedHelper }; } + /// + /// 16:9 thumbnail (or solid black if no thumb URL) with a translucent black play-badge + /// overlay. Calling onPlay swaps the parent ContentView's content to the real WebView. + /// private static View BuildThumbnailPlaceholder(string? thumbnailUrl, Action onPlay) { var grid = new Grid @@ -440,6 +484,7 @@ public static class EmbedHelper return grid; } + /// The actual in-client video player. WebView2 (Windows) and WebKit (mobile) both handle YouTube/Vimeo embed pages. private static View BuildEmbeddedPlayer(string embedUrl) { return new WebView diff --git a/RelayClient/Helpers/MarkdownHelper.cs b/RelayClient/Helpers/MarkdownHelper.cs index 5090903..0f5db45 100644 --- a/RelayClient/Helpers/MarkdownHelper.cs +++ b/RelayClient/Helpers/MarkdownHelper.cs @@ -12,6 +12,12 @@ public static class MarkdownHelper private static readonly Color MentionBg = Color.FromArgb("#2D2F5C"); private static readonly Color SpoilerBg = Color.FromArgb("#1F1F23"); + /// + /// The entry point. Returns either a single Label (simple inline text) or a + /// VerticalStackLayout (anything with paragraphs, code blocks, or headers). + /// First pass extracts fenced code blocks (verbatim, can span multiple lines), then + /// AppendTextSegment handles per-line headers and the inline parser. + /// public static View Render(string markdown, double fontSize = 14) { if (string.IsNullOrEmpty(markdown)) @@ -37,6 +43,11 @@ public static class MarkdownHelper return stack.Children.Count == 1 ? (View)stack.Children[0] : stack; } + /// + /// Splits a non-code segment by newline and emits the right view per line. Headers/subtext + /// get their own labels; consecutive normal lines accumulate into a paragraph buffer so + /// they wrap naturally as one paragraph. + /// private static void AppendTextSegment(VerticalStackLayout stack, string segment, double fontSize) { var paragraphBuffer = new StringBuilder(); @@ -94,6 +105,10 @@ public static class MarkdownHelper FlushParagraph(); } + /// + /// Builds the dark-pane code block. If a language is specified, delegates token coloring + /// to SyntaxHighlighter and prepends a small green language label (Discord-style). + /// private static View CreateCodeBlock(string language, string code) { var label = new Label @@ -141,6 +156,7 @@ public static class MarkdownHelper }; } + /// Bold, larger Label for # / ## / ### lines. Inline markdown still works inside (e.g. `# Hello **world**`). private static Label CreateHeaderLabel(string text, double size) { var label = new Label @@ -162,6 +178,7 @@ public static class MarkdownHelper return label; } + /// Smaller, grey Label for "-#" lines (Discord calls it subtext). Inherits inline markdown. private static Label CreateSubtextLabel(string text, double size) { var label = new Label @@ -190,6 +207,7 @@ public static class MarkdownHelper return label; } + /// Standard paragraph Label. Runs the inline parser to build a FormattedString of spans. private static Label CreateInlineLabel(string text, double fontSize) { var label = new Label { FontSize = fontSize, LineBreakMode = LineBreakMode.WordWrap }; @@ -204,6 +222,11 @@ public static class MarkdownHelper return label; } + /// + /// Attaches a TapGestureRecognizer that reveals every spoiler span in the label when + /// tapped once. MAUI Spans can't fire their own gesture events, so per-spoiler reveal + /// would require splitting the line into separate labels — this is the pragmatic compromise. + /// private static void WireSpoilerTap(Label label, List spoilerSpans) { if (spoilerSpans.Count == 0) return; @@ -220,6 +243,12 @@ public static class MarkdownHelper label.GestureRecognizers.Add(tap); } + /// + /// Single-pass character walk. For each markdown sigil (||, @, ~~, __, **, *, `), tries + /// to find a matching closer; if found, emits a styled Span and skips past. Otherwise the + /// char accumulates into a "plain" buffer that's flushed as a plain Span when the next + /// sigil hits or the string ends. Spoiler spans are registered in spoilerSpans for reveal. + /// private static void ParseInline(string text, IList spans, double fontSize, List spoilerSpans) { var plain = new StringBuilder(); @@ -365,8 +394,13 @@ public static class MarkdownHelper Flush(); } + /// Safe one-character lookahead. Returns '\0' past end-of-string. private static char Peek(string text, int index) => index < text.Length ? text[index] : '\0'; + /// + /// Finds the next single occurrence of marker that is NOT immediately followed by + /// another marker. Used to disambiguate "*italic*" from "**bold**". + /// private static int FindClosingSingle(string text, char marker, int start) { for (int i = start; i < text.Length; i++) diff --git a/RelayClient/Helpers/SyntaxHighlighter.cs b/RelayClient/Helpers/SyntaxHighlighter.cs index 6f197ad..c48b7a0 100644 --- a/RelayClient/Helpers/SyntaxHighlighter.cs +++ b/RelayClient/Helpers/SyntaxHighlighter.cs @@ -2,21 +2,50 @@ using System.Text.RegularExpressions; namespace RelayClient.Helpers; +/// +/// Discord-style syntax highlighting for ```lang...``` fenced code blocks. Builds a list of +/// MAUI Spans (with colors from the VS Code Dark+ palette) that the caller drops into a +/// FormattedString. +/// +/// How it works: +/// - The opening fence captures an optional language tag (e.g. ```cs, ```python). +/// - Aliases resolves "cs" → "csharp", "js" → "javascript", etc. +/// - Tokenizers[lang] is a compiled regex with named groups (comment/string/number/word/…). +/// - For each match, SpanForMatch picks a colour based on which group matched + whether +/// a "word" hit a language keyword set. +/// +/// Adding a new language: register an alias (if needed), a Keywords set, and a tokenizer regex. +/// public static class SyntaxHighlighter { + /// Fallback identifier color (light grey). Used for any token we don't recognise. private static readonly Color DefaultColor = Color.FromArgb("#D4D4D4"); + /// Language keywords (if, for, return, etc.) — VS Code's "control flow" blue. private static readonly Color KeywordColor = Color.FromArgb("#569CD6"); + /// String literals — orange/salmon. private static readonly Color StringColor = Color.FromArgb("#CE9178"); + /// Numeric literals — soft green. private static readonly Color NumberColor = Color.FromArgb("#B5CEA8"); + /// Comments — green, rendered italic. private static readonly Color CommentColor = Color.FromArgb("#6A9955"); + /// Type names (heuristic: uppercase-start words in C#/JS/TS) — teal. private static readonly Color TypeColor = Color.FromArgb("#4EC9B0"); + /// Function names — yellow. Currently unused (we don't disambiguate function calls). private static readonly Color FunctionColor = Color.FromArgb("#DCDCAA"); + /// Operators — same as default. Reserved for future use. private static readonly Color OperatorColor = Color.FromArgb("#D4D4D4"); + /// HTML tag names (<div>, </p>) — blue. private static readonly Color TagColor = Color.FromArgb("#569CD6"); + /// HTML/CSS attribute names, YAML keys, bash variables — light blue. private static readonly Color AttrColor = Color.FromArgb("#9CDCFE"); + /// Monospace font registered in MauiProgram. Used for all code-block spans. private const string FontFamily = "AnonymousProRegular"; + /// + /// Short language tags → canonical names. So users can write ```cs (instead of ```csharp), + /// ```py instead of ```python, etc. Case-insensitive. + /// private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) { ["cs"] = "csharp", @@ -34,6 +63,11 @@ public static class SyntaxHighlighter ["yml"] = "yaml" }; + /// + /// Per-language keyword sets. A token in a "word" match-group that hits one of these + /// gets rendered with KeywordColor. Case-sensitivity matches the language — Ordinal + /// for most languages, OrdinalIgnoreCase for SQL and CSS. + /// private static readonly Dictionary> Keywords = new(StringComparer.OrdinalIgnoreCase) { ["csharp"] = new(StringComparer.Ordinal) @@ -97,6 +131,11 @@ public static class SyntaxHighlighter } }; + /// + /// Per-language compiled token regex. Each pattern uses named groups (comment/string/ + /// number/word/tag/attr/…) which SpanForMatch dispatches on. Initialised lazily in the + /// static constructor so the heavy regex compilation is paid once at startup. + /// private static readonly Dictionary Tokenizers = new(StringComparer.Ordinal); static SyntaxHighlighter() @@ -186,6 +225,11 @@ public static class SyntaxHighlighter opts | RegexOptions.Multiline); } + /// + /// Entry point. Walks every regex match in the code, emits plain spans for the gaps and + /// styled spans for the matches. If the language is unknown (or not specified), returns a + /// single default-colored span — code still renders in the monospace font, just no colors. + /// public static List Highlight(string code, string? language, double fontSize) { var lang = Resolve(language); @@ -215,6 +259,11 @@ public static class SyntaxHighlighter return spans; } + /// + /// Maps a regex Match to a colored Span by inspecting which named group succeeded. Words + /// fall through to a keyword-set lookup; in C#/JS/TS, uppercase-start words that aren't + /// keywords are treated as type names (a cheap heuristic that works surprisingly well). + /// private static Span SpanForMatch(Match m, string lang, HashSet? keywords, double fontSize) { if (m.Groups["comment"].Success) @@ -285,6 +334,7 @@ public static class SyntaxHighlighter return MakeSpan(m.Value, DefaultColor, fontSize); } + /// Helper: build a Span with the monospace code font and the given colour + bold/italic flags. private static Span MakeSpan(string text, Color color, double fontSize, bool bold = false, bool italic = false) { var attrs = FontAttributes.None; @@ -301,6 +351,7 @@ public static class SyntaxHighlighter }; } + /// Normalises a user-supplied language tag through the Aliases table. Returns null for empty/whitespace input. private static string? Resolve(string? language) { if (string.IsNullOrWhiteSpace(language)) return null; diff --git a/RelayClient/MainPage.xaml.cs b/RelayClient/MainPage.xaml.cs index a80f2ff..5096a91 100644 --- a/RelayClient/MainPage.xaml.cs +++ b/RelayClient/MainPage.xaml.cs @@ -9,40 +9,103 @@ using RelayShared.Services; namespace RelayClient; +/// +/// The chat shell. Owns: +/// - RelaySocketClient (the WebSocket to the server) +/// - RtcBridgeService (the bridge between the WS and the WebView-hosted WebRTC JS) +/// - All in-memory message/channel state for the active session. +/// +/// Wire-up of socket events happens once in the constructor: +/// ChannelListReceived → HandleChannelList (rebuilds sidebar; auto-selects #welcome on first load only) +/// EncryptedChatReceived → HandleEncryptedChat (decrypt, append to channel buffer, render bubble) +/// MessageEdited → HandleMessageEdited (decrypt new text, mutate ChatMessage, rebuild bubble in-place) +/// MessageDeleted → HandleMessageDeleted (mark IsDeleted, rebuild bubble to tombstone) +/// TypingReceived → HandleTyping (show "X is typing…", auto-clear after 3s) +/// EditHistoryReceived → HandleEditHistory (decrypt every version, show in DisplayAlert) +/// EncryptedRtcSignalReceived → RtcBridgeService.HandleIncomingRtcSignalAsync +/// +/// State dictionaries: +/// _messagesByChannel: channelId → message list (per-channel scrollback) +/// _messagesById : messageId → ChatMessage (O(1) lookup for edit/delete handlers) +/// _messageBubbles : messageId → MAUI Border (O(1) in-place bubble update on edit/delete) +/// _mentionCounts : channelId → unread @mention count (sidebar 🔔 badge) +/// _typingClearTimers: username → CTS (per-user typing-indicator timeout) +/// +/// Bubble rendering is done by BuildBubbleContent (markdown body → MarkdownHelper.Render, +/// URL embeds → EmbedHelper.BuildEmbeds, attachments → EmbedHelper.BuildBase64ImageEmbed/BuildFileCard). +/// Right-click attaches platform-specific (WinUI RightTapped) context menus via +/// AttachMessageContextMenu / AttachChannelContextMenu. +/// public partial class MainPage : ContentPage { + /// Logged-in username. Static so RelaySocketClient can read it without a back-pointer. public static string _username = string.Empty; + + /// The WebSocket pipe to RelayServer. private readonly RelaySocketClient _socket; + + /// The C#↔JS bridge for the WebView WebRTC engine. private readonly RtcBridgeService _rtc; + /// Core-issued auth token, set during sign-in by ClientSession. public static string? _userToken; + /// "channels:abc" — the currently-viewed channel, or null if none. private string? _currentChannelId; + + /// Display name of the current channel (used for the header label). private string? _currentChannelName; + + /// Drives view switching (messages vs RTC) on channel select. private ChannelType _currentChannelType = ChannelType.Text; + /// channelId → message list. Per-channel scrollback kept in memory for the session. private readonly Dictionary> _messagesByChannel = new(); + + /// The full channel list as last delivered by the server (after Visibility filtering). private readonly List _channels = []; - private readonly Dictionary _messagesById = new(); - private readonly Dictionary _messageBubbles = new(); + /// messageId → ChatMessage. O(1) lookup for edit/delete handlers. + private readonly Dictionary _messagesById = new(); + + /// messageId → MAUI Border (the bubble). O(1) in-place bubble rebuild on edit/delete. + private readonly Dictionary _messageBubbles = new(); + + /// When non-null, the next Send becomes a reply to this message. private ChatMessage? _replyingToMessage; - private string? _editingMessageId; + /// When non-null, the next Send becomes an edit of this message. + private string? _editingMessageId; + + /// Base64 of the staged file attachment. Cleared after Send or context cancel. private string? _pendingAttachmentBase64; private string? _pendingAttachmentMimeType; private string? _pendingAttachmentFileName; - private CancellationTokenSource? _typingDebounce; + /// Cancels the previous "send typing event" if the user keeps typing. Debounce gate. + private CancellationTokenSource? _typingDebounce; + + /// Per-username 3-second auto-clear timers for the typing indicator label. private readonly Dictionary _typingClearTimers = new(); + /// + /// True after the first channel-list arrives. Used so subsequent channel-list broadcasts + /// (from create/delete) DON'T yank the user back to #welcome. + /// private bool _channelsInitialized; + /// channelId → unread @mention count. Drives the 🔔N sidebar badge. private readonly Dictionary _mentionCounts = new(); + /// Matches `@word` (where word is letters/digits/underscore). Used at send time to extract mentions. private static readonly Regex MentionExtract = new(@"@(\w+)", RegexOptions.Compiled); + /// + /// Generates a fresh RSA keypair if the user has none on disk, wires every socket event, + /// then opens the WebSocket. Connect() fires the auth/register-key/get-server-key/get-channels + /// handshake in order before any message can be sent. + /// public MainPage(string username) { InitializeComponent(); @@ -82,6 +145,11 @@ public partial class MainPage : ContentPage SetupFileDragAndDrop(); } + /// + /// Windows-only: wires AllowDrop/DragOver/Drop on the messages view via the WinUI native + /// UIElement. Drop pulls the first file out of the StorageItems payload and stages it via + /// IngestPickedFileAsync (same path as the 📎 button). + /// private void SetupFileDragAndDrop() { #if WINDOWS @@ -123,6 +191,12 @@ public partial class MainPage : ContentPage #endif } + /// + /// Windows-only: replaces the WinUI TextBox's default Enter-handling with Enter=send, + /// Shift+Enter=newline. Has to set AcceptsReturn=false on the native TextBox and use + /// AddHandler(handledEventsToo: true) to intercept Enter before it bubbles back up + /// (the equivalent of WPF's PreviewKeyDown). + /// private void SetupEditorKeyHandler() { #if WINDOWS @@ -170,6 +244,11 @@ public partial class MainPage : ContentPage private void SendButton_OnClicked(object? sender, EventArgs e) => SendMessage(); + /// + /// Debounced typing-indicator fire. Resets a 400ms timer every keystroke; on timeout + /// (i.e. the user paused), sends SendTyping. Prevents spamming the server with one event + /// per character. + /// private void MessageEntry_OnTextChanged(object? sender, TextChangedEventArgs e) { if (string.IsNullOrWhiteSpace(_currentChannelId)) return; @@ -186,6 +265,16 @@ public partial class MainPage : ContentPage }, token); } + /// + /// The main send path. Handles three modes: + /// - Edit mode (_editingMessageId set) → encrypts new text and fires SendEditMessage. + /// - New message → extracts @mentions, auto-mentions reply target, attaches any + /// staged file, encrypts the ChatMessageContent JSON, sends as ClientEncryptedChat. + /// + /// CRITICAL: snapshots UI state to local variables BEFORE clearing the input, then runs + /// the encrypt+send off the UI thread via Task.Run. Attachments can be megabytes; doing + /// the encryption synchronously would freeze the UI for seconds. + /// private void SendMessage() { if (!MessageEntry.IsEnabled) return; @@ -274,6 +363,7 @@ public partial class MainPage : ContentPage }); } + /// 📎 button click. Opens the system file picker (no filter — all files), then stages the result via IngestPickedFileAsync. private async void AttachFile_OnClicked(object? sender, EventArgs e) { try @@ -293,6 +383,11 @@ public partial class MainPage : ContentPage } } + /// + /// Shared by the 📎 button and drag-and-drop. Reads the file, enforces the 50 MB cap, + /// base64-encodes, sets _pendingAttachment* and shows the context bar with the filename + /// so the user knows it's staged. + /// private async Task IngestPickedFileAsync(string fullPath, string fileName) { if (!File.Exists(fullPath)) @@ -333,6 +428,7 @@ public partial class MainPage : ContentPage _pendingAttachmentFileName = null; } + /// Best-effort MIME mapping by file extension. Used to set AttachmentMimeType on send. private static string GetMimeType(string fileName) { var ext = Path.GetExtension(fileName).ToLowerInvariant(); @@ -352,6 +448,7 @@ public partial class MainPage : ContentPage private void CancelContext_OnClicked(object? sender, EventArgs e) => CancelContext(); + /// Clears reply/edit state, drops the staged attachment, hides the context bar, empties the editor. private void CancelContext() { _replyingToMessage = null; @@ -366,6 +463,7 @@ public partial class MainPage : ContentPage }); } + /// Enters reply mode. Next Send becomes a reply to this message + auto-mentions the original sender. private void StartReply(ChatMessage message) { _replyingToMessage = message; @@ -380,6 +478,7 @@ public partial class MainPage : ContentPage }); } + /// Enters edit mode. Pre-fills the editor with the existing text; next Send fires SendEditMessage. private void StartEdit(ChatMessage message) { _editingMessageId = message.MessageId; @@ -397,6 +496,7 @@ public partial class MainPage : ContentPage private void ConfirmDelete(ChatMessage message) => _socket.SendDeleteMessage(message.MessageId, _currentChannelId ?? string.Empty); + /// Copies a relay://jump/{channel}/{message} URL to the clipboard. Pasting this in chat creates a tappable "Jump to message" embed. private void CopyMessageLink(ChatMessage message) { if (string.IsNullOrWhiteSpace(message.MessageId)) return; @@ -405,6 +505,10 @@ public partial class MainPage : ContentPage SafeSendRawToWebView($"Link copied: {link}"); } + /// + /// Wires the message-bubble context menu. On Windows: native RightTapped via HandlerChanged. + /// On mobile (no right click): double-tap as the fallback gesture. + /// private void AttachMessageContextMenu(Border bubble, ChatMessage message) { #if WINDOWS @@ -424,6 +528,10 @@ public partial class MainPage : ContentPage #endif } + /// + /// Builds the action sheet shown on right-click of a message: Reply + Copy Link always; + /// Edit + Delete only for messages the current user owns. + /// private async Task ShowMessageContextMenuAsync(ChatMessage message) { if (message.IsDeleted) return; @@ -447,6 +555,7 @@ public partial class MainPage : ContentPage } } + /// Same gesture wiring as AttachMessageContextMenu, but for sidebar channel buttons. private void AttachChannelContextMenu(View target, ChannelItem channel) { #if WINDOWS @@ -466,6 +575,7 @@ public partial class MainPage : ContentPage #endif } + /// Action sheet for right-click on a channel: "View Permissions" always; "Delete Channel" only if CanManage. private async Task ShowChannelContextMenuAsync(ChannelItem channel) { var options = new List { "⚙ View Permissions" }; @@ -485,6 +595,7 @@ public partial class MainPage : ContentPage } } + /// Read-only summary dialog of channel meta and the user's resolved permissions. Full per-role editing UI is a future feature. private async Task ShowChannelPermissionsAsync(ChannelItem channel) { var lines = new List @@ -517,6 +628,7 @@ public partial class MainPage : ContentPage if (ok) _socket.SendDeleteChannel(channel.ChannelId); } + /// "+" sidebar button. Walks the user through name → type → group, then fires SendCreateChannel. private async void AddChannel_OnClicked(object? sender, EventArgs e) { var name = await DisplayPromptAsync("New Channel", "Channel name:", "Create", "Cancel", @@ -546,6 +658,11 @@ public partial class MainPage : ContentPage group); } + /// + /// ChannelListReceived handler. The _channelsInitialized guard ensures the auto-select + /// (jump to #welcome) only happens on the very first receive — after that, the user + /// stays on whatever channel they were viewing. + /// private void HandleChannelList(SocketChannelList channelList) { _channels.Clear(); @@ -581,6 +698,12 @@ public partial class MainPage : ContentPage } } + /// + /// New-message handler. Filters out frames addressed to other users (delivery is per + /// recipient), decrypts via TryDecryptAndParseContent, stores in both lookup dictionaries. + /// If the message is for the active channel → render immediately. If it's for a different + /// channel AND mentions the user → bump the sidebar 🔔 badge. + /// private void HandleEncryptedChat(SocketEncryptedMessage payload) { if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return; @@ -639,6 +762,7 @@ public partial class MainPage : ContentPage } } + /// True if message.Mentions contains this user's name OR "here" OR "everyone". Drives ping badges and bubble highlight. private static bool MessageMentionsMe(ChatMessage message) => message.Mentions is not null && message.Mentions.Any(m => @@ -646,6 +770,11 @@ public partial class MainPage : ContentPage string.Equals(m, "here", StringComparison.OrdinalIgnoreCase) || string.Equals(m, "everyone", StringComparison.OrdinalIgnoreCase)); + /// + /// Edit broadcast handler. Decrypts the new content, mutates the existing ChatMessage in + /// place, then rebuilds the matching Border's content via RebuildBubbleContent. No full + /// re-render — only this single bubble updates. + /// private void HandleMessageEdited(SocketEncryptedMessage payload) { if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return; @@ -659,6 +788,7 @@ public partial class MainPage : ContentPage MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message)); } + /// Tombstone handler. Flips IsDeleted on the ChatMessage and rebuilds the bubble to a "🗑 deleted" placeholder. private void HandleMessageDeleted(SocketMessageDeletedEvent payload) { if (!_messagesById.TryGetValue(payload.MessageId, out var message)) return; @@ -668,6 +798,10 @@ public partial class MainPage : ContentPage MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message)); } + /// + /// Updates the header typing label and starts (or resets) a 3-second auto-clear timer + /// per typer. Ignores typing events for channels the user isn't currently viewing. + /// private void HandleTyping(SocketTypingEvent payload) { if (payload.ChannelId != _currentChannelId) return; @@ -697,6 +831,10 @@ public partial class MainPage : ContentPage }, cts.Token); } + /// + /// EditHistory response handler. Decrypts every prior version, formats them with timestamps, + /// and dumps into a DisplayAlert. (A scrollable popup is a future polish.) + /// private void HandleEditHistory(SocketEditHistoryResponse response) { var entries = new List<(string Text, DateTime At)>(); @@ -735,6 +873,11 @@ public partial class MainPage : ContentPage }); } + /// + /// RSA-decrypt the payload using the user's private key, then JSON-parse to ChatMessageContent. + /// Falls back to treating the plaintext as raw text if JSON parse fails (compat with messages + /// sent before ChatMessageContent existed). + /// private bool TryDecryptAndParseContent(SocketEncryptedMessage payload, out ChatMessageContent content) { content = new ChatMessageContent(); @@ -764,12 +907,18 @@ public partial class MainPage : ContentPage return true; } + /// MAUI hook: cleanly closes the WebSocket when the user navigates away from MainPage. protected override void OnDisappearing() { _socket.Disconnect(); base.OnDisappearing(); } + /// + /// Rebuilds the sidebar from _channels. Groups by Group label, applies the selected-channel + /// highlight, adds the 🔒 suffix for read-only channels, and shows the 🔔N badge for channels + /// with unread mentions. Right-click context menu is attached per row. + /// private void RenderChannelList() { SidebarList.Children.Clear(); @@ -826,6 +975,12 @@ public partial class MainPage : ContentPage } } + /// + /// Channel button click handler. Clears stale UI state (typing indicator, reply/edit + /// context), then either swaps to the RTC view (for Voice) or shows messages + requests + /// history if we haven't seen it yet. Always rebuilds the sidebar at the end so the + /// selected-channel highlight updates. + /// private void OnChannelSelected(ChannelItem channel) { _currentChannelId = channel.ChannelId; @@ -869,6 +1024,10 @@ public partial class MainPage : ContentPage }); } + /// + /// Enables/disables MessageEntry+SendButton based on the server-resolved CanPost. Updates + /// the placeholder so the user knows why the input is greyed out (in read-only channels). + /// private void UpdateInputForCurrentChannel() { var channel = _channels.FirstOrDefault(c => c.ChannelId == _currentChannelId); @@ -882,6 +1041,7 @@ public partial class MainPage : ContentPage : "This channel is read-only — you don't have permission to post here."; } + /// Cancels every pending typing-clear timer and hides the typing label. Called on channel switch. private void ClearTypingIndicator() { foreach (var cts in _typingClearTimers.Values) cts.Cancel(); @@ -893,6 +1053,7 @@ public partial class MainPage : ContentPage }); } + /// Full re-render of the current channel's scrollback. Called on channel switch or when history first arrives. private void RenderCurrentChannelMessages() { MessagesLayout.Children.Clear(); @@ -904,6 +1065,12 @@ public partial class MainPage : ContentPage private void SwapView_OnClicked(object? sender, EventArgs e) { } + /// + /// Renders ONE message as a Border bubble and appends it to MessagesLayout. Bubble alignment + /// (left vs right) is based on isOwn. mentionsMe adds a 2px violet stroke so the user can + /// spot pings while scrolling. Bubble is registered in _messageBubbles for later in-place + /// updates (edit, delete). + /// private async void RenderSingleMessage(ChatMessage message) { bool isOwn = message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase); @@ -929,11 +1096,18 @@ public partial class MainPage : ContentPage await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, animated: true); } + /// O(1) bubble update — swaps the Border's Content for a freshly-built one. Called by edit/delete handlers. private void RebuildBubbleContent(Border bubble, ChatMessage message) { bubble.Content = BuildBubbleContent(message); } + /// + /// Composes a single bubble's content. Order: tombstone short-circuit → optional reply + /// quote (tap = scroll to original) → sender label → MarkdownHelper.Render body → + /// inline attachment (image or file card) → URL embeds → footer (timestamp + (edited)). + /// Right-click menu is wired at the bubble level (in RenderSingleMessage), not here. + /// private View BuildBubbleContent(ChatMessage message) { if (message.IsDeleted) @@ -1039,6 +1213,11 @@ public partial class MainPage : ContentPage return stack; } + /// + /// Recursively walks an embed view tree (built by EmbedHelper) and binds the tap gesture on + /// any "Jump to message" label to ScrollToMessage. EmbedHelper can't do this itself because + /// it doesn't know about the message-bubble dictionary that ScrollToMessage uses. + /// private void WireJumpLinks(View view) { if (view is Label lbl) @@ -1068,6 +1247,7 @@ public partial class MainPage : ContentPage } } + /// Scrolls the bubble for the given messageId into view. No-op if the bubble isn't in this channel's render set. private void ScrollToMessage(string messageId) { if (!_messageBubbles.TryGetValue(messageId, out var bubble)) return; @@ -1075,12 +1255,18 @@ public partial class MainPage : ContentPage await MessagesScrollView.ScrollToAsync(bubble, ScrollToPosition.Start, animated: true)); } + /// Fires SendGetEditHistory. Server replies asynchronously with SocketEditHistoryResponse → HandleEditHistory. private void RequestEditHistory(ChatMessage message) { if (string.IsNullOrWhiteSpace(message.MessageId)) return; _socket.SendGetEditHistory(message.MessageId, _currentChannelId ?? string.Empty); } + /// + /// HybridWebView callback when JS does HybridWebView.SendRawMessage(string). The JS RTC + /// engine fires "rtc_page_ready" when it's done initialising — we use that to push the + /// current username/channelId into JS context so subsequent SDP exchanges work. + /// private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e) { if (e.Message == "rtc_page_ready") @@ -1091,6 +1277,7 @@ public partial class MainPage : ContentPage SafeSendRawToWebView($"JS → C#: {e.Message}"); } + /// Sends a message into the WebView with the call dispatched onto the UI thread. Swallows + logs send errors. private void SafeSendRawToWebView(string message) { MainThread.BeginInvokeOnMainThread(() => @@ -1100,6 +1287,11 @@ public partial class MainPage : ContentPage }); } + /// + /// Client-side message model. Decrypted ChatMessageContent plus server-supplied metadata + /// (MessageId, IsEdited, IsDeleted) and client-side state (Timestamp, mutable Text/IsEdited + /// for edit handling). + /// public class ChatMessage { public string MessageId { get; set; } = string.Empty; @@ -1117,6 +1309,7 @@ public partial class MainPage : ContentPage public bool IsDeleted { get; set; } } + /// Button subclass that carries the channel's Type/Group, used so the sidebar can re-render with metadata. public class ChannelButton : Button { public ChannelType Type { get; set; } diff --git a/RelayClient/Services/RelaySocketClient.cs b/RelayClient/Services/RelaySocketClient.cs index a87aa94..15c8028 100644 --- a/RelayClient/Services/RelaySocketClient.cs +++ b/RelayClient/Services/RelaySocketClient.cs @@ -5,24 +5,69 @@ using WebSocketSharp; namespace RelayClient.Services; +/// +/// The client-side WebSocket transport. Mirrors ChatSocketBehavior on the server. +/// +/// Sending: typed helpers (SendGetHistory, SendRtcJoinChannel, SendEditMessage, …) build the +/// appropriate WsControlMessage or SocketEncryptedMessage and route through SendRaw. SendRaw +/// always uses synchronous _socket.Send because WebSocketSharp's SendAsync calls +/// Action.BeginInvoke internally, which throws PlatformNotSupportedException on .NET 5+. +/// Callers that need non-blocking sends (e.g. MainPage.SendMessage for image attachments) +/// wrap the call in Task.Run. +/// +/// Receiving: OnMessage peeks the JSON. If it has an "Event" property → WsEventMessage (acks). +/// If it has a "Type" property → SignalType discriminator, deserialise into the right Socket* +/// type, fire the matching C# event. MainPage subscribes to these events. +/// +/// Connect order matters: the first frame after the handshake is Authenticate (so the server +/// can verify the Core-issued token), then RegisterKey (so the server has our public key +/// before any encrypted message arrives), then GetServerKey + GetChannels. +/// public sealed class RelaySocketClient { + /// Username this socket is authenticated as. Captured at construction. private readonly string _username; + + /// The underlying WebSocketSharp client. Owned (constructed) by this class. private readonly WebSocket _socket; + /// + /// The server's RSA public key, cached after the first GetServerKey response. + /// MainPage reads this to encrypt outbound chat payloads. + /// public string? ServerPublicKey { get; private set; } + /// Fires for every raw incoming text frame. Mostly used for debug logging. public event Action? RawMessageReceived; + + /// Fires when the server pushes a fresh channel list (initial connect or after CRUD). public event Action? ChannelListReceived; + + /// Fires for newly-arrived chat messages (SignalType.EncryptedChat). public event Action? EncryptedChatReceived; + + /// Fires when an existing message is edited by its author (SignalType.MessageEdited). public event Action? MessageEdited; + + /// Fires when a message is deleted (SignalType.MessageDeleted). public event Action? MessageDeleted; + + /// Fires when another user is typing in a channel. public event Action? TypingReceived; + + /// Fires in response to a SendGetEditHistory request. public event Action? EditHistoryReceived; + + /// Fires for encrypted RTC SDP/ICE signals — RtcBridgeService forwards into the JS engine. public event Action? EncryptedRtcSignalReceived; + + /// Fires once when the server's public key arrives. Mainly used by tests; production reads ServerPublicKey directly. public event Action? ServerPublicKeyReceived; + + /// Diagnostic logger. MainPage subscribes Console.WriteLine here. public event Action? Log; + /// Default URL points at localhost dev server. Production passes a remote URL. public RelaySocketClient(string username, string url = "ws://127.0.0.1:5001/") { _username = username; @@ -30,6 +75,12 @@ public sealed class RelaySocketClient _socket.OnMessage += OnMessage; } + /// + /// Opens the WebSocket and fires the four-step boot handshake IN ORDER: + /// Authenticate → RegisterKey → GetServerKey → GetChannels. Order matters because the + /// server uses RegisterKey to populate its session→username map (needed for permission + /// checks on subsequent messages). + /// public void Connect() { _socket.Connect(); @@ -42,6 +93,7 @@ public sealed class RelaySocketClient SendControlMessage(new WsControlMessage { Action = WsAction.GetChannels }); } + /// Detaches the message handler and closes the socket. Called from MainPage.OnDisappearing. public void Disconnect() { _socket.OnMessage -= OnMessage; @@ -49,24 +101,31 @@ public sealed class RelaySocketClient _socket.Close(); } + /// Generic control-plane send. Serialises the WsControlMessage to JSON and ships it. public void SendControlMessage(WsControlMessage message) => SendRaw(JsonSerializer.Serialize(message)); + /// Request the message history for a channel. Server streams it back as individual EncryptedChat frames. public void SendGetHistory(string channelId) => SendControlMessage(new WsControlMessage { Action = WsAction.GetHistory, Username = _username, ChannelId = channelId }); + /// Tell the server we've joined a voice channel. Fires Speak permission check server-side. public void SendRtcJoinChannel(string channelId) => SendControlMessage(new WsControlMessage { Action = WsAction.RtcJoin, Username = _username, ChannelId = channelId }); + /// Tell the server we've left the voice channel. Idempotent server-side. public void SendRtcLeaveChannel(string channelId) => SendControlMessage(new WsControlMessage { Action = WsAction.RtcLeave, Username = _username, ChannelId = channelId }); + /// Notify channel peers that we're typing. Server broadcasts a SocketTypingEvent to everyone but us. public void SendTyping(string channelId) => SendControlMessage(new WsControlMessage { Action = WsAction.SendTyping, Username = _username, ChannelId = channelId }); + /// Request all historical versions of a message. Server replies with SocketEditHistoryResponse. public void SendGetEditHistory(string messageId, string channelId) => SendControlMessage(new WsControlMessage { Action = WsAction.GetEditHistory, Username = _username, MessageId = messageId, ChannelId = channelId }); + /// Create a new channel. Permission-gated server-side; on success the server broadcasts a fresh channel list. public void SendCreateChannel(string name, ChannelType type, string group = "") => SendControlMessage(new WsControlMessage { @@ -76,9 +135,14 @@ public sealed class RelaySocketClient ChannelGroup = group }); + /// Soft-delete a channel. Permission-gated server-side. public void SendDeleteChannel(string channelId) => SendControlMessage(new WsControlMessage { Action = WsAction.DeleteChannel, ChannelId = channelId }); + /// + /// Send an edit for an existing message. Caller is responsible for encrypting the new + /// content (with the server's public key) before calling — same encryption shape as a new send. + /// public void SendEditMessage(string messageId, string channelId, EncryptedPayload encrypted) => SendJson(new SocketEncryptedMessage { @@ -88,6 +152,7 @@ public sealed class RelaySocketClient Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey }); + /// Request soft-delete of one of our own messages. Server checks ownership before honoring. public void SendDeleteMessage(string messageId, string channelId) => SendJson(new SocketEncryptedMessage { @@ -95,6 +160,11 @@ public sealed class RelaySocketClient SenderUsername = _username, ChannelId = channelId }); + /// + /// The single send pinch point. Synchronous (WebSocketSharp's SendAsync is broken on .NET 5+ + /// due to Action.BeginInvoke). All exceptions are logged AND rethrown so the calling + /// Task.Run can surface them to the user via DisplayAlert. + /// public void SendRaw(string message) { if (_socket.ReadyState != WebSocketState.Open) @@ -114,8 +184,15 @@ public sealed class RelaySocketClient } } + /// Convenience: JSON-serialise any payload and ship it. Used for all SocketEncryptedMessage and WsControlMessage sends. public void SendJson(T payload) => SendRaw(JsonSerializer.Serialize(payload)); + /// + /// WebSocketSharp callback for every incoming text frame. Peeks the JSON to decide whether + /// it's a control-plane ack (Event property) or data-plane message (Type property), then + /// fires the matching public C# event. Exceptions are caught locally so a malformed frame + /// can't drop the connection. + /// private void OnMessage(object? sender, MessageEventArgs e) { RawMessageReceived?.Invoke(e.Data); diff --git a/RelayClient/Services/RtcBridgeService.cs b/RelayClient/Services/RtcBridgeService.cs index 2a4687f..9bcfaa1 100644 --- a/RelayClient/Services/RtcBridgeService.cs +++ b/RelayClient/Services/RtcBridgeService.cs @@ -6,14 +6,39 @@ using RelayShared.Services; namespace RelayClient.Services; +/// +/// The bridge between the C# WebSocket pipe and the JavaScript WebRTC engine +/// running inside the HybridWebView (which is shown when a Voice channel is open). +/// +/// Outbound (JS → C# → server): the WebView JS calls into C# via SendRtcSignal(json). +/// We deserialise to RtcSignalMessage, encrypt with the server's public key, wrap in +/// SocketRtcSignalMessage, and send through the WebSocket. +/// +/// Inbound (server → C# → JS): the WebSocket fires EncryptedRtcSignalReceived. MainPage +/// hands it to HandleIncomingRtcSignalAsync, which decrypts with the user's private key +/// and calls back into JS via hybridWebView.InvokeJavaScriptAsync("testIndex", …). +/// +/// JoinRtcChannel / LeaveRtcChannel just send WsAction control messages; presence tracking +/// happens server-side in RtcChannelPresenceService. +/// public sealed class RtcBridgeService { + /// The currently-signed-in username. Stamped onto outgoing RTC signals. private readonly string _username; + + /// The shared WebSocket to RelayServer. Outbound RTC signals ride on this. private readonly RelaySocketClient _socket; + + /// The MAUI HybridWebView that hosts the JS WebRTC engine. We push JS calls into it. private readonly HybridWebView _hybridWebView; + + /// Lazy view into MainPage._currentChannelId so we always have the current voice channel. private readonly Func _getCurrentChannelId; + + /// Diagnostic logger that surfaces messages back to the WebView UI. Used for status/error reporting. private readonly Action _sendRawToWebView; + /// Captures collaborators. MainPage constructs this once and never replaces it. public RtcBridgeService(string username, RelaySocketClient socket, HybridWebView hybridWebView, Func getCurrentChannelId, Action sendRawToWebView) { @@ -24,6 +49,7 @@ public sealed class RtcBridgeService _sendRawToWebView = sendRawToWebView; } + /// Sends RtcJoin for the currently-selected channel. Server-side, this triggers the Speak permission check and presence registration. public Task JoinRtcChannel() { var channelId = _getCurrentChannelId(); @@ -35,6 +61,7 @@ public sealed class RtcBridgeService return Task.CompletedTask; } + /// Sends RtcLeave for the currently-selected channel. Clears server-side voice presence so peers stop seeing us. public void LeaveRtcChannel() { var channelId = _getCurrentChannelId(); @@ -45,6 +72,13 @@ public sealed class RtcBridgeService _socket.SendRtcLeaveChannel(channelId); } + /// + /// Called from JavaScript (via the HybridWebView bridge) when the WebRTC engine wants to + /// send an SDP offer/answer or ICE candidate to other peers. Parses the JSON, fills in + /// missing ChannelId/From, encrypts with the server's public key, ships as + /// SocketRtcSignalMessage. The server then forwards it (re-encrypted per-recipient) to + /// every other session in the same voice channel. + /// public void SendRtcSignal(string json) { if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey)) @@ -105,6 +139,7 @@ public sealed class RtcBridgeService } } + /// JS bridge: returns the current voice-channel roster as JSON. Hits ServerAPI's REST endpoint, not the WebSocket. public async Task GetRtcParticipants() { var channelId = _getCurrentChannelId(); @@ -116,6 +151,11 @@ public sealed class RtcBridgeService return JsonSerializer.Serialize(participants ?? []); } + /// + /// MainPage hands incoming SocketRtcSignalMessage frames here. Filters out our own + /// frames, validates the channel scope, decrypts with the user's private key, parses to + /// RtcSignalMessage, then pushes into the JS RTC engine via SendRtcSignalToJsAsync. + /// public async Task HandleIncomingRtcSignalAsync(SocketRtcSignalMessage payload) { // _sendRawToWebView("HandleIncomingRtcSignal called"); @@ -187,6 +227,10 @@ public sealed class RtcBridgeService await SendRtcSignalToJsAsync(rtcSignal); } + /// + /// Pushes the current username and channelId into JS globals (window.setUsername, window.setChannelId). + /// Called whenever the user switches voice channels OR the JS engine reports rtc_page_ready. + /// public Task PushRtcContextToJsAsync() { MainThread.BeginInvokeOnMainThread(async () => @@ -201,6 +245,11 @@ public sealed class RtcBridgeService return Task.CompletedTask; } + /// + /// Final hop: hands a decrypted RtcSignalMessage off to the JS engine via + /// hybridWebView.InvokeJavaScriptAsync("testIndex", …). SDP strings have their newlines + /// escaped as "(rn)" because the JSON marshalling otherwise breaks them. + /// private Task SendRtcSignalToJsAsync(RtcSignalMessage data) { if (data.Type == "rtc_offer" || data.Type == "rtc_answer") diff --git a/RelayServer/Models/Chat/ChannelMessageEdits.cs b/RelayServer/Models/Chat/ChannelMessageEdits.cs index 7f70f99..501465a 100644 --- a/RelayServer/Models/Chat/ChannelMessageEdits.cs +++ b/RelayServer/Models/Chat/ChannelMessageEdits.cs @@ -2,11 +2,24 @@ using SurrealDb.Net.Models; namespace RelayServer.Models; +/// +/// Surreal record for the `channel_message_edits` table. One row per historical version of +/// an edited message — written by HandleEditMessage BEFORE overwriting the live row. +/// +/// Encrypted with the channel AES key (same as ChannelMessages), so HandleGetEditHistory +/// can decrypt + re-encrypt per requester. +/// public class ChannelMessageEdits : Record { + /// "channel_messages:abc" — which live message this version belonged to. public required string MessageId { get; set; } + + /// Base64 AES-GCM ciphertext of the JSON-serialised previous ChatMessageContent. public required string CipherText { get; set; } + public required string Nonce { get; set; } public required string Tag { get; set; } + + /// When this version was the current text (i.e. when it was replaced). public required DateTime EditedAt { get; set; } } diff --git a/RelayServer/Models/Chat/ChannelMessages.cs b/RelayServer/Models/Chat/ChannelMessages.cs index bfa69dd..411662f 100644 --- a/RelayServer/Models/Chat/ChannelMessages.cs +++ b/RelayServer/Models/Chat/ChannelMessages.cs @@ -2,14 +2,36 @@ using SurrealDb.Net.Models; namespace RelayServer.Models; +/// +/// Surreal record for the `channel_messages` table. One row per message. +/// +/// Encryption: CipherText/Nonce/Tag use the channel AES key (ChannelDbKey), NOT any user's +/// RSA keypair. This means the server can decrypt for history queries; the per-recipient +/// RSA wrapping happens at delivery time in DeliverToServerMembers. +/// public class ChannelMessages : Record { + /// "channels:xyz" — which channel this belongs to. public required string ChannelId { get; set; } + + /// "users:keeper317" — who wrote it. Lowercased to match CoreClientService's id format. public required string SenderUserId { get; set; } + + /// Base64 AES-GCM ciphertext of the JSON-serialised ChatMessageContent. public required string CipherText { get; set; } + + /// Base64 AES-GCM 96-bit nonce. Different every message. public required string Nonce { get; set; } + + /// Base64 AES-GCM 128-bit authentication tag. public required string Tag { get; set; } + + /// UTC timestamp of original send. Drives history ordering. public required DateTime CreatedAt { get; set; } + + /// UTC timestamp of last edit. Null = never edited. Drives the (edited) bubble footer. public DateTime? EditedAt { get; set; } + + /// Soft-delete flag. Tombstones in history responses; bubbles show "deleted" placeholder. public bool IsDeleted { get; set; } } diff --git a/RelayServer/Models/Chat/Channels.cs b/RelayServer/Models/Chat/Channels.cs index 5abf9e9..c9a8a9b 100644 --- a/RelayServer/Models/Chat/Channels.cs +++ b/RelayServer/Models/Chat/Channels.cs @@ -3,13 +3,38 @@ using RelayShared.Services; namespace RelayServer.Models; +/// +/// Surreal record for the `channels` table. One row per channel. +/// +/// Lifecycle: created by HandleCreateChannel (or seeded by ServerBootstrapService at boot). +/// Soft-deleted by HandleDeleteChannel (IsDeleted flipped, row stays for audit). +/// public class Channels : Record { + /// Sidebar display name. Lowercased and dash-separated for new channels. public required string Name { get; set; } + + /// Creation timestamp. Drives sidebar sort order. public required DateTime CreatedAt { get; set; } + + /// Drives client rendering and server routing — Text/Voice/File/Forum/Stage. public ChannelType Type { get; set; } = ChannelType.Text; + + /// Sidebar category header (e.g. "General"). Empty means default group. public string Group { get; set; } = string.Empty; + + /// + /// True for announcement-style channels (#welcome, #files). Non-admins are blocked from + /// posting via PermissionService.CanSendMessagesAsync. + /// public bool IsReadOnly { get; set; } + + /// Soft-delete flag. Filtered out of channel-list builds in BuildChannelListForUser. public bool IsDeleted { get; set; } + + /// + /// Surreal record id of a File channel ("channels:xyz"). When set, ChatSocketBehavior's + /// MirrorAttachmentIfNeeded auto-copies non-gif attachments into the linked channel. + /// public string? LinkedFileChannelId { get; set; } } diff --git a/RelayServer/Models/Crypto/ClientPublicKeys.cs b/RelayServer/Models/Crypto/ClientPublicKeys.cs index 04c2c21..b3f33c6 100644 --- a/RelayServer/Models/Crypto/ClientPublicKeys.cs +++ b/RelayServer/Models/Crypto/ClientPublicKeys.cs @@ -1,11 +1,26 @@ -using SurrealDb.Net.Models; +using SurrealDb.Net.Models; namespace RelayServer.Models; +/// +/// Surreal record for the `client_public_keys` table. Stores the RSA public key each user +/// has registered. Written by HandleRegisterKey, read by DeliverToServerMembers and history +/// fetches to encrypt outbound messages per recipient. +/// +/// When a client reinstalls and regenerates a keypair, the existing row is updated rather +/// than duplicated (ClientKeyService.RegisterOrUpdateKeyAsync). +/// public class ClientPublicKeys : Record { + /// Mixed-case username as the user registered it. Used as the lookup key. public required string Username { get; set; } + + /// Base64 SubjectPublicKeyInfo (DER) of the user's RSA public key. public required string PublicKey { get; set; } + + /// When the user first registered. public required DateTime CreatedAt { get; set; } + + /// When the key was last updated (key rotation, reinstall). public required DateTime UpdatedAt { get; set; } -} \ No newline at end of file +} diff --git a/RelayServer/Models/Crypto/ServerEncryptionKeys.cs b/RelayServer/Models/Crypto/ServerEncryptionKeys.cs index 1a3d2fa..7c5d6fc 100644 --- a/RelayServer/Models/Crypto/ServerEncryptionKeys.cs +++ b/RelayServer/Models/Crypto/ServerEncryptionKeys.cs @@ -2,11 +2,28 @@ using SurrealDb.Net.Models; namespace RelayServer.Models; +/// +/// Surreal record for the `server_encryption_keys` table. Stores both: +/// - The server's RSA keypair (for receiving encrypted client→server payloads). +/// - The single AES-256 key used to encrypt channel_messages at rest. +/// +/// Generated once on first boot by ServerBootstrapService. Loaded into static fields on +/// ChatSocketBehavior at boot so handlers can use them without a DB round-trip. +/// public class ServerEncryptionKeys : Record { + /// Base64 AES-256 key used by ChannelCryptoService for at-rest message encryption. public required string KeyBase64 { get; set; } + + /// Base64 SubjectPublicKeyInfo of the server's RSA public key. Sent to clients on GetServerKey. public required string PublicKey { get; set; } + + /// Base64 PKCS8 of the server's RSA private key. Never leaves the server. public required string PrivateKey { get; set; } + + /// When the keys were generated. public required DateTime CreatedAt { get; set; } + + /// When the keys were last rotated. Currently same as CreatedAt — rotation isn't implemented. public required DateTime UpdatedAt { get; set; } -} \ No newline at end of file +} diff --git a/RelayServer/Models/Server/ChannelPermissions.cs b/RelayServer/Models/Server/ChannelPermissions.cs index 3b2e883..1935cbd 100644 --- a/RelayServer/Models/Server/ChannelPermissions.cs +++ b/RelayServer/Models/Server/ChannelPermissions.cs @@ -2,10 +2,24 @@ using SurrealDb.Net.Models; namespace RelayServer.Models; +/// +/// Surreal record for the `channel_permissions` table. Per-(channel, role) override of a +/// role's base permissions. +/// +/// Allow and Deny are independent masks (NOT a tri-state). Deny wins over Allow when both +/// have the same flag set. Bits not set in either fall through to the role's base permissions. +/// public class ChannelPermissions : Record { + /// "channels:xyz" — which channel this override applies in. public required string ChannelId { get; set; } + + /// "roles:abc" — which role this override applies to. public required string RoleId { get; set; } + + /// Permissions explicitly granted here (overrides "role doesn't have it" for this channel). public PermissionFlags Allow { get; set; } + + /// Permissions explicitly denied here. Wins over Allow. public PermissionFlags Deny { get; set; } } diff --git a/RelayServer/Models/Server/Roles.cs b/RelayServer/Models/Server/Roles.cs index edb7087..0a8d84c 100644 --- a/RelayServer/Models/Server/Roles.cs +++ b/RelayServer/Models/Server/Roles.cs @@ -2,6 +2,18 @@ using SurrealDb.Net.Models; namespace RelayServer.Models; +/// +/// The permission bitfield. The whole permission model is just: +/// +/// ServerMembers.IsOwner = true → unconditional Administrator +/// roles.Permissions has Administrator flag → unconditional everything +/// channel_permissions.Deny has a specific flag → that permission denied here +/// channel_permissions.Allow has a specific flag → that permission allowed here +/// roles.Permissions has the flag → fallback (channel-independent) +/// +/// PermissionService.HasPermissionAsync walks that ladder in order. See that class for the +/// authoritative implementation. +/// [Flags] public enum PermissionFlags { @@ -18,11 +30,21 @@ public enum PermissionFlags DeleteChannel = 1 << 9 // Delete a channel } +/// +/// Surreal record for the `roles` table. Defines a named permission bundle that can be +/// assigned to users via UserRoles. +/// public class Roles : Record { + /// Display name ("Admin", "Moderator", "Member"). public required string Name { get; set; } + + /// Base permission bitfield. Channel-level overrides in ChannelPermissions can add or remove. public required PermissionFlags Permissions { get; set; } + + /// When the role was seeded. public required DateTime CreatedAt { get; set; } + /// Tie-breaker for future multi-role-per-user scenarios. Lower = higher priority. Not used by the current ladder. public int Priority { get; set; } } diff --git a/RelayServer/Models/Server/ServerMembers.cs b/RelayServer/Models/Server/ServerMembers.cs index 1a52c53..9dac672 100644 --- a/RelayServer/Models/Server/ServerMembers.cs +++ b/RelayServer/Models/Server/ServerMembers.cs @@ -2,9 +2,22 @@ using SurrealDb.Net.Models; namespace RelayServer.Models; +/// +/// Surreal record for the `server_members` table. Membership list. +/// Drives DeliverToServerMembers (the fan-out target list for every chat message) and the +/// authoritative ownership flag for PermissionService. +/// public class ServerMembers : Record { + /// "users:keeper317" — references the Core users table by name convention. public required string UserId { get; set; } + + /// When the user was added to this server. public required DateTime JoinedAt { get; set; } + + /// + /// Authoritative owner flag. Owner gets unconditional Administrator via + /// PermissionService.IsServerOwnerAsync, independent of role assignments. + /// public bool IsOwner { get; set; } -} \ No newline at end of file +} diff --git a/RelayServer/Models/Server/Servers.cs b/RelayServer/Models/Server/Servers.cs index df4a084..a91d216 100644 --- a/RelayServer/Models/Server/Servers.cs +++ b/RelayServer/Models/Server/Servers.cs @@ -2,9 +2,18 @@ using SurrealDb.Net.Models; namespace RelayServer.Models; +/// +/// Surreal record for the `servers` table. Currently single-row (one server per deployment), +/// but the schema supports multi-server in the future. +/// public class Servers : Record { + /// Display name (currently "Test Server" from bootstrap). public required string Name { get; set; } + + /// "users:keeper317" — the owner. Mirrored as IsOwner=true on the matching ServerMembers row. public required string OwnerUserId { get; set; } + + /// Server creation timestamp. public required DateTime CreatedAt { get; set; } -} \ No newline at end of file +} diff --git a/RelayServer/Models/Server/UserRoles.cs b/RelayServer/Models/Server/UserRoles.cs index 4b63a8f..c3c7244 100644 --- a/RelayServer/Models/Server/UserRoles.cs +++ b/RelayServer/Models/Server/UserRoles.cs @@ -2,9 +2,21 @@ using SurrealDb.Net.Models; namespace RelayServer.Models; +/// +/// Surreal record for the `user_roles` table. Join table linking users to roles. +/// +/// Invariant: ServerBootstrapService.SetUserRoleAsync guarantees exactly one row per user. +/// Multi-role-per-user isn't currently supported by the permission ladder — adding it would +/// just be a matter of removing the bootstrap's "delete stale rows" step. +/// public class UserRoles : Record { + /// "users:keeper317" — the assignee. public required string UserId { get; set; } + + /// "roles:abc" — the role being granted. public required string RoleId { get; set; } + + /// When the assignment was made. public required DateTime AssignedAt { get; set; } } diff --git a/RelayServer/Program.cs b/RelayServer/Program.cs index 319548b..54dcf91 100644 --- a/RelayServer/Program.cs +++ b/RelayServer/Program.cs @@ -1,3 +1,23 @@ +// ============================================================================= +// RelayServer entrypoint. +// +// Boot sequence: +// 1. Connect to SurrealDB (port 8000) via SurrealService. +// 2. Wire static singletons onto ChatSocketBehavior (it's a WebSocketSharp +// WebSocketBehavior, so DI is impossible — fields are static). +// 3. Run ServerBootstrapService.InitializeAsync — seeds users, server, members, +// channels (welcome, general, files, voice-general), roles, role assignments, +// channel permission overrides, and encryption keys. Idempotent across reboots. +// 4. Start two listeners in parallel: +// - HTTP API on 127.0.0.1:5000 (RtcEndpoints — REST for RTC call orchestration) +// - WebSocket server on 127.0.0.1:5001 (ChatSocketBehavior — the chat/RTC-signal pipe) +// 5. Block on ConsoleCommandService.ShutdownTokenSource for graceful shutdown. +// +// Why two listeners? The HTTP API is used for one-shot RPC-style calls (e.g. "fetch +// the participant list for this voice channel"). The WebSocket is the persistent +// duplex pipe used for chat, typing, presence, encrypted RTC signalling. +// ============================================================================= + using RelayServer.Endpoints; using RelayServer.Services.Chat; using RelayServer.Services.Core; diff --git a/RelayServer/Services/Chat/ChannelCryptoService.cs b/RelayServer/Services/Chat/ChannelCryptoService.cs index 1df526e..361ed0a 100644 --- a/RelayServer/Services/Chat/ChannelCryptoService.cs +++ b/RelayServer/Services/Chat/ChannelCryptoService.cs @@ -3,6 +3,25 @@ using System.Text; namespace RelayServer.Services.Chat; +/// +/// 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 +/// public sealed class ChannelCryptoService { public string GenerateKey() diff --git a/RelayServer/Services/Chat/ChatSocketBehavior.cs b/RelayServer/Services/Chat/ChatSocketBehavior.cs index 4b3aafa..2c1cbcb 100644 --- a/RelayServer/Services/Chat/ChatSocketBehavior.cs +++ b/RelayServer/Services/Chat/ChatSocketBehavior.cs @@ -12,19 +12,68 @@ using RelayShared.Services; namespace RelayServer.Services.Chat; /// -/// 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). /// public class ChatSocketBehavior : WebSocketBehavior { + /// Reads/writes the client_public_keys table. Wired by Program.cs at boot. public static ClientKeyService? ClientKeyService { get; set; } + + /// The permission ladder evaluator. Wired by Program.cs at boot. public static PermissionService? PermissionService { get; set; } + + /// Base64 RSA public key — clients use this to encrypt outbound payloads to the server. public static string? ServerPublicKey { get; set; } + + /// Base64 RSA private key — used to decrypt inbound payloads. Never leaves the server. public static string? ServerPrivateKey { get; set; } + + /// Base64 AES-256 key for at-rest encryption of channel_messages.CipherText rows. public static string? ChannelDbKey { get; set; } + + /// AES-GCM-only encryption for stored messages. Wired by Program.cs at boot. public static ChannelCryptoService? ChannelCryptoService { get; set; } + + /// The SurrealDB connection. Wired by Program.cs at boot. public static SurrealDb.Net.SurrealDbClient? Db { get; set; } + /// + /// 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. + /// protected override void OnMessage(MessageEventArgs e) { var msg = e.Data; @@ -62,6 +111,7 @@ public class ChatSocketBehavior : WebSocketBehavior } } + /// Switches on WsAction to the matching Handle* method. Pure routing — no I/O. private void DispatchControl(WsAction action, WsControlMessage c) { switch (action) @@ -81,6 +131,14 @@ public class ChatSocketBehavior : WebSocketBehavior } } + /// + /// 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. + /// 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 })); } + /// + /// 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. + /// 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 })); } + /// Sends the server's public RSA key. Called once per session right after RegisterKey. 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 })); } + /// + /// 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. + /// 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 })); } + /// + /// 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. + /// private void HandleGetHistory(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId)) @@ -194,6 +270,10 @@ public class ChatSocketBehavior : WebSocketBehavior } } + /// + /// 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. + /// 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}"); } + /// Clears the session's voice-channel presence. Idempotent — safe to call when not in a channel. 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}"); } + /// + /// 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. + /// private void HandleTyping(WsControlMessage c) { var senderUsername = ConnectedClientService.GetUsernameForSession(ID); @@ -244,6 +330,11 @@ public class ChatSocketBehavior : WebSocketBehavior } } + /// + /// 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. + /// private void HandleGetEditHistory(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.MessageId) || string.IsNullOrWhiteSpace(c.Username)) return; @@ -283,6 +374,10 @@ public class ChatSocketBehavior : WebSocketBehavior })); } + /// + /// 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). + /// private void HandleCreateChannel(WsControlMessage c) { var username = ConnectedClientService.GetUsernameForSession(ID); @@ -315,6 +410,10 @@ public class ChatSocketBehavior : WebSocketBehavior BroadcastChannelList(); } + /// + /// 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. + /// private void HandleDeleteChannel(WsControlMessage c) { var username = ConnectedClientService.GetUsernameForSession(ID); @@ -340,6 +439,11 @@ public class ChatSocketBehavior : WebSocketBehavior BroadcastChannelList(); } + /// + /// 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. + /// private void HandleEncryptedRtcSignal(string msg) { SocketRtcSignalMessage? payload; @@ -374,6 +478,10 @@ public class ChatSocketBehavior : WebSocketBehavior } } + /// + /// The main chat-message path. Permission gate → server-side decrypt → store with channel + /// key → DeliverToServerMembers (per-user re-encrypt + send) → MirrorAttachmentIfNeeded. + /// private void HandleEncryptedChatMessage(string msg) { SocketEncryptedMessage? payload; @@ -429,6 +537,11 @@ public class ChatSocketBehavior : WebSocketBehavior MirrorAttachmentIfNeeded(plainText, payload.SenderUsername, payload.ChannelId); } + /// + /// 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. + /// 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}"); } + /// + /// 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. + /// private void HandleEditMessage(string msg) { SocketEncryptedMessage? request; @@ -542,6 +660,11 @@ public class ChatSocketBehavior : WebSocketBehavior request.MessageId, SignalType.MessageEdited, isEdited: true); } + /// + /// 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. + /// private void HandleDeleteMessage(string msg) { SocketEncryptedMessage? request; @@ -587,6 +710,14 @@ public class ChatSocketBehavior : WebSocketBehavior } } + /// + /// 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. + /// private void DeliverToServerMembers( string plainText, string senderUsername, string channelId, string messageId, SignalType signalType, bool isEdited) @@ -619,6 +750,10 @@ public class ChatSocketBehavior : WebSocketBehavior } } + /// + /// 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. + /// private void BroadcastChannelList() { foreach (var member in GetServerMembersSync()) @@ -639,6 +774,10 @@ public class ChatSocketBehavior : WebSocketBehavior } } + /// + /// Resolves the channel list a specific user can see, with CanPost/CanManage flags filled + /// in. Visibility (ViewChannel) determines inclusion — denied channels are filtered out. + /// private List BuildChannelListForUser(string username) { var rawChannels = GetChannelsSync() @@ -679,6 +818,10 @@ public class ChatSocketBehavior : WebSocketBehavior return items; } + /// + /// 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. + /// protected override void OnClose(CloseEventArgs e) { ConnectedClientService.Unregister(ID); @@ -687,12 +830,19 @@ public class ChatSocketBehavior : WebSocketBehavior base.OnClose(e); } + /// WebSocketSharp callback for socket-level errors. Logged but non-fatal. 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 GetServerMembersSync() => Task.Run(async () => await Db!.Select("server_members")).GetAwaiter().GetResult().ToList(); + /// "users:keeper317" → "keeper317". Stored as Surreal record id, displayed as plain name. 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; } + /// SurrealDB's Id object → "table:recordId" string. Used for storing parent refs as strings in child rows. 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()}"; } + /// Guard: returns true if the DB and key service are both initialised. Logs and returns false otherwise. private bool EnsureCoreReady() { if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; } return true; } + /// Guard: returns true if encryption keys + channel crypto service are all set. Logs and returns false otherwise. private bool EnsureCryptoReady() { if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null) diff --git a/RelayServer/Services/Chat/ConnectedClientService.cs b/RelayServer/Services/Chat/ConnectedClientService.cs index 79ee253..551af53 100644 --- a/RelayServer/Services/Chat/ConnectedClientService.cs +++ b/RelayServer/Services/Chat/ConnectedClientService.cs @@ -2,12 +2,32 @@ using System.Collections.Concurrent; namespace RelayServer.Services.Chat; +/// +/// 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. +/// public static class ConnectedClientService { private static readonly ConcurrentDictionary SessionToUsername = new(); private static readonly ConcurrentDictionary> UsernameToSessions = new(StringComparer.OrdinalIgnoreCase); + /// + /// 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. + /// 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); } + /// + /// Removes a session from both mappings. Called from OnClose. Idempotent — calling for + /// a session that's already gone is a no-op. + /// public static void Unregister(string sessionId) { if (SessionToUsername.TryRemove(sessionId, out var username)) RemoveSessionFromUsername(sessionId, username); } + /// + /// 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. + /// public static IReadOnlyCollection GetSessionsForUser(string username) { if (UsernameToSessions.TryGetValue(username, out var sessions)) @@ -43,11 +72,19 @@ public static class ConnectedClientService return Array.Empty(); } + /// + /// 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. + /// public static string? GetUsernameForSession(string sessionId) { return SessionToUsername.TryGetValue(sessionId, out var u) ? u : null; } + /// + /// 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). + /// private static void RemoveSessionFromUsername(string sessionId, string username) { if (!UsernameToSessions.TryGetValue(username, out var sessions)) diff --git a/RelayServer/Services/Core/ServerBootstrapService.cs b/RelayServer/Services/Core/ServerBootstrapService.cs index 84ed33c..d494d19 100644 --- a/RelayServer/Services/Core/ServerBootstrapService.cs +++ b/RelayServer/Services/Core/ServerBootstrapService.cs @@ -7,6 +7,28 @@ using SurrealDb.Net; namespace RelayServer.Services.Core; +/// +/// 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. +/// public sealed class ServerBootstrapService { private readonly SurrealDbClient _db; diff --git a/RelayServer/Services/Crypto/E2EeHelper.cs b/RelayServer/Services/Crypto/E2EeHelper.cs index a26e64e..db806b1 100644 --- a/RelayServer/Services/Crypto/E2EeHelper.cs +++ b/RelayServer/Services/Crypto/E2EeHelper.cs @@ -3,6 +3,26 @@ using System.Text; namespace RelayServer.Services.Crypto; +/// +/// 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. +/// public static class E2EeHelper { public static (string publicKey, string privateKey) GenerateRsaKeyPair() diff --git a/RelayServer/Services/Data/PermissionService.cs b/RelayServer/Services/Data/PermissionService.cs index cc7cddc..503828f 100644 --- a/RelayServer/Services/Data/PermissionService.cs +++ b/RelayServer/Services/Data/PermissionService.cs @@ -12,6 +12,10 @@ public sealed class PermissionService _db = db; } + /// + /// 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. + /// public async Task CanSendMessagesAsync(string username, string channelId) { if (await IsOwnerOrAdminAsync(username)) @@ -23,39 +27,57 @@ public sealed class PermissionService return await HasPermissionAsync(username, channelId, PermissionFlags.SendMessages); } + /// Server-wide ability to create channels. Gates the "+" button on the sidebar. public async Task CanManageChannelsAsync(string username) => await IsOwnerOrAdminAsync(username) || await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels); + /// Per-channel ability to delete/edit OTHER people's messages. Authors can always delete their own. public async Task CanManageMessagesAsync(string username, string channelId) => await IsOwnerOrAdminAsync(username) || await HasPermissionAsync(username, channelId, PermissionFlags.ManageMessages); + /// Convenience query — exposes the owner-or-admin shortcut as a public method. public async Task IsAdministratorAsync(string username) => await IsOwnerOrAdminAsync(username); + /// + /// "Visibility" — default-allow. Only blocks if a channel-level Deny mask explicitly + /// removes ViewChannel for the user's role. Owners/admins bypass. + /// public async Task CanViewChannelAsync(string username, string channelId) { if (await IsOwnerOrAdminAsync(username)) return true; return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.ViewChannel); } + /// + /// Voice-channel Speak. Default-allow. Blocked by channel-level Deny. Used at RtcJoin + /// time so denied users can't even register voice presence. + /// public async Task CanSpeakAsync(string username, string channelId) { if (await IsOwnerOrAdminAsync(username)) return true; return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.Speak); } + /// Server-wide ability to delete channels. ManageChannels OR explicit DeleteChannel. public async Task CanDeleteChannelAsync(string username) => await IsOwnerOrAdminAsync(username) || await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) || await HasGlobalPermissionAsync(username, PermissionFlags.DeleteChannel); + /// Server-wide ability to edit channels. ManageChannels OR explicit EditChannel. public async Task CanEditChannelAsync(string username) => await IsOwnerOrAdminAsync(username) || await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) || await HasGlobalPermissionAsync(username, PermissionFlags.EditChannel); + /// + /// 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. + /// private async Task IsOwnerOrAdminAsync(string username) { if (await IsServerOwnerAsync(username)) @@ -65,6 +87,13 @@ public sealed class PermissionService return roles.Any(r => r.Permissions.HasFlag(PermissionFlags.Administrator)); } + /// + /// 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. + /// private async Task HasPermissionAsync( string username, string channelId, PermissionFlags flag) { @@ -86,6 +115,10 @@ public sealed class PermissionService return userRoles.Any(r => r.Permissions.HasFlag(flag)); } + /// + /// Server-wide (not channel-scoped) permission check. Used for things like ManageChannels + /// where there's no specific channel context. Admin flag short-circuits. + /// private async Task HasGlobalPermissionAsync(string username, PermissionFlags flag) { var roles = await GetUserRolesAsync(username); @@ -94,6 +127,10 @@ public sealed class PermissionService r.Permissions.HasFlag(flag)); } + /// + /// "Was this permission explicitly denied here?" — used by default-allow permissions + /// (ViewChannel, Speak) which only become restrictive when there's a Deny override. + /// private async Task 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)); } + /// + /// 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. + /// private async Task IsServerOwnerAsync(string username) { var userId = $"users:{username.ToLower()}"; @@ -116,6 +157,11 @@ public sealed class PermissionService m.IsOwner); } + /// + /// 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). + /// private async Task> GetUserRolesAsync(string username) { var userId = $"users:{username.ToLower()}"; @@ -134,12 +180,14 @@ public sealed class PermissionService .ToList(); } + /// Loads every channel_permissions override row for a channel (all roles, all flags). private async Task> GetChannelPermissionsAsync(string channelId) { var all = await _db.Select("channel_permissions"); return all.Where(cp => cp.ChannelId == channelId).ToList(); } + /// True if the channel's IsReadOnly flag is set on its row in the channels table. private async Task IsChannelReadOnlyAsync(string channelId) { var channels = await _db.Select("channels"); @@ -147,6 +195,7 @@ public sealed class PermissionService return channel?.IsReadOnly ?? false; } + /// SurrealDB's Id object → "table:id" string. Local copy because PermissionService isn't a friend of ChatSocketBehavior. private static string GetRecordIdString(object? id) { if (id is null) return string.Empty; diff --git a/RelayShared/Services/ChannelEnums.cs b/RelayShared/Services/ChannelEnums.cs index 73e1d00..fb618e6 100644 --- a/RelayShared/Services/ChannelEnums.cs +++ b/RelayShared/Services/ChannelEnums.cs @@ -1,10 +1,23 @@ -namespace RelayShared.Services; +namespace RelayShared.Services; +/// +/// Drives both rendering (sidebar icon, message view vs RTC view) and server-side routing +/// (file mirror destination must be ChannelType.File, RTC join only on Voice/Stage). +/// public enum ChannelType { - Text, //Default channel type, handles text, links, files*, all in a linear live chat format - Voice, //Used for general voice and video calls, utilizes WebRTC in its intended use - File, //File browser for connected text channels, used for browsing files rather than scrolling through text channel - Forum, //Specific forum posts, meant to keep conversations grouped and on topic while keeping all in an easy to find place - Stage //Used for announcements and presentations, voice/video call utilizing a modified WebRTC protocol through server -} \ No newline at end of file + /// Default. Linear chat: text, markdown, embeds, attachments. Sidebar prefix "#". + Text, + + /// WebRTC voice/video. Sidebar prefix 🔊. Selecting auto-swaps to the RTC view. + Voice, + + /// File browser. Receives auto-mirrored attachments from any Text channel that points here via LinkedFileChannelId. Sidebar prefix 📁. + File, + + /// Forum-style threaded posts. Sidebar prefix 📋. Currently a placeholder type. + Forum, + + /// Announcement-style voice. Modified WebRTC where most participants are listeners. Sidebar prefix 🎤. Placeholder. + Stage +} diff --git a/RelayShared/Services/ChannelTransmissions.cs b/RelayShared/Services/ChannelTransmissions.cs index 8ad49a2..6cf38aa 100644 --- a/RelayShared/Services/ChannelTransmissions.cs +++ b/RelayShared/Services/ChannelTransmissions.cs @@ -1,19 +1,44 @@ namespace RelayShared.Services; +/// +/// One row in the sidebar channel list. The server computes the permission-derived fields +/// (CanPost, CanManage) per-user so the client never has to evaluate permissions itself. +/// public sealed class ChannelItem { + /// Surreal record id (e.g. "channels:abc"). public string ChannelId { get; set; } = string.Empty; + + /// Sidebar display name ("general", "welcome", etc.). public string Name { get; set; } = string.Empty; + + /// Drives icon and behavior: Text/Voice/File/Forum/Stage. public ChannelType Type { get; set; } + + /// Sidebar category label (e.g. "General"). Empty groups fall under a default "Channels" header. public string Group { get; set; } = string.Empty; + + /// Creation timestamp. Drives sidebar sort order (oldest → newest). public DateTime CreatedAt { get; set; } + + /// True if the channel is announcement-style (welcome, files). Drives the 🔒 suffix in the sidebar. public bool IsReadOnly { get; set; } + + /// Permission-resolved: can the receiving user send messages here. Drives input enable/disable. public bool CanPost { get; set; } + + /// Permission-resolved: can the receiving user edit/delete this channel. Drives context-menu visibility. public bool CanManage { get; set; } } +/// +/// Server-to-client channel list. Sent in response to WsAction.GetChannels and broadcast +/// to all sessions after every channel create / delete. +/// public sealed class SocketChannelList { public SignalType Type { get; set; } = SignalType.ChannelList; + + /// Channels the receiving user is allowed to view. Permission filtering happens server-side. public List Channels { get; set; } = []; } diff --git a/RelayShared/Services/ChatMessageContent.cs b/RelayShared/Services/ChatMessageContent.cs index 1d0e3b7..c0f5fdf 100644 --- a/RelayShared/Services/ChatMessageContent.cs +++ b/RelayShared/Services/ChatMessageContent.cs @@ -1,14 +1,43 @@ namespace RelayShared.Services; +/// +/// The plaintext payload of a chat message before E2E encryption is applied. +/// +/// Lifecycle of a message: +/// 1. Client builds a ChatMessageContent (text + optional reply/attachment/mentions). +/// 2. Client JSON-serialises it, encrypts with the server's public key (RSA wrapping an +/// AES-GCM key), and sends the encrypted blob wrapped in a SocketEncryptedMessage. +/// 3. Server decrypts with its private key, re-encrypts with the channel DB key, stores it. +/// 4. For each recipient, server decrypts from DB key and re-encrypts with that recipient's +/// public key, then delivers via SocketEncryptedMessage. +/// 5. Recipient decrypts with their private key and JSON-deserialises back to ChatMessageContent. +/// +/// This type is intentionally shared by RelayClient and RelayServer so both ends agree on the +/// JSON shape. Adding a field here lights up the whole pipeline automatically. +/// public sealed class ChatMessageContent { + /// The raw message body, including Markdown syntax and @mentions. public string Text { get; set; } = string.Empty; + /// When set, this message is a reply. Carries the Surreal record id of the message being replied to. public string? ReplyToId { get; set; } + + /// Display name of the user being replied to. Lets the client render the quote bar without a lookup. public string? ReplyToSenderUsername { get; set; } + + /// Trimmed preview of the replied-to text (≤100 chars). Captured at send time so the server never has to look it up. public string? ReplyPreview { get; set; } + + /// Extracted usernames + special tokens ("everyone", "here"). Drives the ping-badge in the sidebar. public List? Mentions { get; set; } + + /// Base64-encoded attachment bytes. Null when there's no attachment. public string? AttachmentBase64 { get; set; } + + /// MIME type of the attachment (e.g. "image/png"). Used to choose between BuildBase64ImageEmbed and BuildFileCard. public string? AttachmentMimeType { get; set; } + + /// Original filename as chosen by the sender. Shown as the file card label and used for the download path. public string? AttachmentFileName { get; set; } } diff --git a/RelayShared/Services/SocketTransmissions.cs b/RelayShared/Services/SocketTransmissions.cs index a0473af..07dec97 100644 --- a/RelayShared/Services/SocketTransmissions.cs +++ b/RelayShared/Services/SocketTransmissions.cs @@ -2,71 +2,159 @@ namespace RelayShared.Services; //TODO: review name of file, potentially rename for Encryption services rather than sockets +/// +/// The "data plane" wire types for the WebSocket protocol. +/// +/// Every type here carries a SignalType discriminator so a generic JsonDocument peek +/// can identify the variant. The server dispatches on SignalType in ChatSocketBehavior.OnMessage; +/// the client dispatches on it in RelaySocketClient.OnMessage. +/// +/// Encrypted payloads share a uniform 4-tuple shape: (CipherText, Nonce, Tag, EncryptedKey). +/// That tuple is hybrid RSA+AES-GCM: EncryptedKey is the per-message AES key wrapped with the +/// recipient's RSA public key; CipherText/Nonce/Tag are the AES-GCM ciphertext, nonce, and +/// authentication tag for the actual JSON-serialised ChatMessageContent. +/// public sealed class SocketRtcSignalMessage { + /// Always SignalType.EncryptedSignal in flight. public SignalType Type { get; set; } + + /// Username of the user generating the SDP/ICE signal. public string SenderUsername { get; set; } = string.Empty; + + /// The voice channel this signal belongs to. public string ChannelId { get; set; } = string.Empty; + + /// Base64 AES-GCM ciphertext of the JSON-serialised RtcSignalMessage. public string CipherText { get; set; } = string.Empty; + + /// Base64 AES-GCM 96-bit nonce. public string Nonce { get; set; } = string.Empty; + + /// Base64 AES-GCM 128-bit authentication tag. public string Tag { get; set; } = string.Empty; + + /// Base64 RSA-OAEP-encrypted AES key (encrypted with recipient's public key). public string EncryptedKey { get; set; } = string.Empty; } +/// +/// The workhorse envelope for chat messages and message lifecycle events. +/// Used for both directions and for new sends / edits / delete tombstones. +/// public sealed class SocketEncryptedMessage { + /// + /// EncryptedChat (server→client), ClientEncryptedChat (client→server new message), + /// ClientEditMessage / ClientDeleteMessage (client→server lifecycle), MessageEdited (server→client). + /// public SignalType Type { get; set; } = SignalType.EncryptedChat; + + /// Surreal record id (e.g. "channel_messages:abc"). Populated by the server on outbound delivery. public string MessageId { get; set; } = string.Empty; + /// Who wrote the message. public string SenderUsername { get; set; } = string.Empty; + + /// Who this specific delivery is encrypted for. Different per recipient on the same logical message. public string RecipientUsername { get; set; } = string.Empty; + + /// The channel the message belongs to. public string ChannelId { get; set; } = string.Empty; + + /// Base64 AES-GCM ciphertext of the JSON-serialised ChatMessageContent. Empty on tombstone deliveries. public string CipherText { get; set; } = string.Empty; + + /// Base64 AES-GCM 96-bit nonce. public string Nonce { get; set; } = string.Empty; + + /// Base64 AES-GCM 128-bit authentication tag. public string Tag { get; set; } = string.Empty; + + /// Base64 RSA-OAEP-encrypted AES key (encrypted with recipient's public key on outbound, server's on inbound). public string EncryptedKey { get; set; } = string.Empty; + + /// True when this message has been edited at least once. Drives the (edited) footer in the bubble. public bool IsEdited { get; set; } + + /// True for tombstone deliveries (history only). Client renders a placeholder; no decryption is attempted. public bool IsDeleted { get; set; } } +/// +/// Server-broadcast tombstone fired the moment a message is deleted. Carries no content — +/// recipients use MessageId to find the existing bubble and swap it to a "deleted" placeholder. +/// public sealed class SocketMessageDeletedEvent { public SignalType Type { get; set; } = SignalType.MessageDeleted; + + /// The message being tombstoned. public string MessageId { get; set; } = string.Empty; + + /// Channel scope — clients that aren't viewing this channel can defer the bubble update. public string ChannelId { get; set; } = string.Empty; } +/// +/// "{Username} is typing…" hint. Server forwards to every connected member except the sender. +/// Client auto-clears the indicator 3 seconds after the last such event. +/// public sealed class SocketTypingEvent { public SignalType Type { get; set; } = SignalType.TypingIndicator; + + /// Who is typing. public string Username { get; set; } = string.Empty; + + /// Which channel they're typing in. Clients ignore events for channels they're not viewing. public string ChannelId { get; set; } = string.Empty; } +/// One historical version of an edited message, re-encrypted for the requester. public sealed class SocketEditHistoryEntry { + /// Base64 AES-GCM ciphertext of the JSON-serialised previous ChatMessageContent. public string CipherText { get; set; } = string.Empty; + public string Nonce { get; set; } = string.Empty; public string Tag { get; set; } = string.Empty; + + /// Base64 RSA-OAEP-encrypted AES key (encrypted with requester's public key). public string EncryptedKey { get; set; } = string.Empty; + + /// When this version was the current text (i.e. when it was replaced). public DateTime EditedAt { get; set; } } +/// Server reply to a GetEditHistory request. Entries are ordered oldest→newest. public sealed class SocketEditHistoryResponse { public SignalType Type { get; set; } = SignalType.EditHistory; + + /// Which message this history is for. public string MessageId { get; set; } = string.Empty; + + /// Every previous version of the message. Empty if the message has never been edited. public List Entries { get; set; } = []; } +/// +/// Server-to-client delivery of the server's public RSA key. Sent once per session in +/// response to WsAction.GetServerKey. Clients cache this for all outbound encryption. +/// public sealed class ServerPublicKeyMessage { public SignalType Type { get; set; } = SignalType.ServerPublicKey; + + /// Base64 SubjectPublicKeyInfo (DER) of the server's RSA public key. public string PublicKey { get; set; } = string.Empty; } +/// The wire discriminator for every data-plane Socket*Message. public enum SignalType { + // RTC SDP/ICE wire types (used by the WebView RTC engine, not handled directly here) Offer, Answer, Candidate, @@ -74,15 +162,37 @@ public enum SignalType AnswerUpdated, CandidateAdded, CallLeft, + + /// Server→client: paginated channel list (SocketChannelList). ChannelList, + + /// Server→client: ServerPublicKeyMessage delivery. ServerPublicKey, + + /// Bidirectional: encrypted RTC SDP/ICE signal (SocketRtcSignalMessage). EncryptedSignal, + + /// Server→client: delivered chat message (SocketEncryptedMessage). EncryptedChat, + + /// Client→server: new chat message send (SocketEncryptedMessage). ClientEncryptedChat, + + /// Client→server: request to edit own message (SocketEncryptedMessage with new content). ClientEditMessage, + + /// Client→server: request to delete own message (SocketEncryptedMessage with only MessageId). ClientDeleteMessage, + + /// Server→clients: edit broadcast carrying re-encrypted new content (SocketEncryptedMessage). MessageEdited, + + /// Server→clients: deletion tombstone (SocketMessageDeletedEvent). MessageDeleted, + + /// Server→peers: typing indicator (SocketTypingEvent). TypingIndicator, + + /// Server→requester: edit-history response (SocketEditHistoryResponse). EditHistory } diff --git a/RelayShared/Services/WsControlMessage.cs b/RelayShared/Services/WsControlMessage.cs index 279753c..e21b7eb 100644 --- a/RelayShared/Services/WsControlMessage.cs +++ b/RelayShared/Services/WsControlMessage.cs @@ -1,42 +1,111 @@ namespace RelayShared.Services; +/// +/// JSON-dispatch contract for the WebSocket "control plane" (non-encrypted, +/// non-realtime requests like auth, key registration, channel CRUD, history fetches). +/// +/// The server's ChatSocketBehavior.OnMessage looks at the first JSON property of every +/// incoming text frame: +/// - "Action" present → deserialise into WsControlMessage and dispatch on WsAction. +/// - "Type" present → deserialise into SocketEncryptedMessage/SocketRtcSignalMessage +/// and dispatch on SignalType (the "data plane" — chat messages, +/// RTC signals, edit/delete requests). +/// +/// Responses come back as either WsEventMessage (for acks/errors) or one of the +/// Socket*Message types (for streaming data). +/// public enum WsAction { + /// Verify a Core-issued user token. Fields used: Username, Token. Authenticate, + + /// Register/update the client's RSA public key. Fields used: Username, PublicKey. RegisterKey, + + /// Request the server's public RSA key for outbound encryption. No fields. GetServerKey, + + /// Request the full channel list for this user. No fields. GetChannels, + + /// Request decrypted message history for a channel. Fields used: Username, ChannelId. GetHistory, + + /// Join a voice channel (presence tracking). Fields used: Username, ChannelId. RtcJoin, + + /// Leave a voice channel. Fields used: Username, ChannelId. RtcLeave, + + /// Broadcast "user is typing" to channel peers. Fields used: ChannelId. SendTyping, + + /// Request the edit-history chain for a specific message. Fields used: Username, MessageId, ChannelId. GetEditHistory, + + /// Create a new channel (permission-gated). Fields used: ChannelName, ChannelType, ChannelGroup. CreateChannel, + + /// Soft-delete a channel (permission-gated). Fields used: ChannelId. DeleteChannel } +/// Server-to-client event types for acks and errors. public enum WsEvent { + /// Reply to Authenticate. Detail = username. Authenticated, + + /// Reply to RegisterKey. Detail = username. KeyRegistered, + + /// Generic error. Detail = human-readable reason shown to the user. Error } +/// +/// Control-plane envelope. All fields are nullable because each action only uses a subset +/// of them. Serialised as JSON; identified by the presence of the "Action" property. +/// public sealed class WsControlMessage { + /// The action to perform. Server dispatches on this. public WsAction Action { get; set; } + + /// Mixed-case username as the user typed it on sign-in. Server preserves casing for display. public string? Username { get; set; } + + /// Core-issued auth token. Only set on Authenticate. public string? Token { get; set; } + + /// Base64-encoded RSA public key. Only set on RegisterKey. public string? PublicKey { get; set; } + + /// Surreal record id of a channel (e.g. "channels:xyz"). Used by most channel-scoped actions. public string? ChannelId { get; set; } + + /// Surreal record id of a message. Used by GetEditHistory. public string? MessageId { get; set; } + + /// Channel name on create (e.g. "memes"). Server normalises to lowercase-dashes. public string? ChannelName { get; set; } - public int ChannelType { get; set; } // cast to ChannelType enum + + /// Integer cast of ChannelType enum (Text=0, Voice=1, …). Used on CreateChannel. + public int ChannelType { get; set; } + + /// Group/category label shown in the sidebar (e.g. "General"). Optional on CreateChannel. public string? ChannelGroup { get; set; } } +/// +/// Server-to-client ack envelope. Identified by the "Event" JSON property +/// (vs WsControlMessage's "Action" or Socket*Message's "Type"). +/// public sealed class WsEventMessage { + /// Which event this is acknowledging. public WsEvent Event { get; set; } + + /// Human-readable context (username on success, error message on Error). public string? Detail { get; set; } -} \ No newline at end of file +}