Summary Update.
This commit is contained in:
@@ -3,8 +3,14 @@ using System.Text;
|
|||||||
|
|
||||||
namespace RelayClient.Crypto;
|
namespace RelayClient.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static class E2EeHelper
|
public static class E2EeHelper
|
||||||
{
|
{
|
||||||
|
/// <summary>Generates a fresh RSA-2048 keypair. Called once per user on first launch and persisted via KeyStorage.</summary>
|
||||||
public static (string publicKey, string privateKey) GenerateRsaKeyPair()
|
public static (string publicKey, string privateKey) GenerateRsaKeyPair()
|
||||||
{
|
{
|
||||||
using var rsa = RSA.Create(2048);
|
using var rsa = RSA.Create(2048);
|
||||||
@@ -15,6 +21,11 @@ public static class E2EeHelper
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static EncryptedPayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64)
|
public static EncryptedPayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64)
|
||||||
{
|
{
|
||||||
byte[] aesKey = RandomNumberGenerator.GetBytes(32);
|
byte[] aesKey = RandomNumberGenerator.GetBytes(32);
|
||||||
@@ -44,6 +55,11 @@ public static class E2EeHelper
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static string DecryptForRecipient(EncryptedPayload payload, string recipientPrivateKeyBase64)
|
public static string DecryptForRecipient(EncryptedPayload payload, string recipientPrivateKeyBase64)
|
||||||
{
|
{
|
||||||
byte[] aesKey;
|
byte[] aesKey;
|
||||||
@@ -69,6 +85,7 @@ public static class E2EeHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The 4-tuple ciphertext bundle. Same shape on both client and server; matches SocketEncryptedMessage's encrypted fields.</summary>
|
||||||
public class EncryptedPayload
|
public class EncryptedPayload
|
||||||
{
|
{
|
||||||
public required string CipherText { get; set; }
|
public required string CipherText { get; set; }
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
namespace RelayClient.Crypto;
|
namespace RelayClient.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static class KeyStorage
|
public static class KeyStorage
|
||||||
{
|
{
|
||||||
|
/// <summary>Returns (and creates if needed) the per-app keys directory.</summary>
|
||||||
private static string GetKeyFolder()
|
private static string GetKeyFolder()
|
||||||
{
|
{
|
||||||
var folder = Path.Combine(FileSystem.AppDataDirectory, "keys");
|
var folder = Path.Combine(FileSystem.AppDataDirectory, "keys");
|
||||||
@@ -9,26 +19,31 @@ public static class KeyStorage
|
|||||||
return folder;
|
return folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the base64 RSA private key to disk. Used at first-launch after GenerateRsaKeyPair.</summary>
|
||||||
public static void SavePrivateKey(string username, string privateKey)
|
public static void SavePrivateKey(string username, string privateKey)
|
||||||
{
|
{
|
||||||
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"), privateKey);
|
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"), privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the base64 RSA public key to disk. Sent to the server via WsAction.RegisterKey.</summary>
|
||||||
public static void SavePublicKey(string username, string publicKey)
|
public static void SavePublicKey(string username, string publicKey)
|
||||||
{
|
{
|
||||||
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"), publicKey);
|
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"), publicKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the user's RSA private key. Used by TryDecryptAndParseContent on every inbound message.</summary>
|
||||||
public static string LoadPrivateKey(string username)
|
public static string LoadPrivateKey(string username)
|
||||||
{
|
{
|
||||||
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"));
|
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the user's RSA public key. Used during the boot handshake to send to the server.</summary>
|
||||||
public static string LoadPublicKey(string username)
|
public static string LoadPublicKey(string username)
|
||||||
{
|
{
|
||||||
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"));
|
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True if BOTH halves of the user's keypair already exist on disk. False means we need to generate.</summary>
|
||||||
public static bool HasKeys(string username)
|
public static bool HasKeys(string username)
|
||||||
{
|
{
|
||||||
return File.Exists(Path.Combine(GetKeyFolder(), $"{username}.private.key")) &&
|
return File.Exists(Path.Combine(GetKeyFolder(), $"{username}.private.key")) &&
|
||||||
|
|||||||
@@ -22,12 +22,18 @@ public static class EmbedHelper
|
|||||||
private static readonly HashSet<string> ImageExtensions =
|
private static readonly HashSet<string> ImageExtensions =
|
||||||
[".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"];
|
[".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"];
|
||||||
|
|
||||||
|
/// <summary>Extracts every distinct http/https URL from message text. De-duped so multiple occurrences don't double-embed.</summary>
|
||||||
public static List<string> DetectUrls(string text)
|
public static List<string> DetectUrls(string text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text)) return [];
|
if (string.IsNullOrWhiteSpace(text)) return [];
|
||||||
return UrlPattern.Matches(text).Select(m => m.Value).Distinct().ToList();
|
return UrlPattern.Matches(text).Select(m => m.Value).Distinct().ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static List<View> BuildEmbeds(string text)
|
public static List<View> BuildEmbeds(string text)
|
||||||
{
|
{
|
||||||
var views = new List<View>();
|
var views = new List<View>();
|
||||||
@@ -51,6 +57,10 @@ public static class EmbedHelper
|
|||||||
return views;
|
return views;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decodes a base64 attachment to bytes and renders it as an inline Image. Used by
|
||||||
|
/// MainPage.BuildBubbleContent when a message has an image attachment.
|
||||||
|
/// </summary>
|
||||||
public static View BuildBase64ImageEmbed(string base64, string fileName)
|
public static View BuildBase64ImageEmbed(string base64, string fileName)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -86,6 +96,10 @@ public static class EmbedHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static View BuildFileCard(string base64, string fileName, string mimeType)
|
public static View BuildFileCard(string base64, string fileName, string mimeType)
|
||||||
{
|
{
|
||||||
var label = new Label
|
var label = new Label
|
||||||
@@ -122,6 +136,7 @@ public static class EmbedHelper
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Direct image URL → inline Image (loaded async by MAUI from the URI). Tap opens in browser.</summary>
|
||||||
private static View BuildImageEmbed(string url)
|
private static View BuildImageEmbed(string url)
|
||||||
{
|
{
|
||||||
var image = new Image
|
var image = new Image
|
||||||
@@ -146,6 +161,11 @@ public static class EmbedHelper
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static View BuildJumpCard(string relayUrl)
|
private static View BuildJumpCard(string relayUrl)
|
||||||
{
|
{
|
||||||
var label = new Label
|
var label = new Label
|
||||||
@@ -167,9 +187,15 @@ public static class EmbedHelper
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Attached property that stores the relay:// URL on the jump label so MainPage.WireJumpLinks can find it.</summary>
|
||||||
public static readonly BindableProperty JumpUrlProperty =
|
public static readonly BindableProperty JumpUrlProperty =
|
||||||
BindableProperty.CreateAttached("JumpUrl", typeof(string), typeof(EmbedHelper), null);
|
BindableProperty.CreateAttached("JumpUrl", typeof(string), typeof(EmbedHelper), null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static View BuildLinkCard(string url)
|
private static View BuildLinkCard(string url)
|
||||||
{
|
{
|
||||||
var displayUrl = url.Length > 55 ? url[..52] + "…" : 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);
|
private sealed record OgData(string? Title, string? Description, string? ImageUrl);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private static async Task<OgData?> FetchOgTagsAsync(string url)
|
private static async Task<OgData?> FetchOgTagsAsync(string url)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -283,6 +313,7 @@ public static class EmbedHelper
|
|||||||
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()) : null;
|
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the URL's path ends with a known image extension. Used to choose between BuildImageEmbed and BuildLinkCard.</summary>
|
||||||
private static bool IsImageUrl(string url)
|
private static bool IsImageUrl(string url)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -298,6 +329,7 @@ public static class EmbedHelper
|
|||||||
@"(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{6,})",
|
@"(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{6,})",
|
||||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Extracts the 11-char video ID from any YouTube URL form (watch, youtu.be, embed, shorts, /v/).</summary>
|
||||||
private static bool TryGetYouTubeId(string url, out string id)
|
private static bool TryGetYouTubeId(string url, out string id)
|
||||||
{
|
{
|
||||||
var match = YouTubePattern.Match(url);
|
var match = YouTubePattern.Match(url);
|
||||||
@@ -314,6 +346,7 @@ public static class EmbedHelper
|
|||||||
@"vimeo\.com/(?:video/|channels/[^/]+/|groups/[^/]+/videos/)?(\d{6,})",
|
@"vimeo\.com/(?:video/|channels/[^/]+/|groups/[^/]+/videos/)?(\d{6,})",
|
||||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Extracts the numeric video ID from Vimeo URLs. Handles vimeo.com/{id}, /video/{id}, channels/x/{id}, groups/x/videos/{id}.</summary>
|
||||||
private static bool TryGetVimeoId(string url, out string id)
|
private static bool TryGetVimeoId(string url, out string id)
|
||||||
{
|
{
|
||||||
var match = VimeoPattern.Match(url);
|
var match = VimeoPattern.Match(url);
|
||||||
@@ -326,6 +359,7 @@ public static class EmbedHelper
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>YouTube embed card. Thumbnail comes from img.youtube.com; player swaps to the youtube.com/embed/ URL on tap.</summary>
|
||||||
private static View BuildYouTubeCard(string url, string videoId) =>
|
private static View BuildYouTubeCard(string url, string videoId) =>
|
||||||
BuildVideoCardWithEmbed(
|
BuildVideoCardWithEmbed(
|
||||||
providerLabel: "🎬 YouTube",
|
providerLabel: "🎬 YouTube",
|
||||||
@@ -334,6 +368,7 @@ public static class EmbedHelper
|
|||||||
thumbnailUrl: $"https://img.youtube.com/vi/{videoId}/hqdefault.jpg",
|
thumbnailUrl: $"https://img.youtube.com/vi/{videoId}/hqdefault.jpg",
|
||||||
embedUrl: $"https://www.youtube.com/embed/{videoId}?autoplay=1&rel=0");
|
embedUrl: $"https://www.youtube.com/embed/{videoId}?autoplay=1&rel=0");
|
||||||
|
|
||||||
|
/// <summary>Vimeo embed card. No thumbnail (Vimeo's API requires OAuth); placeholder stays black with a play badge until tap.</summary>
|
||||||
private static View BuildVimeoCard(string url, string videoId) =>
|
private static View BuildVimeoCard(string url, string videoId) =>
|
||||||
BuildVideoCardWithEmbed(
|
BuildVideoCardWithEmbed(
|
||||||
providerLabel: "🎬 Vimeo",
|
providerLabel: "🎬 Vimeo",
|
||||||
@@ -342,6 +377,11 @@ public static class EmbedHelper
|
|||||||
thumbnailUrl: null, // Vimeo thumbs require an API call; skip and show a black placeholder
|
thumbnailUrl: null, // Vimeo thumbs require an API call; skip and show a black placeholder
|
||||||
embedUrl: $"https://player.vimeo.com/video/{videoId}?autoplay=1");
|
embedUrl: $"https://player.vimeo.com/video/{videoId}?autoplay=1");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static View BuildVideoCardWithEmbed(
|
private static View BuildVideoCardWithEmbed(
|
||||||
string providerLabel,
|
string providerLabel,
|
||||||
Color providerColor,
|
Color providerColor,
|
||||||
@@ -399,6 +439,10 @@ public static class EmbedHelper
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static View BuildThumbnailPlaceholder(string? thumbnailUrl, Action onPlay)
|
private static View BuildThumbnailPlaceholder(string? thumbnailUrl, Action onPlay)
|
||||||
{
|
{
|
||||||
var grid = new Grid
|
var grid = new Grid
|
||||||
@@ -440,6 +484,7 @@ public static class EmbedHelper
|
|||||||
return grid;
|
return grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The actual in-client video player. WebView2 (Windows) and WebKit (mobile) both handle YouTube/Vimeo embed pages.</summary>
|
||||||
private static View BuildEmbeddedPlayer(string embedUrl)
|
private static View BuildEmbeddedPlayer(string embedUrl)
|
||||||
{
|
{
|
||||||
return new WebView
|
return new WebView
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ public static class MarkdownHelper
|
|||||||
private static readonly Color MentionBg = Color.FromArgb("#2D2F5C");
|
private static readonly Color MentionBg = Color.FromArgb("#2D2F5C");
|
||||||
private static readonly Color SpoilerBg = Color.FromArgb("#1F1F23");
|
private static readonly Color SpoilerBg = Color.FromArgb("#1F1F23");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static View Render(string markdown, double fontSize = 14)
|
public static View Render(string markdown, double fontSize = 14)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(markdown))
|
if (string.IsNullOrEmpty(markdown))
|
||||||
@@ -37,6 +43,11 @@ public static class MarkdownHelper
|
|||||||
return stack.Children.Count == 1 ? (View)stack.Children[0] : stack;
|
return stack.Children.Count == 1 ? (View)stack.Children[0] : stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static void AppendTextSegment(VerticalStackLayout stack, string segment, double fontSize)
|
private static void AppendTextSegment(VerticalStackLayout stack, string segment, double fontSize)
|
||||||
{
|
{
|
||||||
var paragraphBuffer = new StringBuilder();
|
var paragraphBuffer = new StringBuilder();
|
||||||
@@ -94,6 +105,10 @@ public static class MarkdownHelper
|
|||||||
FlushParagraph();
|
FlushParagraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private static View CreateCodeBlock(string language, string code)
|
private static View CreateCodeBlock(string language, string code)
|
||||||
{
|
{
|
||||||
var label = new Label
|
var label = new Label
|
||||||
@@ -141,6 +156,7 @@ public static class MarkdownHelper
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Bold, larger Label for # / ## / ### lines. Inline markdown still works inside (e.g. `# Hello **world**`).</summary>
|
||||||
private static Label CreateHeaderLabel(string text, double size)
|
private static Label CreateHeaderLabel(string text, double size)
|
||||||
{
|
{
|
||||||
var label = new Label
|
var label = new Label
|
||||||
@@ -162,6 +178,7 @@ public static class MarkdownHelper
|
|||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Smaller, grey Label for "-#" lines (Discord calls it subtext). Inherits inline markdown.</summary>
|
||||||
private static Label CreateSubtextLabel(string text, double size)
|
private static Label CreateSubtextLabel(string text, double size)
|
||||||
{
|
{
|
||||||
var label = new Label
|
var label = new Label
|
||||||
@@ -190,6 +207,7 @@ public static class MarkdownHelper
|
|||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Standard paragraph Label. Runs the inline parser to build a FormattedString of spans.</summary>
|
||||||
private static Label CreateInlineLabel(string text, double fontSize)
|
private static Label CreateInlineLabel(string text, double fontSize)
|
||||||
{
|
{
|
||||||
var label = new Label { FontSize = fontSize, LineBreakMode = LineBreakMode.WordWrap };
|
var label = new Label { FontSize = fontSize, LineBreakMode = LineBreakMode.WordWrap };
|
||||||
@@ -204,6 +222,11 @@ public static class MarkdownHelper
|
|||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static void WireSpoilerTap(Label label, List<Span> spoilerSpans)
|
private static void WireSpoilerTap(Label label, List<Span> spoilerSpans)
|
||||||
{
|
{
|
||||||
if (spoilerSpans.Count == 0) return;
|
if (spoilerSpans.Count == 0) return;
|
||||||
@@ -220,6 +243,12 @@ public static class MarkdownHelper
|
|||||||
label.GestureRecognizers.Add(tap);
|
label.GestureRecognizers.Add(tap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static void ParseInline(string text, IList<Span> spans, double fontSize, List<Span> spoilerSpans)
|
private static void ParseInline(string text, IList<Span> spans, double fontSize, List<Span> spoilerSpans)
|
||||||
{
|
{
|
||||||
var plain = new StringBuilder();
|
var plain = new StringBuilder();
|
||||||
@@ -365,8 +394,13 @@ public static class MarkdownHelper
|
|||||||
Flush();
|
Flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Safe one-character lookahead. Returns '\0' past end-of-string.</summary>
|
||||||
private static char Peek(string text, int index) => index < text.Length ? text[index] : '\0';
|
private static char Peek(string text, int index) => index < text.Length ? text[index] : '\0';
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the next single occurrence of marker that is NOT immediately followed by
|
||||||
|
/// another marker. Used to disambiguate "*italic*" from "**bold**".
|
||||||
|
/// </summary>
|
||||||
private static int FindClosingSingle(string text, char marker, int start)
|
private static int FindClosingSingle(string text, char marker, int start)
|
||||||
{
|
{
|
||||||
for (int i = start; i < text.Length; i++)
|
for (int i = start; i < text.Length; i++)
|
||||||
|
|||||||
@@ -2,21 +2,50 @@ using System.Text.RegularExpressions;
|
|||||||
|
|
||||||
namespace RelayClient.Helpers;
|
namespace RelayClient.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static class SyntaxHighlighter
|
public static class SyntaxHighlighter
|
||||||
{
|
{
|
||||||
|
/// <summary>Fallback identifier color (light grey). Used for any token we don't recognise.</summary>
|
||||||
private static readonly Color DefaultColor = Color.FromArgb("#D4D4D4");
|
private static readonly Color DefaultColor = Color.FromArgb("#D4D4D4");
|
||||||
|
/// <summary>Language keywords (if, for, return, etc.) — VS Code's "control flow" blue.</summary>
|
||||||
private static readonly Color KeywordColor = Color.FromArgb("#569CD6");
|
private static readonly Color KeywordColor = Color.FromArgb("#569CD6");
|
||||||
|
/// <summary>String literals — orange/salmon.</summary>
|
||||||
private static readonly Color StringColor = Color.FromArgb("#CE9178");
|
private static readonly Color StringColor = Color.FromArgb("#CE9178");
|
||||||
|
/// <summary>Numeric literals — soft green.</summary>
|
||||||
private static readonly Color NumberColor = Color.FromArgb("#B5CEA8");
|
private static readonly Color NumberColor = Color.FromArgb("#B5CEA8");
|
||||||
|
/// <summary>Comments — green, rendered italic.</summary>
|
||||||
private static readonly Color CommentColor = Color.FromArgb("#6A9955");
|
private static readonly Color CommentColor = Color.FromArgb("#6A9955");
|
||||||
|
/// <summary>Type names (heuristic: uppercase-start words in C#/JS/TS) — teal.</summary>
|
||||||
private static readonly Color TypeColor = Color.FromArgb("#4EC9B0");
|
private static readonly Color TypeColor = Color.FromArgb("#4EC9B0");
|
||||||
|
/// <summary>Function names — yellow. Currently unused (we don't disambiguate function calls).</summary>
|
||||||
private static readonly Color FunctionColor = Color.FromArgb("#DCDCAA");
|
private static readonly Color FunctionColor = Color.FromArgb("#DCDCAA");
|
||||||
|
/// <summary>Operators — same as default. Reserved for future use.</summary>
|
||||||
private static readonly Color OperatorColor = Color.FromArgb("#D4D4D4");
|
private static readonly Color OperatorColor = Color.FromArgb("#D4D4D4");
|
||||||
|
/// <summary>HTML tag names (<div>, </p>) — blue.</summary>
|
||||||
private static readonly Color TagColor = Color.FromArgb("#569CD6");
|
private static readonly Color TagColor = Color.FromArgb("#569CD6");
|
||||||
|
/// <summary>HTML/CSS attribute names, YAML keys, bash variables — light blue.</summary>
|
||||||
private static readonly Color AttrColor = Color.FromArgb("#9CDCFE");
|
private static readonly Color AttrColor = Color.FromArgb("#9CDCFE");
|
||||||
|
|
||||||
|
/// <summary>Monospace font registered in MauiProgram. Used for all code-block spans.</summary>
|
||||||
private const string FontFamily = "AnonymousProRegular";
|
private const string FontFamily = "AnonymousProRegular";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short language tags → canonical names. So users can write ```cs (instead of ```csharp),
|
||||||
|
/// ```py instead of ```python, etc. Case-insensitive.
|
||||||
|
/// </summary>
|
||||||
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["cs"] = "csharp",
|
["cs"] = "csharp",
|
||||||
@@ -34,6 +63,11 @@ public static class SyntaxHighlighter
|
|||||||
["yml"] = "yaml"
|
["yml"] = "yaml"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static readonly Dictionary<string, HashSet<string>> Keywords = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly Dictionary<string, HashSet<string>> Keywords = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["csharp"] = new(StringComparer.Ordinal)
|
["csharp"] = new(StringComparer.Ordinal)
|
||||||
@@ -97,6 +131,11 @@ public static class SyntaxHighlighter
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static readonly Dictionary<string, Regex> Tokenizers = new(StringComparer.Ordinal);
|
private static readonly Dictionary<string, Regex> Tokenizers = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
static SyntaxHighlighter()
|
static SyntaxHighlighter()
|
||||||
@@ -186,6 +225,11 @@ public static class SyntaxHighlighter
|
|||||||
opts | RegexOptions.Multiline);
|
opts | RegexOptions.Multiline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static List<Span> Highlight(string code, string? language, double fontSize)
|
public static List<Span> Highlight(string code, string? language, double fontSize)
|
||||||
{
|
{
|
||||||
var lang = Resolve(language);
|
var lang = Resolve(language);
|
||||||
@@ -215,6 +259,11 @@ public static class SyntaxHighlighter
|
|||||||
return spans;
|
return spans;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private static Span SpanForMatch(Match m, string lang, HashSet<string>? keywords, double fontSize)
|
private static Span SpanForMatch(Match m, string lang, HashSet<string>? keywords, double fontSize)
|
||||||
{
|
{
|
||||||
if (m.Groups["comment"].Success)
|
if (m.Groups["comment"].Success)
|
||||||
@@ -285,6 +334,7 @@ public static class SyntaxHighlighter
|
|||||||
return MakeSpan(m.Value, DefaultColor, fontSize);
|
return MakeSpan(m.Value, DefaultColor, fontSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Helper: build a Span with the monospace code font and the given colour + bold/italic flags.</summary>
|
||||||
private static Span MakeSpan(string text, Color color, double fontSize, bool bold = false, bool italic = false)
|
private static Span MakeSpan(string text, Color color, double fontSize, bool bold = false, bool italic = false)
|
||||||
{
|
{
|
||||||
var attrs = FontAttributes.None;
|
var attrs = FontAttributes.None;
|
||||||
@@ -301,6 +351,7 @@ public static class SyntaxHighlighter
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Normalises a user-supplied language tag through the Aliases table. Returns null for empty/whitespace input.</summary>
|
||||||
private static string? Resolve(string? language)
|
private static string? Resolve(string? language)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(language)) return null;
|
if (string.IsNullOrWhiteSpace(language)) return null;
|
||||||
|
|||||||
@@ -9,40 +9,103 @@ using RelayShared.Services;
|
|||||||
|
|
||||||
namespace RelayClient;
|
namespace RelayClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public partial class MainPage : ContentPage
|
public partial class MainPage : ContentPage
|
||||||
{
|
{
|
||||||
|
/// <summary>Logged-in username. Static so RelaySocketClient can read it without a back-pointer.</summary>
|
||||||
public static string _username = string.Empty;
|
public static string _username = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The WebSocket pipe to RelayServer.</summary>
|
||||||
private readonly RelaySocketClient _socket;
|
private readonly RelaySocketClient _socket;
|
||||||
|
|
||||||
|
/// <summary>The C#↔JS bridge for the WebView WebRTC engine.</summary>
|
||||||
private readonly RtcBridgeService _rtc;
|
private readonly RtcBridgeService _rtc;
|
||||||
|
|
||||||
|
/// <summary>Core-issued auth token, set during sign-in by ClientSession.</summary>
|
||||||
public static string? _userToken;
|
public static string? _userToken;
|
||||||
|
|
||||||
|
/// <summary>"channels:abc" — the currently-viewed channel, or null if none.</summary>
|
||||||
private string? _currentChannelId;
|
private string? _currentChannelId;
|
||||||
|
|
||||||
|
/// <summary>Display name of the current channel (used for the header label).</summary>
|
||||||
private string? _currentChannelName;
|
private string? _currentChannelName;
|
||||||
|
|
||||||
|
/// <summary>Drives view switching (messages vs RTC) on channel select.</summary>
|
||||||
private ChannelType _currentChannelType = ChannelType.Text;
|
private ChannelType _currentChannelType = ChannelType.Text;
|
||||||
|
|
||||||
|
/// <summary>channelId → message list. Per-channel scrollback kept in memory for the session.</summary>
|
||||||
private readonly Dictionary<string, List<ChatMessage>> _messagesByChannel = new();
|
private readonly Dictionary<string, List<ChatMessage>> _messagesByChannel = new();
|
||||||
|
|
||||||
|
/// <summary>The full channel list as last delivered by the server (after Visibility filtering).</summary>
|
||||||
private readonly List<ChannelItem> _channels = [];
|
private readonly List<ChannelItem> _channels = [];
|
||||||
|
|
||||||
|
/// <summary>messageId → ChatMessage. O(1) lookup for edit/delete handlers.</summary>
|
||||||
private readonly Dictionary<string, ChatMessage> _messagesById = new();
|
private readonly Dictionary<string, ChatMessage> _messagesById = new();
|
||||||
|
|
||||||
|
/// <summary>messageId → MAUI Border (the bubble). O(1) in-place bubble rebuild on edit/delete.</summary>
|
||||||
private readonly Dictionary<string, Border> _messageBubbles = new();
|
private readonly Dictionary<string, Border> _messageBubbles = new();
|
||||||
|
|
||||||
|
/// <summary>When non-null, the next Send becomes a reply to this message.</summary>
|
||||||
private ChatMessage? _replyingToMessage;
|
private ChatMessage? _replyingToMessage;
|
||||||
|
|
||||||
|
/// <summary>When non-null, the next Send becomes an edit of this message.</summary>
|
||||||
private string? _editingMessageId;
|
private string? _editingMessageId;
|
||||||
|
|
||||||
|
/// <summary>Base64 of the staged file attachment. Cleared after Send or context cancel.</summary>
|
||||||
private string? _pendingAttachmentBase64;
|
private string? _pendingAttachmentBase64;
|
||||||
private string? _pendingAttachmentMimeType;
|
private string? _pendingAttachmentMimeType;
|
||||||
private string? _pendingAttachmentFileName;
|
private string? _pendingAttachmentFileName;
|
||||||
|
|
||||||
|
/// <summary>Cancels the previous "send typing event" if the user keeps typing. Debounce gate.</summary>
|
||||||
private CancellationTokenSource? _typingDebounce;
|
private CancellationTokenSource? _typingDebounce;
|
||||||
|
|
||||||
|
/// <summary>Per-username 3-second auto-clear timers for the typing indicator label.</summary>
|
||||||
private readonly Dictionary<string, CancellationTokenSource> _typingClearTimers = new();
|
private readonly Dictionary<string, CancellationTokenSource> _typingClearTimers = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True after the first channel-list arrives. Used so subsequent channel-list broadcasts
|
||||||
|
/// (from create/delete) DON'T yank the user back to #welcome.
|
||||||
|
/// </summary>
|
||||||
private bool _channelsInitialized;
|
private bool _channelsInitialized;
|
||||||
|
|
||||||
|
/// <summary>channelId → unread @mention count. Drives the 🔔N sidebar badge.</summary>
|
||||||
private readonly Dictionary<string, int> _mentionCounts = new();
|
private readonly Dictionary<string, int> _mentionCounts = new();
|
||||||
|
|
||||||
|
/// <summary>Matches `@word` (where word is letters/digits/underscore). Used at send time to extract mentions.</summary>
|
||||||
private static readonly Regex MentionExtract =
|
private static readonly Regex MentionExtract =
|
||||||
new(@"@(\w+)", RegexOptions.Compiled);
|
new(@"@(\w+)", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public MainPage(string username)
|
public MainPage(string username)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -82,6 +145,11 @@ public partial class MainPage : ContentPage
|
|||||||
SetupFileDragAndDrop();
|
SetupFileDragAndDrop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private void SetupFileDragAndDrop()
|
private void SetupFileDragAndDrop()
|
||||||
{
|
{
|
||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
@@ -123,6 +191,12 @@ public partial class MainPage : ContentPage
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private void SetupEditorKeyHandler()
|
private void SetupEditorKeyHandler()
|
||||||
{
|
{
|
||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
@@ -170,6 +244,11 @@ public partial class MainPage : ContentPage
|
|||||||
|
|
||||||
private void SendButton_OnClicked(object? sender, EventArgs e) => SendMessage();
|
private void SendButton_OnClicked(object? sender, EventArgs e) => SendMessage();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void MessageEntry_OnTextChanged(object? sender, TextChangedEventArgs e)
|
private void MessageEntry_OnTextChanged(object? sender, TextChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_currentChannelId)) return;
|
if (string.IsNullOrWhiteSpace(_currentChannelId)) return;
|
||||||
@@ -186,6 +265,16 @@ public partial class MainPage : ContentPage
|
|||||||
}, token);
|
}, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void SendMessage()
|
private void SendMessage()
|
||||||
{
|
{
|
||||||
if (!MessageEntry.IsEnabled) return;
|
if (!MessageEntry.IsEnabled) return;
|
||||||
@@ -274,6 +363,7 @@ public partial class MainPage : ContentPage
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>📎 button click. Opens the system file picker (no filter — all files), then stages the result via IngestPickedFileAsync.</summary>
|
||||||
private async void AttachFile_OnClicked(object? sender, EventArgs e)
|
private async void AttachFile_OnClicked(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -293,6 +383,11 @@ public partial class MainPage : ContentPage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private async Task IngestPickedFileAsync(string fullPath, string fileName)
|
private async Task IngestPickedFileAsync(string fullPath, string fileName)
|
||||||
{
|
{
|
||||||
if (!File.Exists(fullPath))
|
if (!File.Exists(fullPath))
|
||||||
@@ -333,6 +428,7 @@ public partial class MainPage : ContentPage
|
|||||||
_pendingAttachmentFileName = null;
|
_pendingAttachmentFileName = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Best-effort MIME mapping by file extension. Used to set AttachmentMimeType on send.</summary>
|
||||||
private static string GetMimeType(string fileName)
|
private static string GetMimeType(string fileName)
|
||||||
{
|
{
|
||||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
@@ -352,6 +448,7 @@ public partial class MainPage : ContentPage
|
|||||||
|
|
||||||
private void CancelContext_OnClicked(object? sender, EventArgs e) => CancelContext();
|
private void CancelContext_OnClicked(object? sender, EventArgs e) => CancelContext();
|
||||||
|
|
||||||
|
/// <summary>Clears reply/edit state, drops the staged attachment, hides the context bar, empties the editor.</summary>
|
||||||
private void CancelContext()
|
private void CancelContext()
|
||||||
{
|
{
|
||||||
_replyingToMessage = null;
|
_replyingToMessage = null;
|
||||||
@@ -366,6 +463,7 @@ public partial class MainPage : ContentPage
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Enters reply mode. Next Send becomes a reply to this message + auto-mentions the original sender.</summary>
|
||||||
private void StartReply(ChatMessage message)
|
private void StartReply(ChatMessage message)
|
||||||
{
|
{
|
||||||
_replyingToMessage = message;
|
_replyingToMessage = message;
|
||||||
@@ -380,6 +478,7 @@ public partial class MainPage : ContentPage
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Enters edit mode. Pre-fills the editor with the existing text; next Send fires SendEditMessage.</summary>
|
||||||
private void StartEdit(ChatMessage message)
|
private void StartEdit(ChatMessage message)
|
||||||
{
|
{
|
||||||
_editingMessageId = message.MessageId;
|
_editingMessageId = message.MessageId;
|
||||||
@@ -397,6 +496,7 @@ public partial class MainPage : ContentPage
|
|||||||
private void ConfirmDelete(ChatMessage message) =>
|
private void ConfirmDelete(ChatMessage message) =>
|
||||||
_socket.SendDeleteMessage(message.MessageId, _currentChannelId ?? string.Empty);
|
_socket.SendDeleteMessage(message.MessageId, _currentChannelId ?? string.Empty);
|
||||||
|
|
||||||
|
/// <summary>Copies a relay://jump/{channel}/{message} URL to the clipboard. Pasting this in chat creates a tappable "Jump to message" embed.</summary>
|
||||||
private void CopyMessageLink(ChatMessage message)
|
private void CopyMessageLink(ChatMessage message)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(message.MessageId)) return;
|
if (string.IsNullOrWhiteSpace(message.MessageId)) return;
|
||||||
@@ -405,6 +505,10 @@ public partial class MainPage : ContentPage
|
|||||||
SafeSendRawToWebView($"Link copied: {link}");
|
SafeSendRawToWebView($"Link copied: {link}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires the message-bubble context menu. On Windows: native RightTapped via HandlerChanged.
|
||||||
|
/// On mobile (no right click): double-tap as the fallback gesture.
|
||||||
|
/// </summary>
|
||||||
private void AttachMessageContextMenu(Border bubble, ChatMessage message)
|
private void AttachMessageContextMenu(Border bubble, ChatMessage message)
|
||||||
{
|
{
|
||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
@@ -424,6 +528,10 @@ public partial class MainPage : ContentPage
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the action sheet shown on right-click of a message: Reply + Copy Link always;
|
||||||
|
/// Edit + Delete only for messages the current user owns.
|
||||||
|
/// </summary>
|
||||||
private async Task ShowMessageContextMenuAsync(ChatMessage message)
|
private async Task ShowMessageContextMenuAsync(ChatMessage message)
|
||||||
{
|
{
|
||||||
if (message.IsDeleted) return;
|
if (message.IsDeleted) return;
|
||||||
@@ -447,6 +555,7 @@ public partial class MainPage : ContentPage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Same gesture wiring as AttachMessageContextMenu, but for sidebar channel buttons.</summary>
|
||||||
private void AttachChannelContextMenu(View target, ChannelItem channel)
|
private void AttachChannelContextMenu(View target, ChannelItem channel)
|
||||||
{
|
{
|
||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
@@ -466,6 +575,7 @@ public partial class MainPage : ContentPage
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Action sheet for right-click on a channel: "View Permissions" always; "Delete Channel" only if CanManage.</summary>
|
||||||
private async Task ShowChannelContextMenuAsync(ChannelItem channel)
|
private async Task ShowChannelContextMenuAsync(ChannelItem channel)
|
||||||
{
|
{
|
||||||
var options = new List<string> { "⚙ View Permissions" };
|
var options = new List<string> { "⚙ View Permissions" };
|
||||||
@@ -485,6 +595,7 @@ public partial class MainPage : ContentPage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Read-only summary dialog of channel meta and the user's resolved permissions. Full per-role editing UI is a future feature.</summary>
|
||||||
private async Task ShowChannelPermissionsAsync(ChannelItem channel)
|
private async Task ShowChannelPermissionsAsync(ChannelItem channel)
|
||||||
{
|
{
|
||||||
var lines = new List<string>
|
var lines = new List<string>
|
||||||
@@ -517,6 +628,7 @@ public partial class MainPage : ContentPage
|
|||||||
if (ok) _socket.SendDeleteChannel(channel.ChannelId);
|
if (ok) _socket.SendDeleteChannel(channel.ChannelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>"+" sidebar button. Walks the user through name → type → group, then fires SendCreateChannel.</summary>
|
||||||
private async void AddChannel_OnClicked(object? sender, EventArgs e)
|
private async void AddChannel_OnClicked(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
var name = await DisplayPromptAsync("New Channel", "Channel name:", "Create", "Cancel",
|
var name = await DisplayPromptAsync("New Channel", "Channel name:", "Create", "Cancel",
|
||||||
@@ -546,6 +658,11 @@ public partial class MainPage : ContentPage
|
|||||||
group);
|
group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void HandleChannelList(SocketChannelList channelList)
|
private void HandleChannelList(SocketChannelList channelList)
|
||||||
{
|
{
|
||||||
_channels.Clear();
|
_channels.Clear();
|
||||||
@@ -581,6 +698,12 @@ public partial class MainPage : ContentPage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void HandleEncryptedChat(SocketEncryptedMessage payload)
|
private void HandleEncryptedChat(SocketEncryptedMessage payload)
|
||||||
{
|
{
|
||||||
if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
|
if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
|
||||||
@@ -639,6 +762,7 @@ public partial class MainPage : ContentPage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True if message.Mentions contains this user's name OR "here" OR "everyone". Drives ping badges and bubble highlight.</summary>
|
||||||
private static bool MessageMentionsMe(ChatMessage message) =>
|
private static bool MessageMentionsMe(ChatMessage message) =>
|
||||||
message.Mentions is not null &&
|
message.Mentions is not null &&
|
||||||
message.Mentions.Any(m =>
|
message.Mentions.Any(m =>
|
||||||
@@ -646,6 +770,11 @@ public partial class MainPage : ContentPage
|
|||||||
string.Equals(m, "here", StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(m, "here", StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(m, "everyone", StringComparison.OrdinalIgnoreCase));
|
string.Equals(m, "everyone", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void HandleMessageEdited(SocketEncryptedMessage payload)
|
private void HandleMessageEdited(SocketEncryptedMessage payload)
|
||||||
{
|
{
|
||||||
if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
|
if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
|
||||||
@@ -659,6 +788,7 @@ public partial class MainPage : ContentPage
|
|||||||
MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message));
|
MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Tombstone handler. Flips IsDeleted on the ChatMessage and rebuilds the bubble to a "🗑 deleted" placeholder.</summary>
|
||||||
private void HandleMessageDeleted(SocketMessageDeletedEvent payload)
|
private void HandleMessageDeleted(SocketMessageDeletedEvent payload)
|
||||||
{
|
{
|
||||||
if (!_messagesById.TryGetValue(payload.MessageId, out var message)) return;
|
if (!_messagesById.TryGetValue(payload.MessageId, out var message)) return;
|
||||||
@@ -668,6 +798,10 @@ public partial class MainPage : ContentPage
|
|||||||
MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message));
|
MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void HandleTyping(SocketTypingEvent payload)
|
private void HandleTyping(SocketTypingEvent payload)
|
||||||
{
|
{
|
||||||
if (payload.ChannelId != _currentChannelId) return;
|
if (payload.ChannelId != _currentChannelId) return;
|
||||||
@@ -697,6 +831,10 @@ public partial class MainPage : ContentPage
|
|||||||
}, cts.Token);
|
}, cts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EditHistory response handler. Decrypts every prior version, formats them with timestamps,
|
||||||
|
/// and dumps into a DisplayAlert. (A scrollable popup is a future polish.)
|
||||||
|
/// </summary>
|
||||||
private void HandleEditHistory(SocketEditHistoryResponse response)
|
private void HandleEditHistory(SocketEditHistoryResponse response)
|
||||||
{
|
{
|
||||||
var entries = new List<(string Text, DateTime At)>();
|
var entries = new List<(string Text, DateTime At)>();
|
||||||
@@ -735,6 +873,11 @@ public partial class MainPage : ContentPage
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private bool TryDecryptAndParseContent(SocketEncryptedMessage payload, out ChatMessageContent content)
|
private bool TryDecryptAndParseContent(SocketEncryptedMessage payload, out ChatMessageContent content)
|
||||||
{
|
{
|
||||||
content = new ChatMessageContent();
|
content = new ChatMessageContent();
|
||||||
@@ -764,12 +907,18 @@ public partial class MainPage : ContentPage
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>MAUI hook: cleanly closes the WebSocket when the user navigates away from MainPage.</summary>
|
||||||
protected override void OnDisappearing()
|
protected override void OnDisappearing()
|
||||||
{
|
{
|
||||||
_socket.Disconnect();
|
_socket.Disconnect();
|
||||||
base.OnDisappearing();
|
base.OnDisappearing();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void RenderChannelList()
|
private void RenderChannelList()
|
||||||
{
|
{
|
||||||
SidebarList.Children.Clear();
|
SidebarList.Children.Clear();
|
||||||
@@ -826,6 +975,12 @@ public partial class MainPage : ContentPage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void OnChannelSelected(ChannelItem channel)
|
private void OnChannelSelected(ChannelItem channel)
|
||||||
{
|
{
|
||||||
_currentChannelId = channel.ChannelId;
|
_currentChannelId = channel.ChannelId;
|
||||||
@@ -869,6 +1024,10 @@ public partial class MainPage : ContentPage
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private void UpdateInputForCurrentChannel()
|
private void UpdateInputForCurrentChannel()
|
||||||
{
|
{
|
||||||
var channel = _channels.FirstOrDefault(c => c.ChannelId == _currentChannelId);
|
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.";
|
: "This channel is read-only — you don't have permission to post here.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Cancels every pending typing-clear timer and hides the typing label. Called on channel switch.</summary>
|
||||||
private void ClearTypingIndicator()
|
private void ClearTypingIndicator()
|
||||||
{
|
{
|
||||||
foreach (var cts in _typingClearTimers.Values) cts.Cancel();
|
foreach (var cts in _typingClearTimers.Values) cts.Cancel();
|
||||||
@@ -893,6 +1053,7 @@ public partial class MainPage : ContentPage
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Full re-render of the current channel's scrollback. Called on channel switch or when history first arrives.</summary>
|
||||||
private void RenderCurrentChannelMessages()
|
private void RenderCurrentChannelMessages()
|
||||||
{
|
{
|
||||||
MessagesLayout.Children.Clear();
|
MessagesLayout.Children.Clear();
|
||||||
@@ -904,6 +1065,12 @@ public partial class MainPage : ContentPage
|
|||||||
|
|
||||||
private void SwapView_OnClicked(object? sender, EventArgs e) { }
|
private void SwapView_OnClicked(object? sender, EventArgs e) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private async void RenderSingleMessage(ChatMessage message)
|
private async void RenderSingleMessage(ChatMessage message)
|
||||||
{
|
{
|
||||||
bool isOwn = message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase);
|
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);
|
await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, animated: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>O(1) bubble update — swaps the Border's Content for a freshly-built one. Called by edit/delete handlers.</summary>
|
||||||
private void RebuildBubbleContent(Border bubble, ChatMessage message)
|
private void RebuildBubbleContent(Border bubble, ChatMessage message)
|
||||||
{
|
{
|
||||||
bubble.Content = BuildBubbleContent(message);
|
bubble.Content = BuildBubbleContent(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private View BuildBubbleContent(ChatMessage message)
|
private View BuildBubbleContent(ChatMessage message)
|
||||||
{
|
{
|
||||||
if (message.IsDeleted)
|
if (message.IsDeleted)
|
||||||
@@ -1039,6 +1213,11 @@ public partial class MainPage : ContentPage
|
|||||||
return stack;
|
return stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void WireJumpLinks(View view)
|
private void WireJumpLinks(View view)
|
||||||
{
|
{
|
||||||
if (view is Label lbl)
|
if (view is Label lbl)
|
||||||
@@ -1068,6 +1247,7 @@ public partial class MainPage : ContentPage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Scrolls the bubble for the given messageId into view. No-op if the bubble isn't in this channel's render set.</summary>
|
||||||
private void ScrollToMessage(string messageId)
|
private void ScrollToMessage(string messageId)
|
||||||
{
|
{
|
||||||
if (!_messageBubbles.TryGetValue(messageId, out var bubble)) return;
|
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));
|
await MessagesScrollView.ScrollToAsync(bubble, ScrollToPosition.Start, animated: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Fires SendGetEditHistory. Server replies asynchronously with SocketEditHistoryResponse → HandleEditHistory.</summary>
|
||||||
private void RequestEditHistory(ChatMessage message)
|
private void RequestEditHistory(ChatMessage message)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(message.MessageId)) return;
|
if (string.IsNullOrWhiteSpace(message.MessageId)) return;
|
||||||
_socket.SendGetEditHistory(message.MessageId, _currentChannelId ?? string.Empty);
|
_socket.SendGetEditHistory(message.MessageId, _currentChannelId ?? string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e)
|
private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Message == "rtc_page_ready")
|
if (e.Message == "rtc_page_ready")
|
||||||
@@ -1091,6 +1277,7 @@ public partial class MainPage : ContentPage
|
|||||||
SafeSendRawToWebView($"JS → C#: {e.Message}");
|
SafeSendRawToWebView($"JS → C#: {e.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends a message into the WebView with the call dispatched onto the UI thread. Swallows + logs send errors.</summary>
|
||||||
private void SafeSendRawToWebView(string message)
|
private void SafeSendRawToWebView(string message)
|
||||||
{
|
{
|
||||||
MainThread.BeginInvokeOnMainThread(() =>
|
MainThread.BeginInvokeOnMainThread(() =>
|
||||||
@@ -1100,6 +1287,11 @@ public partial class MainPage : ContentPage
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side message model. Decrypted ChatMessageContent plus server-supplied metadata
|
||||||
|
/// (MessageId, IsEdited, IsDeleted) and client-side state (Timestamp, mutable Text/IsEdited
|
||||||
|
/// for edit handling).
|
||||||
|
/// </summary>
|
||||||
public class ChatMessage
|
public class ChatMessage
|
||||||
{
|
{
|
||||||
public string MessageId { get; set; } = string.Empty;
|
public string MessageId { get; set; } = string.Empty;
|
||||||
@@ -1117,6 +1309,7 @@ public partial class MainPage : ContentPage
|
|||||||
public bool IsDeleted { get; set; }
|
public bool IsDeleted { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Button subclass that carries the channel's Type/Group, used so the sidebar can re-render with metadata.</summary>
|
||||||
public class ChannelButton : Button
|
public class ChannelButton : Button
|
||||||
{
|
{
|
||||||
public ChannelType Type { get; set; }
|
public ChannelType Type { get; set; }
|
||||||
|
|||||||
@@ -5,24 +5,69 @@ using WebSocketSharp;
|
|||||||
|
|
||||||
namespace RelayClient.Services;
|
namespace RelayClient.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public sealed class RelaySocketClient
|
public sealed class RelaySocketClient
|
||||||
{
|
{
|
||||||
|
/// <summary>Username this socket is authenticated as. Captured at construction.</summary>
|
||||||
private readonly string _username;
|
private readonly string _username;
|
||||||
|
|
||||||
|
/// <summary>The underlying WebSocketSharp client. Owned (constructed) by this class.</summary>
|
||||||
private readonly WebSocket _socket;
|
private readonly WebSocket _socket;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The server's RSA public key, cached after the first GetServerKey response.
|
||||||
|
/// MainPage reads this to encrypt outbound chat payloads.
|
||||||
|
/// </summary>
|
||||||
public string? ServerPublicKey { get; private set; }
|
public string? ServerPublicKey { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Fires for every raw incoming text frame. Mostly used for debug logging.</summary>
|
||||||
public event Action<string>? RawMessageReceived;
|
public event Action<string>? RawMessageReceived;
|
||||||
|
|
||||||
|
/// <summary>Fires when the server pushes a fresh channel list (initial connect or after CRUD).</summary>
|
||||||
public event Action<SocketChannelList>? ChannelListReceived;
|
public event Action<SocketChannelList>? ChannelListReceived;
|
||||||
|
|
||||||
|
/// <summary>Fires for newly-arrived chat messages (SignalType.EncryptedChat).</summary>
|
||||||
public event Action<SocketEncryptedMessage>? EncryptedChatReceived;
|
public event Action<SocketEncryptedMessage>? EncryptedChatReceived;
|
||||||
|
|
||||||
|
/// <summary>Fires when an existing message is edited by its author (SignalType.MessageEdited).</summary>
|
||||||
public event Action<SocketEncryptedMessage>? MessageEdited;
|
public event Action<SocketEncryptedMessage>? MessageEdited;
|
||||||
|
|
||||||
|
/// <summary>Fires when a message is deleted (SignalType.MessageDeleted).</summary>
|
||||||
public event Action<SocketMessageDeletedEvent>? MessageDeleted;
|
public event Action<SocketMessageDeletedEvent>? MessageDeleted;
|
||||||
|
|
||||||
|
/// <summary>Fires when another user is typing in a channel.</summary>
|
||||||
public event Action<SocketTypingEvent>? TypingReceived;
|
public event Action<SocketTypingEvent>? TypingReceived;
|
||||||
|
|
||||||
|
/// <summary>Fires in response to a SendGetEditHistory request.</summary>
|
||||||
public event Action<SocketEditHistoryResponse>? EditHistoryReceived;
|
public event Action<SocketEditHistoryResponse>? EditHistoryReceived;
|
||||||
|
|
||||||
|
/// <summary>Fires for encrypted RTC SDP/ICE signals — RtcBridgeService forwards into the JS engine.</summary>
|
||||||
public event Action<SocketRtcSignalMessage>? EncryptedRtcSignalReceived;
|
public event Action<SocketRtcSignalMessage>? EncryptedRtcSignalReceived;
|
||||||
|
|
||||||
|
/// <summary>Fires once when the server's public key arrives. Mainly used by tests; production reads ServerPublicKey directly.</summary>
|
||||||
public event Action<string>? ServerPublicKeyReceived;
|
public event Action<string>? ServerPublicKeyReceived;
|
||||||
|
|
||||||
|
/// <summary>Diagnostic logger. MainPage subscribes Console.WriteLine here.</summary>
|
||||||
public event Action<string>? Log;
|
public event Action<string>? Log;
|
||||||
|
|
||||||
|
/// <summary>Default URL points at localhost dev server. Production passes a remote URL.</summary>
|
||||||
public RelaySocketClient(string username, string url = "ws://127.0.0.1:5001/")
|
public RelaySocketClient(string username, string url = "ws://127.0.0.1:5001/")
|
||||||
{
|
{
|
||||||
_username = username;
|
_username = username;
|
||||||
@@ -30,6 +75,12 @@ public sealed class RelaySocketClient
|
|||||||
_socket.OnMessage += OnMessage;
|
_socket.OnMessage += OnMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
public void Connect()
|
public void Connect()
|
||||||
{
|
{
|
||||||
_socket.Connect();
|
_socket.Connect();
|
||||||
@@ -42,6 +93,7 @@ public sealed class RelaySocketClient
|
|||||||
SendControlMessage(new WsControlMessage { Action = WsAction.GetChannels });
|
SendControlMessage(new WsControlMessage { Action = WsAction.GetChannels });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Detaches the message handler and closes the socket. Called from MainPage.OnDisappearing.</summary>
|
||||||
public void Disconnect()
|
public void Disconnect()
|
||||||
{
|
{
|
||||||
_socket.OnMessage -= OnMessage;
|
_socket.OnMessage -= OnMessage;
|
||||||
@@ -49,24 +101,31 @@ public sealed class RelaySocketClient
|
|||||||
_socket.Close();
|
_socket.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Generic control-plane send. Serialises the WsControlMessage to JSON and ships it.</summary>
|
||||||
public void SendControlMessage(WsControlMessage message) =>
|
public void SendControlMessage(WsControlMessage message) =>
|
||||||
SendRaw(JsonSerializer.Serialize(message));
|
SendRaw(JsonSerializer.Serialize(message));
|
||||||
|
|
||||||
|
/// <summary>Request the message history for a channel. Server streams it back as individual EncryptedChat frames.</summary>
|
||||||
public void SendGetHistory(string channelId) =>
|
public void SendGetHistory(string channelId) =>
|
||||||
SendControlMessage(new WsControlMessage { Action = WsAction.GetHistory, Username = _username, ChannelId = channelId });
|
SendControlMessage(new WsControlMessage { Action = WsAction.GetHistory, Username = _username, ChannelId = channelId });
|
||||||
|
|
||||||
|
/// <summary>Tell the server we've joined a voice channel. Fires Speak permission check server-side.</summary>
|
||||||
public void SendRtcJoinChannel(string channelId) =>
|
public void SendRtcJoinChannel(string channelId) =>
|
||||||
SendControlMessage(new WsControlMessage { Action = WsAction.RtcJoin, Username = _username, ChannelId = channelId });
|
SendControlMessage(new WsControlMessage { Action = WsAction.RtcJoin, Username = _username, ChannelId = channelId });
|
||||||
|
|
||||||
|
/// <summary>Tell the server we've left the voice channel. Idempotent server-side.</summary>
|
||||||
public void SendRtcLeaveChannel(string channelId) =>
|
public void SendRtcLeaveChannel(string channelId) =>
|
||||||
SendControlMessage(new WsControlMessage { Action = WsAction.RtcLeave, Username = _username, ChannelId = channelId });
|
SendControlMessage(new WsControlMessage { Action = WsAction.RtcLeave, Username = _username, ChannelId = channelId });
|
||||||
|
|
||||||
|
/// <summary>Notify channel peers that we're typing. Server broadcasts a SocketTypingEvent to everyone but us.</summary>
|
||||||
public void SendTyping(string channelId) =>
|
public void SendTyping(string channelId) =>
|
||||||
SendControlMessage(new WsControlMessage { Action = WsAction.SendTyping, Username = _username, ChannelId = channelId });
|
SendControlMessage(new WsControlMessage { Action = WsAction.SendTyping, Username = _username, ChannelId = channelId });
|
||||||
|
|
||||||
|
/// <summary>Request all historical versions of a message. Server replies with SocketEditHistoryResponse.</summary>
|
||||||
public void SendGetEditHistory(string messageId, string channelId) =>
|
public void SendGetEditHistory(string messageId, string channelId) =>
|
||||||
SendControlMessage(new WsControlMessage { Action = WsAction.GetEditHistory, Username = _username, MessageId = messageId, ChannelId = channelId });
|
SendControlMessage(new WsControlMessage { Action = WsAction.GetEditHistory, Username = _username, MessageId = messageId, ChannelId = channelId });
|
||||||
|
|
||||||
|
/// <summary>Create a new channel. Permission-gated server-side; on success the server broadcasts a fresh channel list.</summary>
|
||||||
public void SendCreateChannel(string name, ChannelType type, string group = "") =>
|
public void SendCreateChannel(string name, ChannelType type, string group = "") =>
|
||||||
SendControlMessage(new WsControlMessage
|
SendControlMessage(new WsControlMessage
|
||||||
{
|
{
|
||||||
@@ -76,9 +135,14 @@ public sealed class RelaySocketClient
|
|||||||
ChannelGroup = group
|
ChannelGroup = group
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// <summary>Soft-delete a channel. Permission-gated server-side.</summary>
|
||||||
public void SendDeleteChannel(string channelId) =>
|
public void SendDeleteChannel(string channelId) =>
|
||||||
SendControlMessage(new WsControlMessage { Action = WsAction.DeleteChannel, ChannelId = channelId });
|
SendControlMessage(new WsControlMessage { Action = WsAction.DeleteChannel, ChannelId = channelId });
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public void SendEditMessage(string messageId, string channelId, EncryptedPayload encrypted) =>
|
public void SendEditMessage(string messageId, string channelId, EncryptedPayload encrypted) =>
|
||||||
SendJson(new SocketEncryptedMessage
|
SendJson(new SocketEncryptedMessage
|
||||||
{
|
{
|
||||||
@@ -88,6 +152,7 @@ public sealed class RelaySocketClient
|
|||||||
Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey
|
Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// <summary>Request soft-delete of one of our own messages. Server checks ownership before honoring.</summary>
|
||||||
public void SendDeleteMessage(string messageId, string channelId) =>
|
public void SendDeleteMessage(string messageId, string channelId) =>
|
||||||
SendJson(new SocketEncryptedMessage
|
SendJson(new SocketEncryptedMessage
|
||||||
{
|
{
|
||||||
@@ -95,6 +160,11 @@ public sealed class RelaySocketClient
|
|||||||
SenderUsername = _username, ChannelId = channelId
|
SenderUsername = _username, ChannelId = channelId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public void SendRaw(string message)
|
public void SendRaw(string message)
|
||||||
{
|
{
|
||||||
if (_socket.ReadyState != WebSocketState.Open)
|
if (_socket.ReadyState != WebSocketState.Open)
|
||||||
@@ -114,8 +184,15 @@ public sealed class RelaySocketClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Convenience: JSON-serialise any payload and ship it. Used for all SocketEncryptedMessage and WsControlMessage sends.</summary>
|
||||||
public void SendJson<T>(T payload) => SendRaw(JsonSerializer.Serialize(payload));
|
public void SendJson<T>(T payload) => SendRaw(JsonSerializer.Serialize(payload));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void OnMessage(object? sender, MessageEventArgs e)
|
private void OnMessage(object? sender, MessageEventArgs e)
|
||||||
{
|
{
|
||||||
RawMessageReceived?.Invoke(e.Data);
|
RawMessageReceived?.Invoke(e.Data);
|
||||||
|
|||||||
@@ -6,14 +6,39 @@ using RelayShared.Services;
|
|||||||
|
|
||||||
namespace RelayClient.Services;
|
namespace RelayClient.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public sealed class RtcBridgeService
|
public sealed class RtcBridgeService
|
||||||
{
|
{
|
||||||
|
/// <summary>The currently-signed-in username. Stamped onto outgoing RTC signals.</summary>
|
||||||
private readonly string _username;
|
private readonly string _username;
|
||||||
|
|
||||||
|
/// <summary>The shared WebSocket to RelayServer. Outbound RTC signals ride on this.</summary>
|
||||||
private readonly RelaySocketClient _socket;
|
private readonly RelaySocketClient _socket;
|
||||||
|
|
||||||
|
/// <summary>The MAUI HybridWebView that hosts the JS WebRTC engine. We push JS calls into it.</summary>
|
||||||
private readonly HybridWebView _hybridWebView;
|
private readonly HybridWebView _hybridWebView;
|
||||||
|
|
||||||
|
/// <summary>Lazy view into MainPage._currentChannelId so we always have the current voice channel.</summary>
|
||||||
private readonly Func<string?> _getCurrentChannelId;
|
private readonly Func<string?> _getCurrentChannelId;
|
||||||
|
|
||||||
|
/// <summary>Diagnostic logger that surfaces messages back to the WebView UI. Used for status/error reporting.</summary>
|
||||||
private readonly Action<string> _sendRawToWebView;
|
private readonly Action<string> _sendRawToWebView;
|
||||||
|
|
||||||
|
/// <summary>Captures collaborators. MainPage constructs this once and never replaces it.</summary>
|
||||||
public RtcBridgeService(string username, RelaySocketClient socket, HybridWebView hybridWebView,
|
public RtcBridgeService(string username, RelaySocketClient socket, HybridWebView hybridWebView,
|
||||||
Func<string?> getCurrentChannelId, Action<string> sendRawToWebView)
|
Func<string?> getCurrentChannelId, Action<string> sendRawToWebView)
|
||||||
{
|
{
|
||||||
@@ -24,6 +49,7 @@ public sealed class RtcBridgeService
|
|||||||
_sendRawToWebView = sendRawToWebView;
|
_sendRawToWebView = sendRawToWebView;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends RtcJoin for the currently-selected channel. Server-side, this triggers the Speak permission check and presence registration.</summary>
|
||||||
public Task JoinRtcChannel()
|
public Task JoinRtcChannel()
|
||||||
{
|
{
|
||||||
var channelId = _getCurrentChannelId();
|
var channelId = _getCurrentChannelId();
|
||||||
@@ -35,6 +61,7 @@ public sealed class RtcBridgeService
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends RtcLeave for the currently-selected channel. Clears server-side voice presence so peers stop seeing us.</summary>
|
||||||
public void LeaveRtcChannel()
|
public void LeaveRtcChannel()
|
||||||
{
|
{
|
||||||
var channelId = _getCurrentChannelId();
|
var channelId = _getCurrentChannelId();
|
||||||
@@ -45,6 +72,13 @@ public sealed class RtcBridgeService
|
|||||||
_socket.SendRtcLeaveChannel(channelId);
|
_socket.SendRtcLeaveChannel(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public void SendRtcSignal(string json)
|
public void SendRtcSignal(string json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey))
|
if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey))
|
||||||
@@ -105,6 +139,7 @@ public sealed class RtcBridgeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>JS bridge: returns the current voice-channel roster as JSON. Hits ServerAPI's REST endpoint, not the WebSocket.</summary>
|
||||||
public async Task<string> GetRtcParticipants()
|
public async Task<string> GetRtcParticipants()
|
||||||
{
|
{
|
||||||
var channelId = _getCurrentChannelId();
|
var channelId = _getCurrentChannelId();
|
||||||
@@ -116,6 +151,11 @@ public sealed class RtcBridgeService
|
|||||||
return JsonSerializer.Serialize(participants ?? []);
|
return JsonSerializer.Serialize(participants ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public async Task HandleIncomingRtcSignalAsync(SocketRtcSignalMessage payload)
|
public async Task HandleIncomingRtcSignalAsync(SocketRtcSignalMessage payload)
|
||||||
{
|
{
|
||||||
// _sendRawToWebView("HandleIncomingRtcSignal called");
|
// _sendRawToWebView("HandleIncomingRtcSignal called");
|
||||||
@@ -187,6 +227,10 @@ public sealed class RtcBridgeService
|
|||||||
await SendRtcSignalToJsAsync(rtcSignal);
|
await SendRtcSignalToJsAsync(rtcSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public Task PushRtcContextToJsAsync()
|
public Task PushRtcContextToJsAsync()
|
||||||
{
|
{
|
||||||
MainThread.BeginInvokeOnMainThread(async () =>
|
MainThread.BeginInvokeOnMainThread(async () =>
|
||||||
@@ -201,6 +245,11 @@ public sealed class RtcBridgeService
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private Task SendRtcSignalToJsAsync(RtcSignalMessage data)
|
private Task SendRtcSignalToJsAsync(RtcSignalMessage data)
|
||||||
{
|
{
|
||||||
if (data.Type == "rtc_offer" || data.Type == "rtc_answer")
|
if (data.Type == "rtc_offer" || data.Type == "rtc_answer")
|
||||||
|
|||||||
@@ -2,11 +2,24 @@ using SurrealDb.Net.Models;
|
|||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public class ChannelMessageEdits : Record
|
public class ChannelMessageEdits : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>"channel_messages:abc" — which live message this version belonged to.</summary>
|
||||||
public required string MessageId { get; set; }
|
public required string MessageId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM ciphertext of the JSON-serialised previous ChatMessageContent.</summary>
|
||||||
public required string CipherText { get; set; }
|
public required string CipherText { get; set; }
|
||||||
|
|
||||||
public required string Nonce { get; set; }
|
public required string Nonce { get; set; }
|
||||||
public required string Tag { get; set; }
|
public required string Tag { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this version was the current text (i.e. when it was replaced).</summary>
|
||||||
public required DateTime EditedAt { get; set; }
|
public required DateTime EditedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,36 @@ using SurrealDb.Net.Models;
|
|||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public class ChannelMessages : Record
|
public class ChannelMessages : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>"channels:xyz" — which channel this belongs to.</summary>
|
||||||
public required string ChannelId { get; set; }
|
public required string ChannelId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"users:keeper317" — who wrote it. Lowercased to match CoreClientService's id format.</summary>
|
||||||
public required string SenderUserId { get; set; }
|
public required string SenderUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM ciphertext of the JSON-serialised ChatMessageContent.</summary>
|
||||||
public required string CipherText { get; set; }
|
public required string CipherText { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM 96-bit nonce. Different every message.</summary>
|
||||||
public required string Nonce { get; set; }
|
public required string Nonce { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM 128-bit authentication tag.</summary>
|
||||||
public required string Tag { get; set; }
|
public required string Tag { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp of original send. Drives history ordering.</summary>
|
||||||
public required DateTime CreatedAt { get; set; }
|
public required DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp of last edit. Null = never edited. Drives the (edited) bubble footer.</summary>
|
||||||
public DateTime? EditedAt { get; set; }
|
public DateTime? EditedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Soft-delete flag. Tombstones in history responses; bubbles show "deleted" placeholder.</summary>
|
||||||
public bool IsDeleted { get; set; }
|
public bool IsDeleted { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,38 @@ using RelayShared.Services;
|
|||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
public class Channels : Record
|
public class Channels : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>Sidebar display name. Lowercased and dash-separated for new channels.</summary>
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Creation timestamp. Drives sidebar sort order.</summary>
|
||||||
public required DateTime CreatedAt { get; set; }
|
public required DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Drives client rendering and server routing — Text/Voice/File/Forum/Stage.</summary>
|
||||||
public ChannelType Type { get; set; } = ChannelType.Text;
|
public ChannelType Type { get; set; } = ChannelType.Text;
|
||||||
|
|
||||||
|
/// <summary>Sidebar category header (e.g. "General"). Empty means default group.</summary>
|
||||||
public string Group { get; set; } = string.Empty;
|
public string Group { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True for announcement-style channels (#welcome, #files). Non-admins are blocked from
|
||||||
|
/// posting via PermissionService.CanSendMessagesAsync.
|
||||||
|
/// </summary>
|
||||||
public bool IsReadOnly { get; set; }
|
public bool IsReadOnly { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Soft-delete flag. Filtered out of channel-list builds in BuildChannelListForUser.</summary>
|
||||||
public bool IsDeleted { get; set; }
|
public bool IsDeleted { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Surreal record id of a File channel ("channels:xyz"). When set, ChatSocketBehavior's
|
||||||
|
/// MirrorAttachmentIfNeeded auto-copies non-gif attachments into the linked channel.
|
||||||
|
/// </summary>
|
||||||
public string? LinkedFileChannelId { get; set; }
|
public string? LinkedFileChannelId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
using SurrealDb.Net.Models;
|
using SurrealDb.Net.Models;
|
||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
public class ClientPublicKeys : Record
|
public class ClientPublicKeys : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>Mixed-case username as the user registered it. Used as the lookup key.</summary>
|
||||||
public required string Username { get; set; }
|
public required string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 SubjectPublicKeyInfo (DER) of the user's RSA public key.</summary>
|
||||||
public required string PublicKey { get; set; }
|
public required string PublicKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the user first registered.</summary>
|
||||||
public required DateTime CreatedAt { get; set; }
|
public required DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the key was last updated (key rotation, reinstall).</summary>
|
||||||
public required DateTime UpdatedAt { get; set; }
|
public required DateTime UpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,28 @@ using SurrealDb.Net.Models;
|
|||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public class ServerEncryptionKeys : Record
|
public class ServerEncryptionKeys : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>Base64 AES-256 key used by ChannelCryptoService for at-rest message encryption.</summary>
|
||||||
public required string KeyBase64 { get; set; }
|
public required string KeyBase64 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 SubjectPublicKeyInfo of the server's RSA public key. Sent to clients on GetServerKey.</summary>
|
||||||
public required string PublicKey { get; set; }
|
public required string PublicKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 PKCS8 of the server's RSA private key. Never leaves the server.</summary>
|
||||||
public required string PrivateKey { get; set; }
|
public required string PrivateKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the keys were generated.</summary>
|
||||||
public required DateTime CreatedAt { get; set; }
|
public required DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the keys were last rotated. Currently same as CreatedAt — rotation isn't implemented.</summary>
|
||||||
public required DateTime UpdatedAt { get; set; }
|
public required DateTime UpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,24 @@ using SurrealDb.Net.Models;
|
|||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public class ChannelPermissions : Record
|
public class ChannelPermissions : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>"channels:xyz" — which channel this override applies in.</summary>
|
||||||
public required string ChannelId { get; set; }
|
public required string ChannelId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"roles:abc" — which role this override applies to.</summary>
|
||||||
public required string RoleId { get; set; }
|
public required string RoleId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Permissions explicitly granted here (overrides "role doesn't have it" for this channel).</summary>
|
||||||
public PermissionFlags Allow { get; set; }
|
public PermissionFlags Allow { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Permissions explicitly denied here. Wins over Allow.</summary>
|
||||||
public PermissionFlags Deny { get; set; }
|
public PermissionFlags Deny { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ using SurrealDb.Net.Models;
|
|||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum PermissionFlags
|
public enum PermissionFlags
|
||||||
{
|
{
|
||||||
@@ -18,11 +30,21 @@ public enum PermissionFlags
|
|||||||
DeleteChannel = 1 << 9 // Delete a channel
|
DeleteChannel = 1 << 9 // Delete a channel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Surreal record for the `roles` table. Defines a named permission bundle that can be
|
||||||
|
/// assigned to users via UserRoles.
|
||||||
|
/// </summary>
|
||||||
public class Roles : Record
|
public class Roles : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>Display name ("Admin", "Moderator", "Member").</summary>
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base permission bitfield. Channel-level overrides in ChannelPermissions can add or remove.</summary>
|
||||||
public required PermissionFlags Permissions { get; set; }
|
public required PermissionFlags Permissions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the role was seeded.</summary>
|
||||||
public required DateTime CreatedAt { get; set; }
|
public required DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tie-breaker for future multi-role-per-user scenarios. Lower = higher priority. Not used by the current ladder.</summary>
|
||||||
public int Priority { get; set; }
|
public int Priority { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,22 @@ using SurrealDb.Net.Models;
|
|||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public class ServerMembers : Record
|
public class ServerMembers : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>"users:keeper317" — references the Core users table by name convention.</summary>
|
||||||
public required string UserId { get; set; }
|
public required string UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the user was added to this server.</summary>
|
||||||
public required DateTime JoinedAt { get; set; }
|
public required DateTime JoinedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authoritative owner flag. Owner gets unconditional Administrator via
|
||||||
|
/// PermissionService.IsServerOwnerAsync, independent of role assignments.
|
||||||
|
/// </summary>
|
||||||
public bool IsOwner { get; set; }
|
public bool IsOwner { get; set; }
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,18 @@ using SurrealDb.Net.Models;
|
|||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Surreal record for the `servers` table. Currently single-row (one server per deployment),
|
||||||
|
/// but the schema supports multi-server in the future.
|
||||||
|
/// </summary>
|
||||||
public class Servers : Record
|
public class Servers : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>Display name (currently "Test Server" from bootstrap).</summary>
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"users:keeper317" — the owner. Mirrored as IsOwner=true on the matching ServerMembers row.</summary>
|
||||||
public required string OwnerUserId { get; set; }
|
public required string OwnerUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Server creation timestamp.</summary>
|
||||||
public required DateTime CreatedAt { get; set; }
|
public required DateTime CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,21 @@ using SurrealDb.Net.Models;
|
|||||||
|
|
||||||
namespace RelayServer.Models;
|
namespace RelayServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public class UserRoles : Record
|
public class UserRoles : Record
|
||||||
{
|
{
|
||||||
|
/// <summary>"users:keeper317" — the assignee.</summary>
|
||||||
public required string UserId { get; set; }
|
public required string UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"roles:abc" — the role being granted.</summary>
|
||||||
public required string RoleId { get; set; }
|
public required string RoleId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the assignment was made.</summary>
|
||||||
public required DateTime AssignedAt { get; set; }
|
public required DateTime AssignedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Endpoints;
|
||||||
using RelayServer.Services.Chat;
|
using RelayServer.Services.Chat;
|
||||||
using RelayServer.Services.Core;
|
using RelayServer.Services.Core;
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ using System.Text;
|
|||||||
|
|
||||||
namespace RelayServer.Services.Chat;
|
namespace RelayServer.Services.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AES-GCM-256 only (no RSA). Used exclusively for "at-rest" encryption of channel messages
|
||||||
|
/// in the SurrealDB channel_messages table.
|
||||||
|
///
|
||||||
|
/// Why a separate service from E2EeHelper:
|
||||||
|
/// - E2EeHelper is for *transit* between a specific sender and a specific recipient — it
|
||||||
|
/// wraps an ephemeral AES key with the recipient's RSA public key.
|
||||||
|
/// - ChannelCryptoService is for *storage* — the server is both the encryptor and the
|
||||||
|
/// decryptor, and it stores the symmetric channel key in server_encryption_keys.KeyBase64.
|
||||||
|
/// There's no recipient to wrap for.
|
||||||
|
///
|
||||||
|
/// Server flow for a chat message:
|
||||||
|
/// incoming SocketEncryptedMessage (encrypted with server's RSA public key, by client)
|
||||||
|
/// → E2EeHelper.DecryptForRecipient(serverPrivateKey) → plaintext
|
||||||
|
/// → ChannelCryptoService.Encrypt(channelDbKey) → stored ciphertext
|
||||||
|
/// → … later, on history fetch …
|
||||||
|
/// → ChannelCryptoService.Decrypt(channelDbKey) → plaintext
|
||||||
|
/// → E2EeHelper.EncryptForRecipient(clientPublicKey) → delivered ciphertext
|
||||||
|
/// </summary>
|
||||||
public sealed class ChannelCryptoService
|
public sealed class ChannelCryptoService
|
||||||
{
|
{
|
||||||
public string GenerateKey()
|
public string GenerateKey()
|
||||||
|
|||||||
@@ -12,19 +12,68 @@ using RelayShared.Services;
|
|||||||
namespace RelayServer.Services.Chat;
|
namespace RelayServer.Services.Chat;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles all WebSocket traffic: authentication, key registration, channel management,
|
/// The server-side WebSocket endpoint. Every client connection creates one instance of this
|
||||||
/// encrypted chat relay, message editing/deletion, typing indicators, and edit history.
|
/// class. WebSocketSharp owns the lifecycle: it constructs the behavior, calls OnMessage for
|
||||||
|
/// each incoming frame, and calls OnClose when the connection drops.
|
||||||
|
///
|
||||||
|
/// MESSAGE FLOW (data plane — chat message):
|
||||||
|
/// 1. Client sends a SocketEncryptedMessage with SignalType.ClientEncryptedChat.
|
||||||
|
/// Payload is JSON-serialised ChatMessageContent, encrypted with the server's public key.
|
||||||
|
/// 2. OnMessage parses the JSON, identifies Type, routes to HandleEncryptedChatMessage.
|
||||||
|
/// 3. Permission check via PermissionService.CanSendMessagesAsync.
|
||||||
|
/// 4. Decrypt with ServerPrivateKey → get plaintext JSON.
|
||||||
|
/// 5. Re-encrypt with ChannelDbKey (AES-GCM only, no RSA) → store in channel_messages table.
|
||||||
|
/// 6. For each connected server member: re-encrypt with their client public key, deliver
|
||||||
|
/// via Sessions.SendTo to every one of their active sessions (multi-device).
|
||||||
|
/// 7. If the origin channel has LinkedFileChannelId set, MirrorAttachmentIfNeeded also
|
||||||
|
/// stores+delivers a trimmed copy into the linked File channel.
|
||||||
|
///
|
||||||
|
/// MESSAGE FLOW (control plane — e.g. CreateChannel):
|
||||||
|
/// 1. Client sends a WsControlMessage with Action=CreateChannel.
|
||||||
|
/// 2. OnMessage sees the "Action" JSON property, routes via DispatchControl.
|
||||||
|
/// 3. Permission check, DB write, then BroadcastChannelList rebuilds the channel list per
|
||||||
|
/// user (because CanPost/CanManage are computed per-user) and pushes it to everyone.
|
||||||
|
///
|
||||||
|
/// STATE STORES used here:
|
||||||
|
/// - ConnectedClientService: session ↔ username mapping (in-memory, multi-device aware).
|
||||||
|
/// Populated by HandleRegisterKey, cleared by OnClose.
|
||||||
|
/// - RtcChannelPresenceService: session ↔ voice channel mapping. Populated by RtcJoin,
|
||||||
|
/// cleared by RtcLeave / OnClose.
|
||||||
|
/// - SurrealDB tables: channel_messages, channels, server_members, roles, user_roles,
|
||||||
|
/// channel_permissions, client_public_keys, server_encryption_keys, channel_message_edits.
|
||||||
|
///
|
||||||
|
/// CRITICAL invariant: this class is constructed by WebSocketSharp and has no constructor
|
||||||
|
/// hook for DI, so ALL services are static (set once by Program.cs at boot).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ChatSocketBehavior : WebSocketBehavior
|
public class ChatSocketBehavior : WebSocketBehavior
|
||||||
{
|
{
|
||||||
|
/// <summary>Reads/writes the client_public_keys table. Wired by Program.cs at boot.</summary>
|
||||||
public static ClientKeyService? ClientKeyService { get; set; }
|
public static ClientKeyService? ClientKeyService { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The permission ladder evaluator. Wired by Program.cs at boot.</summary>
|
||||||
public static PermissionService? PermissionService { get; set; }
|
public static PermissionService? PermissionService { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 RSA public key — clients use this to encrypt outbound payloads to the server.</summary>
|
||||||
public static string? ServerPublicKey { get; set; }
|
public static string? ServerPublicKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 RSA private key — used to decrypt inbound payloads. Never leaves the server.</summary>
|
||||||
public static string? ServerPrivateKey { get; set; }
|
public static string? ServerPrivateKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-256 key for at-rest encryption of channel_messages.CipherText rows.</summary>
|
||||||
public static string? ChannelDbKey { get; set; }
|
public static string? ChannelDbKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>AES-GCM-only encryption for stored messages. Wired by Program.cs at boot.</summary>
|
||||||
public static ChannelCryptoService? ChannelCryptoService { get; set; }
|
public static ChannelCryptoService? ChannelCryptoService { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The SurrealDB connection. Wired by Program.cs at boot.</summary>
|
||||||
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
|
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebSocketSharp callback fired for every incoming text frame. Peeks the JSON to identify
|
||||||
|
/// "Action" (control-plane) vs "Type" (data-plane), then routes to the right handler.
|
||||||
|
/// All exceptions are caught and logged — they MUST NOT propagate or WebSocketSharp will
|
||||||
|
/// drop the connection.
|
||||||
|
/// </summary>
|
||||||
protected override void OnMessage(MessageEventArgs e)
|
protected override void OnMessage(MessageEventArgs e)
|
||||||
{
|
{
|
||||||
var msg = e.Data;
|
var msg = e.Data;
|
||||||
@@ -62,6 +111,7 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Switches on WsAction to the matching Handle* method. Pure routing — no I/O.</summary>
|
||||||
private void DispatchControl(WsAction action, WsControlMessage c)
|
private void DispatchControl(WsAction action, WsControlMessage c)
|
||||||
{
|
{
|
||||||
switch (action)
|
switch (action)
|
||||||
@@ -81,6 +131,14 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a Core-issued user token against the Core service. The HTTP call is wrapped in
|
||||||
|
/// try/catch so that a Core outage doesn't drop the chat session — we still ack with
|
||||||
|
/// WsEvent.Authenticated so the rest of the boot handshake can proceed.
|
||||||
|
///
|
||||||
|
/// NOTE async void here is unavoidable (it's an event handler) but every exception path
|
||||||
|
/// must be caught locally or WebSocketSharp will tear down the session.
|
||||||
|
/// </summary>
|
||||||
private async void HandleAuthenticate(WsControlMessage c)
|
private async void HandleAuthenticate(WsControlMessage c)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.Token))
|
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 }));
|
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Authenticated, Detail = c.Username }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores or updates the client's RSA public key in client_public_keys, then registers the
|
||||||
|
/// (sessionId, username) mapping in ConnectedClientService. After this fires the server can
|
||||||
|
/// route encrypted chat messages to this user's connected devices.
|
||||||
|
/// </summary>
|
||||||
private void HandleRegisterKey(WsControlMessage c)
|
private void HandleRegisterKey(WsControlMessage c)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.PublicKey))
|
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 }));
|
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = c.Username }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sends the server's public RSA key. Called once per session right after RegisterKey.</summary>
|
||||||
private void HandleGetServerKey()
|
private void HandleGetServerKey()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(ServerPublicKey)) { Console.WriteLine("Server public key not initialised."); return; }
|
if (string.IsNullOrWhiteSpace(ServerPublicKey)) { Console.WriteLine("Server public key not initialised."); return; }
|
||||||
Send(JsonSerializer.Serialize(new ServerPublicKeyMessage { Type = SignalType.ServerPublicKey, PublicKey = ServerPublicKey }));
|
Send(JsonSerializer.Serialize(new ServerPublicKeyMessage { Type = SignalType.ServerPublicKey, PublicKey = ServerPublicKey }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a channel list with CanPost/CanManage/visibility resolved for this specific user.
|
||||||
|
/// The username is looked up by session ID so the client never has to spoof it.
|
||||||
|
/// </summary>
|
||||||
private void HandleGetChannels()
|
private void HandleGetChannels()
|
||||||
{
|
{
|
||||||
if (Db is null) { Console.WriteLine("Db null."); return; }
|
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 }));
|
Send(JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams the channel's full message history back to the requester. Each message is:
|
||||||
|
/// 1. Decrypted from the channel DB key (ChannelCryptoService.Decrypt).
|
||||||
|
/// 2. Re-encrypted with the requester's public key (E2EeHelper.EncryptForRecipient).
|
||||||
|
/// 3. Sent as an individual SocketEncryptedMessage frame.
|
||||||
|
/// Deleted messages are sent as tombstones (IsDeleted=true, no ciphertext) so the client
|
||||||
|
/// can render a placeholder without trying to decrypt.
|
||||||
|
/// </summary>
|
||||||
private void HandleGetHistory(WsControlMessage c)
|
private void HandleGetHistory(WsControlMessage c)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
|
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
|
||||||
@@ -194,6 +270,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks the session as present in a voice channel. Gated by CanSpeakAsync — if the user's
|
||||||
|
/// role is denied Speak here we reject with WsEvent.Error and refuse to register presence.
|
||||||
|
/// </summary>
|
||||||
private void HandleRtcJoinChannel(WsControlMessage c)
|
private void HandleRtcJoinChannel(WsControlMessage c)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
|
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}");
|
Console.WriteLine($"RTC join: session={ID}, user={c.Username}, channel={c.ChannelId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Clears the session's voice-channel presence. Idempotent — safe to call when not in a channel.</summary>
|
||||||
private void HandleRtcLeaveChannel(WsControlMessage c)
|
private void HandleRtcLeaveChannel(WsControlMessage c)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(c.ChannelId) && RtcChannelPresenceService.IsInChannel(ID, c.ChannelId))
|
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}");
|
Console.WriteLine($"RTC leave: session={ID}, user={c.Username}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcasts "{Username} is typing…" to every connected server member EXCEPT the sender.
|
||||||
|
/// Sender's username comes from ConnectedClientService (not the message payload) so a
|
||||||
|
/// malicious client can't impersonate someone else's typing.
|
||||||
|
/// </summary>
|
||||||
private void HandleTyping(WsControlMessage c)
|
private void HandleTyping(WsControlMessage c)
|
||||||
{
|
{
|
||||||
var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
|
var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
|
||||||
@@ -244,6 +330,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams every prior version of a message back to the requester. Each entry is decrypted
|
||||||
|
/// from the channel key then re-encrypted for the requester's public key. Drives the
|
||||||
|
/// "(edited)" tap-popup on the client.
|
||||||
|
/// </summary>
|
||||||
private void HandleGetEditHistory(WsControlMessage c)
|
private void HandleGetEditHistory(WsControlMessage c)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(c.MessageId) || string.IsNullOrWhiteSpace(c.Username)) return;
|
if (string.IsNullOrWhiteSpace(c.MessageId) || string.IsNullOrWhiteSpace(c.Username)) return;
|
||||||
@@ -283,6 +374,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Permission-gated channel creation. On success, broadcasts the new channel list to every
|
||||||
|
/// connected member (computed per-user since CanPost/CanManage depend on the recipient).
|
||||||
|
/// </summary>
|
||||||
private void HandleCreateChannel(WsControlMessage c)
|
private void HandleCreateChannel(WsControlMessage c)
|
||||||
{
|
{
|
||||||
var username = ConnectedClientService.GetUsernameForSession(ID);
|
var username = ConnectedClientService.GetUsernameForSession(ID);
|
||||||
@@ -315,6 +410,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
BroadcastChannelList();
|
BroadcastChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Permission-gated soft-delete (sets IsDeleted on the row, doesn't actually remove it).
|
||||||
|
/// Broadcasts a fresh channel list after — clients drop the channel from their sidebar.
|
||||||
|
/// </summary>
|
||||||
private void HandleDeleteChannel(WsControlMessage c)
|
private void HandleDeleteChannel(WsControlMessage c)
|
||||||
{
|
{
|
||||||
var username = ConnectedClientService.GetUsernameForSession(ID);
|
var username = ConnectedClientService.GetUsernameForSession(ID);
|
||||||
@@ -340,6 +439,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
BroadcastChannelList();
|
BroadcastChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relays an encrypted WebRTC SDP/ICE signal to every other session in the same voice
|
||||||
|
/// channel. Decrypts with the server's private key, re-encrypts per-recipient. The server
|
||||||
|
/// never stores RTC signals — pure forwarding.
|
||||||
|
/// </summary>
|
||||||
private void HandleEncryptedRtcSignal(string msg)
|
private void HandleEncryptedRtcSignal(string msg)
|
||||||
{
|
{
|
||||||
SocketRtcSignalMessage? payload;
|
SocketRtcSignalMessage? payload;
|
||||||
@@ -374,6 +478,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The main chat-message path. Permission gate → server-side decrypt → store with channel
|
||||||
|
/// key → DeliverToServerMembers (per-user re-encrypt + send) → MirrorAttachmentIfNeeded.
|
||||||
|
/// </summary>
|
||||||
private void HandleEncryptedChatMessage(string msg)
|
private void HandleEncryptedChatMessage(string msg)
|
||||||
{
|
{
|
||||||
SocketEncryptedMessage? payload;
|
SocketEncryptedMessage? payload;
|
||||||
@@ -429,6 +537,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
MirrorAttachmentIfNeeded(plainText, payload.SenderUsername, payload.ChannelId);
|
MirrorAttachmentIfNeeded(plainText, payload.SenderUsername, payload.ChannelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If the origin channel has LinkedFileChannelId set and the message has a non-gif
|
||||||
|
/// attachment, stores+delivers a trimmed copy ("📎 Shared from #X by Y" + attachment)
|
||||||
|
/// into the linked File channel. No-op for plain text messages.
|
||||||
|
/// </summary>
|
||||||
private void MirrorAttachmentIfNeeded(string plainText, string senderUsername, string originChannelId)
|
private void MirrorAttachmentIfNeeded(string plainText, string senderUsername, string originChannelId)
|
||||||
{
|
{
|
||||||
ChatMessageContent? content;
|
ChatMessageContent? content;
|
||||||
@@ -483,6 +596,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
Console.WriteLine($"Mirrored attachment from {originChannelId} to file channel {fileChannelId}");
|
Console.WriteLine($"Mirrored attachment from {originChannelId} to file channel {fileChannelId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ownership-gated edit. Saves the OLD ciphertext as a ChannelMessageEdits row before
|
||||||
|
/// overwriting the current row, so the edit chain is preserved. Broadcasts MessageEdited
|
||||||
|
/// with the new ciphertext so every recipient updates their bubble in place.
|
||||||
|
/// </summary>
|
||||||
private void HandleEditMessage(string msg)
|
private void HandleEditMessage(string msg)
|
||||||
{
|
{
|
||||||
SocketEncryptedMessage? request;
|
SocketEncryptedMessage? request;
|
||||||
@@ -542,6 +660,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
request.MessageId, SignalType.MessageEdited, isEdited: true);
|
request.MessageId, SignalType.MessageEdited, isEdited: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soft-delete (sets IsDeleted on the row). Allowed for the message author OR anyone with
|
||||||
|
/// ManageMessages permission in the channel. Broadcasts a tombstone event to every
|
||||||
|
/// connected member; their client swaps the bubble to a "deleted" placeholder.
|
||||||
|
/// </summary>
|
||||||
private void HandleDeleteMessage(string msg)
|
private void HandleDeleteMessage(string msg)
|
||||||
{
|
{
|
||||||
SocketEncryptedMessage? request;
|
SocketEncryptedMessage? request;
|
||||||
@@ -587,6 +710,14 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The fan-out for any chat-message delivery (new send, edit broadcast). For each
|
||||||
|
/// server_members row, looks up active sessions, fetches that user's public key, encrypts
|
||||||
|
/// the plaintext for them, and sends to every one of their sessions (multi-device).
|
||||||
|
///
|
||||||
|
/// "ProperUsername" is the mixed-case version captured at RegisterKey time, used so the
|
||||||
|
/// client's case-insensitive compare picks up the message instead of dropping it silently.
|
||||||
|
/// </summary>
|
||||||
private void DeliverToServerMembers(
|
private void DeliverToServerMembers(
|
||||||
string plainText, string senderUsername, string channelId,
|
string plainText, string senderUsername, string channelId,
|
||||||
string messageId, SignalType signalType, bool isEdited)
|
string messageId, SignalType signalType, bool isEdited)
|
||||||
@@ -619,6 +750,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes a freshly-built channel list to every connected member. Has to compute the list
|
||||||
|
/// PER user because CanPost/CanManage/visibility are user-specific. Called after Create/Delete.
|
||||||
|
/// </summary>
|
||||||
private void BroadcastChannelList()
|
private void BroadcastChannelList()
|
||||||
{
|
{
|
||||||
foreach (var member in GetServerMembersSync())
|
foreach (var member in GetServerMembersSync())
|
||||||
@@ -639,6 +774,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the channel list a specific user can see, with CanPost/CanManage flags filled
|
||||||
|
/// in. Visibility (ViewChannel) determines inclusion — denied channels are filtered out.
|
||||||
|
/// </summary>
|
||||||
private List<ChannelItem> BuildChannelListForUser(string username)
|
private List<ChannelItem> BuildChannelListForUser(string username)
|
||||||
{
|
{
|
||||||
var rawChannels = GetChannelsSync()
|
var rawChannels = GetChannelsSync()
|
||||||
@@ -679,6 +818,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebSocketSharp callback when the connection drops (clean close OR network drop). Clears
|
||||||
|
/// both presence registries so other clients aren't trying to send to a dead session.
|
||||||
|
/// </summary>
|
||||||
protected override void OnClose(CloseEventArgs e)
|
protected override void OnClose(CloseEventArgs e)
|
||||||
{
|
{
|
||||||
ConnectedClientService.Unregister(ID);
|
ConnectedClientService.Unregister(ID);
|
||||||
@@ -687,12 +830,19 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
base.OnClose(e);
|
base.OnClose(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>WebSocketSharp callback for socket-level errors. Logged but non-fatal.</summary>
|
||||||
protected override void OnError(ErrorEventArgs e)
|
protected override void OnError(ErrorEventArgs e)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"WS error: session={ID}, message={e.Message}");
|
Console.WriteLine($"WS error: session={ID}, message={e.Message}");
|
||||||
base.OnError(e);
|
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) =>
|
private void RegisterOrUpdateClientKeySync(string username, string publicKey) =>
|
||||||
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)).GetAwaiter().GetResult();
|
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)).GetAwaiter().GetResult();
|
||||||
|
|
||||||
@@ -727,6 +877,7 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
private List<ServerMembers> GetServerMembersSync() =>
|
private List<ServerMembers> GetServerMembersSync() =>
|
||||||
Task.Run(async () => await Db!.Select<ServerMembers>("server_members")).GetAwaiter().GetResult().ToList();
|
Task.Run(async () => await Db!.Select<ServerMembers>("server_members")).GetAwaiter().GetResult().ToList();
|
||||||
|
|
||||||
|
/// <summary>"users:keeper317" → "keeper317". Stored as Surreal record id, displayed as plain name.</summary>
|
||||||
private static string ExtractUsernameFromUserId(string senderUserId)
|
private static string ExtractUsernameFromUserId(string senderUserId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(senderUserId)) return "Unknown";
|
if (string.IsNullOrWhiteSpace(senderUserId)) return "Unknown";
|
||||||
@@ -734,6 +885,7 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
return parts.Length == 2 ? parts[1] : senderUserId;
|
return parts.Length == 2 ? parts[1] : senderUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>SurrealDB's Id object → "table:recordId" string. Used for storing parent refs as strings in child rows.</summary>
|
||||||
private static string GetRecordId(object? id)
|
private static string GetRecordId(object? id)
|
||||||
{
|
{
|
||||||
if (id is null) return string.Empty;
|
if (id is null) return string.Empty;
|
||||||
@@ -743,12 +895,14 @@ public class ChatSocketBehavior : WebSocketBehavior
|
|||||||
return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}";
|
return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Guard: returns true if the DB and key service are both initialised. Logs and returns false otherwise.</summary>
|
||||||
private bool EnsureCoreReady()
|
private bool EnsureCoreReady()
|
||||||
{
|
{
|
||||||
if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; }
|
if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; }
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Guard: returns true if encryption keys + channel crypto service are all set. Logs and returns false otherwise.</summary>
|
||||||
private bool EnsureCryptoReady()
|
private bool EnsureCryptoReady()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null)
|
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null)
|
||||||
|
|||||||
@@ -2,12 +2,32 @@ using System.Collections.Concurrent;
|
|||||||
|
|
||||||
namespace RelayServer.Services.Chat;
|
namespace RelayServer.Services.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two-way in-memory mapping between WebSocket session IDs and usernames.
|
||||||
|
///
|
||||||
|
/// Why both directions: when a chat message arrives, we need to look up "which sessions does
|
||||||
|
/// this server member have open right now?" (username → sessions) so we can deliver to each
|
||||||
|
/// of their devices. When a connection closes, we need to know "which user owned this session?"
|
||||||
|
/// (session → username) to clean up correctly.
|
||||||
|
///
|
||||||
|
/// Multi-device support: one username can have multiple sessions (phone + desktop + web all
|
||||||
|
/// connected simultaneously). UsernameToSessions stores a HashSet per username; each lock
|
||||||
|
/// is scoped to that specific HashSet so different users never block each other.
|
||||||
|
///
|
||||||
|
/// Username comparisons are case-insensitive (OrdinalIgnoreCase on the outer dictionary)
|
||||||
|
/// because the DB stores usernames lowercase but clients may register with mixed case.
|
||||||
|
/// </summary>
|
||||||
public static class ConnectedClientService
|
public static class ConnectedClientService
|
||||||
{
|
{
|
||||||
private static readonly ConcurrentDictionary<string, string> SessionToUsername = new();
|
private static readonly ConcurrentDictionary<string, string> SessionToUsername = new();
|
||||||
private static readonly ConcurrentDictionary<string, HashSet<string>> UsernameToSessions =
|
private static readonly ConcurrentDictionary<string, HashSet<string>> UsernameToSessions =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Associates a session ID with a username. Called from HandleRegisterKey. If the same
|
||||||
|
/// session re-registers under a different username (rare — basically only if the client
|
||||||
|
/// reauthenticates), the old mapping is cleaned up first to avoid double-bookkeeping.
|
||||||
|
/// </summary>
|
||||||
public static void Register(string sessionId, string username)
|
public static void Register(string sessionId, string username)
|
||||||
{
|
{
|
||||||
if (SessionToUsername.TryGetValue(sessionId, out var oldUsername) &&
|
if (SessionToUsername.TryGetValue(sessionId, out var oldUsername) &&
|
||||||
@@ -26,12 +46,21 @@ public static class ConnectedClientService
|
|||||||
sessions.Add(sessionId);
|
sessions.Add(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a session from both mappings. Called from OnClose. Idempotent — calling for
|
||||||
|
/// a session that's already gone is a no-op.
|
||||||
|
/// </summary>
|
||||||
public static void Unregister(string sessionId)
|
public static void Unregister(string sessionId)
|
||||||
{
|
{
|
||||||
if (SessionToUsername.TryRemove(sessionId, out var username))
|
if (SessionToUsername.TryRemove(sessionId, out var username))
|
||||||
RemoveSessionFromUsername(sessionId, username);
|
RemoveSessionFromUsername(sessionId, username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns every active session ID for a given username (case-insensitive lookup).
|
||||||
|
/// Empty collection if the user is offline. Snapshot-safe: the returned list is a copy,
|
||||||
|
/// not a live view of the underlying HashSet.
|
||||||
|
/// </summary>
|
||||||
public static IReadOnlyCollection<string> GetSessionsForUser(string username)
|
public static IReadOnlyCollection<string> GetSessionsForUser(string username)
|
||||||
{
|
{
|
||||||
if (UsernameToSessions.TryGetValue(username, out var sessions))
|
if (UsernameToSessions.TryGetValue(username, out var sessions))
|
||||||
@@ -43,11 +72,19 @@ public static class ConnectedClientService
|
|||||||
return Array.Empty<string>();
|
return Array.Empty<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverse lookup: which user owns this session? Returns the mixed-case username the
|
||||||
|
/// client registered with (preserves casing for display). Null if the session is unknown.
|
||||||
|
/// </summary>
|
||||||
public static string? GetUsernameForSession(string sessionId)
|
public static string? GetUsernameForSession(string sessionId)
|
||||||
{
|
{
|
||||||
return SessionToUsername.TryGetValue(sessionId, out var u) ? u : null;
|
return SessionToUsername.TryGetValue(sessionId, out var u) ? u : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal cleanup: pulls a session out of the username→sessions HashSet, and removes
|
||||||
|
/// the username entry entirely if no sessions remain (keeps the dictionary lean).
|
||||||
|
/// </summary>
|
||||||
private static void RemoveSessionFromUsername(string sessionId, string username)
|
private static void RemoveSessionFromUsername(string sessionId, string username)
|
||||||
{
|
{
|
||||||
if (!UsernameToSessions.TryGetValue(username, out var sessions))
|
if (!UsernameToSessions.TryGetValue(username, out var sessions))
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ using SurrealDb.Net;
|
|||||||
|
|
||||||
namespace RelayServer.Services.Core;
|
namespace RelayServer.Services.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Idempotent server setup. Runs once at boot from Program.cs.
|
||||||
|
///
|
||||||
|
/// Each "Ensure*" helper either inserts a missing row or patches an existing one so the
|
||||||
|
/// declared state matches the code. Running this twice in a row is a no-op.
|
||||||
|
///
|
||||||
|
/// What it provisions:
|
||||||
|
/// - Verifies the three test users exist via CoreClientService (currently a hardcoded stub).
|
||||||
|
/// - Creates the "Test Server" row in the servers table if missing.
|
||||||
|
/// - Adds those users to server_members, with Keeper317 as IsOwner=true.
|
||||||
|
/// - Creates the four premade channels with correct ChannelType and IsReadOnly flags:
|
||||||
|
/// welcome (Text, read-only) general (Text)
|
||||||
|
/// files (File, read-only) voice-general (Voice)
|
||||||
|
/// - Links #general → #files so attachments posted in #general auto-mirror to #files.
|
||||||
|
/// - Creates the three roles: Admin (all perms), Moderator (manage messages), Member (read+send).
|
||||||
|
/// - Assigns exactly one role per user (Keeper→Admin, Kira→Moderator, Test→Member).
|
||||||
|
/// SetUserRoleAsync DELETES stale assignments to guarantee single-role-per-user.
|
||||||
|
/// - Writes channel_permissions overrides explicitly denying Members SendMessages in
|
||||||
|
/// #welcome and #files.
|
||||||
|
/// - Generates the server's RSA keypair + the channel AES key on first boot, stores both
|
||||||
|
/// in server_encryption_keys, and copies them into ChatSocketBehavior's static fields.
|
||||||
|
/// </summary>
|
||||||
public sealed class ServerBootstrapService
|
public sealed class ServerBootstrapService
|
||||||
{
|
{
|
||||||
private readonly SurrealDbClient _db;
|
private readonly SurrealDbClient _db;
|
||||||
|
|||||||
@@ -3,6 +3,26 @@ using System.Text;
|
|||||||
|
|
||||||
namespace RelayServer.Services.Crypto;
|
namespace RelayServer.Services.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hybrid RSA-2048 + AES-GCM-256 encryption. Used for any payload that needs to be
|
||||||
|
/// readable by exactly one party (the holder of a specific RSA private key).
|
||||||
|
///
|
||||||
|
/// Encrypt:
|
||||||
|
/// 1. Generate a fresh 256-bit AES key and 96-bit nonce.
|
||||||
|
/// 2. Encrypt the plaintext with AES-GCM → CipherText + Tag (auth tag, 128-bit).
|
||||||
|
/// 3. Encrypt the AES key with the recipient's RSA public key (OAEP-SHA256).
|
||||||
|
/// 4. Return all four as base64 strings in an EncryptedPayload.
|
||||||
|
///
|
||||||
|
/// Decrypt: reverse — RSA-decrypt the AES key, then AES-GCM-decrypt the ciphertext.
|
||||||
|
///
|
||||||
|
/// Why hybrid: RSA can only encrypt small inputs (~190 bytes for 2048-bit OAEP-SHA256).
|
||||||
|
/// Wrapping a symmetric key with RSA lets us encrypt arbitrarily large payloads while
|
||||||
|
/// still using the recipient's RSA keypair as the access mechanism. This is the same
|
||||||
|
/// design as PGP, TLS handshakes, etc.
|
||||||
|
///
|
||||||
|
/// The identical implementation exists in RelayClient.Crypto.E2EeHelper — they're
|
||||||
|
/// mirrored on both ends so any payload encrypted on one side decrypts on the other.
|
||||||
|
/// </summary>
|
||||||
public static class E2EeHelper
|
public static class E2EeHelper
|
||||||
{
|
{
|
||||||
public static (string publicKey, string privateKey) GenerateRsaKeyPair()
|
public static (string publicKey, string privateKey) GenerateRsaKeyPair()
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ public sealed class PermissionService
|
|||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owners/admins always allowed. Non-admins blocked from read-only channels (#welcome,
|
||||||
|
/// #files). Everyone else passes through the normal channel-level Deny → Allow → role ladder.
|
||||||
|
/// </summary>
|
||||||
public async Task<bool> CanSendMessagesAsync(string username, string channelId)
|
public async Task<bool> CanSendMessagesAsync(string username, string channelId)
|
||||||
{
|
{
|
||||||
if (await IsOwnerOrAdminAsync(username))
|
if (await IsOwnerOrAdminAsync(username))
|
||||||
@@ -23,39 +27,57 @@ public sealed class PermissionService
|
|||||||
return await HasPermissionAsync(username, channelId, PermissionFlags.SendMessages);
|
return await HasPermissionAsync(username, channelId, PermissionFlags.SendMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-wide ability to create channels. Gates the "+" button on the sidebar.</summary>
|
||||||
public async Task<bool> CanManageChannelsAsync(string username) =>
|
public async Task<bool> CanManageChannelsAsync(string username) =>
|
||||||
await IsOwnerOrAdminAsync(username) ||
|
await IsOwnerOrAdminAsync(username) ||
|
||||||
await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels);
|
await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels);
|
||||||
|
|
||||||
|
/// <summary>Per-channel ability to delete/edit OTHER people's messages. Authors can always delete their own.</summary>
|
||||||
public async Task<bool> CanManageMessagesAsync(string username, string channelId) =>
|
public async Task<bool> CanManageMessagesAsync(string username, string channelId) =>
|
||||||
await IsOwnerOrAdminAsync(username) ||
|
await IsOwnerOrAdminAsync(username) ||
|
||||||
await HasPermissionAsync(username, channelId, PermissionFlags.ManageMessages);
|
await HasPermissionAsync(username, channelId, PermissionFlags.ManageMessages);
|
||||||
|
|
||||||
|
/// <summary>Convenience query — exposes the owner-or-admin shortcut as a public method.</summary>
|
||||||
public async Task<bool> IsAdministratorAsync(string username) =>
|
public async Task<bool> IsAdministratorAsync(string username) =>
|
||||||
await IsOwnerOrAdminAsync(username);
|
await IsOwnerOrAdminAsync(username);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Visibility" — default-allow. Only blocks if a channel-level Deny mask explicitly
|
||||||
|
/// removes ViewChannel for the user's role. Owners/admins bypass.
|
||||||
|
/// </summary>
|
||||||
public async Task<bool> CanViewChannelAsync(string username, string channelId)
|
public async Task<bool> CanViewChannelAsync(string username, string channelId)
|
||||||
{
|
{
|
||||||
if (await IsOwnerOrAdminAsync(username)) return true;
|
if (await IsOwnerOrAdminAsync(username)) return true;
|
||||||
return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.ViewChannel);
|
return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.ViewChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Voice-channel Speak. Default-allow. Blocked by channel-level Deny. Used at RtcJoin
|
||||||
|
/// time so denied users can't even register voice presence.
|
||||||
|
/// </summary>
|
||||||
public async Task<bool> CanSpeakAsync(string username, string channelId)
|
public async Task<bool> CanSpeakAsync(string username, string channelId)
|
||||||
{
|
{
|
||||||
if (await IsOwnerOrAdminAsync(username)) return true;
|
if (await IsOwnerOrAdminAsync(username)) return true;
|
||||||
return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.Speak);
|
return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.Speak);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-wide ability to delete channels. ManageChannels OR explicit DeleteChannel.</summary>
|
||||||
public async Task<bool> CanDeleteChannelAsync(string username) =>
|
public async Task<bool> CanDeleteChannelAsync(string username) =>
|
||||||
await IsOwnerOrAdminAsync(username) ||
|
await IsOwnerOrAdminAsync(username) ||
|
||||||
await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) ||
|
await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) ||
|
||||||
await HasGlobalPermissionAsync(username, PermissionFlags.DeleteChannel);
|
await HasGlobalPermissionAsync(username, PermissionFlags.DeleteChannel);
|
||||||
|
|
||||||
|
/// <summary>Server-wide ability to edit channels. ManageChannels OR explicit EditChannel.</summary>
|
||||||
public async Task<bool> CanEditChannelAsync(string username) =>
|
public async Task<bool> CanEditChannelAsync(string username) =>
|
||||||
await IsOwnerOrAdminAsync(username) ||
|
await IsOwnerOrAdminAsync(username) ||
|
||||||
await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) ||
|
await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) ||
|
||||||
await HasGlobalPermissionAsync(username, PermissionFlags.EditChannel);
|
await HasGlobalPermissionAsync(username, PermissionFlags.EditChannel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Step 1 of the ladder: owner flag OR Administrator permission on any assigned role.
|
||||||
|
/// Owner check goes first because it doesn't require roles to be seeded — server owner
|
||||||
|
/// is authoritative regardless of role-table state.
|
||||||
|
/// </summary>
|
||||||
private async Task<bool> IsOwnerOrAdminAsync(string username)
|
private async Task<bool> IsOwnerOrAdminAsync(string username)
|
||||||
{
|
{
|
||||||
if (await IsServerOwnerAsync(username))
|
if (await IsServerOwnerAsync(username))
|
||||||
@@ -65,6 +87,13 @@ public sealed class PermissionService
|
|||||||
return roles.Any(r => r.Permissions.HasFlag(PermissionFlags.Administrator));
|
return roles.Any(r => r.Permissions.HasFlag(PermissionFlags.Administrator));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The canonical permission ladder for per-channel checks:
|
||||||
|
/// 1. Owner/admin → true.
|
||||||
|
/// 2. Channel-level Deny mask for any of the user's roles → false (Deny wins).
|
||||||
|
/// 3. Channel-level Allow mask for any of the user's roles → true.
|
||||||
|
/// 4. Base role permissions → fallback.
|
||||||
|
/// </summary>
|
||||||
private async Task<bool> HasPermissionAsync(
|
private async Task<bool> HasPermissionAsync(
|
||||||
string username, string channelId, PermissionFlags flag)
|
string username, string channelId, PermissionFlags flag)
|
||||||
{
|
{
|
||||||
@@ -86,6 +115,10 @@ public sealed class PermissionService
|
|||||||
return userRoles.Any(r => r.Permissions.HasFlag(flag));
|
return userRoles.Any(r => r.Permissions.HasFlag(flag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-wide (not channel-scoped) permission check. Used for things like ManageChannels
|
||||||
|
/// where there's no specific channel context. Admin flag short-circuits.
|
||||||
|
/// </summary>
|
||||||
private async Task<bool> HasGlobalPermissionAsync(string username, PermissionFlags flag)
|
private async Task<bool> HasGlobalPermissionAsync(string username, PermissionFlags flag)
|
||||||
{
|
{
|
||||||
var roles = await GetUserRolesAsync(username);
|
var roles = await GetUserRolesAsync(username);
|
||||||
@@ -94,6 +127,10 @@ public sealed class PermissionService
|
|||||||
r.Permissions.HasFlag(flag));
|
r.Permissions.HasFlag(flag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Was this permission explicitly denied here?" — used by default-allow permissions
|
||||||
|
/// (ViewChannel, Speak) which only become restrictive when there's a Deny override.
|
||||||
|
/// </summary>
|
||||||
private async Task<bool> IsDeniedByChannelAsync(string username, string channelId, PermissionFlags flag)
|
private async Task<bool> IsDeniedByChannelAsync(string username, string channelId, PermissionFlags flag)
|
||||||
{
|
{
|
||||||
var userRoles = await GetUserRolesAsync(username);
|
var userRoles = await GetUserRolesAsync(username);
|
||||||
@@ -107,6 +144,10 @@ public sealed class PermissionService
|
|||||||
.Any(co => co.Deny.HasFlag(flag));
|
.Any(co => co.Deny.HasFlag(flag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks ServerMembers.IsOwner directly. This is the authoritative ownership test —
|
||||||
|
/// independent of the role table, so ownership keeps working even if roles aren't seeded.
|
||||||
|
/// </summary>
|
||||||
private async Task<bool> IsServerOwnerAsync(string username)
|
private async Task<bool> IsServerOwnerAsync(string username)
|
||||||
{
|
{
|
||||||
var userId = $"users:{username.ToLower()}";
|
var userId = $"users:{username.ToLower()}";
|
||||||
@@ -116,6 +157,11 @@ public sealed class PermissionService
|
|||||||
m.IsOwner);
|
m.IsOwner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads every Role row currently assigned to the user via UserRoles. Empty list if the
|
||||||
|
/// user has no role assignments (which means they implicitly fail every permission check
|
||||||
|
/// unless they happen to be the server owner).
|
||||||
|
/// </summary>
|
||||||
private async Task<List<Roles>> GetUserRolesAsync(string username)
|
private async Task<List<Roles>> GetUserRolesAsync(string username)
|
||||||
{
|
{
|
||||||
var userId = $"users:{username.ToLower()}";
|
var userId = $"users:{username.ToLower()}";
|
||||||
@@ -134,12 +180,14 @@ public sealed class PermissionService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Loads every channel_permissions override row for a channel (all roles, all flags).</summary>
|
||||||
private async Task<List<ChannelPermissions>> GetChannelPermissionsAsync(string channelId)
|
private async Task<List<ChannelPermissions>> GetChannelPermissionsAsync(string channelId)
|
||||||
{
|
{
|
||||||
var all = await _db.Select<ChannelPermissions>("channel_permissions");
|
var all = await _db.Select<ChannelPermissions>("channel_permissions");
|
||||||
return all.Where(cp => cp.ChannelId == channelId).ToList();
|
return all.Where(cp => cp.ChannelId == channelId).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the channel's IsReadOnly flag is set on its row in the channels table.</summary>
|
||||||
private async Task<bool> IsChannelReadOnlyAsync(string channelId)
|
private async Task<bool> IsChannelReadOnlyAsync(string channelId)
|
||||||
{
|
{
|
||||||
var channels = await _db.Select<Channels>("channels");
|
var channels = await _db.Select<Channels>("channels");
|
||||||
@@ -147,6 +195,7 @@ public sealed class PermissionService
|
|||||||
return channel?.IsReadOnly ?? false;
|
return channel?.IsReadOnly ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>SurrealDB's Id object → "table:id" string. Local copy because PermissionService isn't a friend of ChatSocketBehavior.</summary>
|
||||||
private static string GetRecordIdString(object? id)
|
private static string GetRecordIdString(object? id)
|
||||||
{
|
{
|
||||||
if (id is null) return string.Empty;
|
if (id is null) return string.Empty;
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
namespace RelayShared.Services;
|
namespace RelayShared.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
public enum ChannelType
|
public enum ChannelType
|
||||||
{
|
{
|
||||||
Text, //Default channel type, handles text, links, files*, all in a linear live chat format
|
/// <summary>Default. Linear chat: text, markdown, embeds, attachments. Sidebar prefix "#".</summary>
|
||||||
Voice, //Used for general voice and video calls, utilizes WebRTC in its intended use
|
Text,
|
||||||
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
|
/// <summary>WebRTC voice/video. Sidebar prefix 🔊. Selecting auto-swaps to the RTC view.</summary>
|
||||||
Stage //Used for announcements and presentations, voice/video call utilizing a modified WebRTC protocol through server
|
Voice,
|
||||||
|
|
||||||
|
/// <summary>File browser. Receives auto-mirrored attachments from any Text channel that points here via LinkedFileChannelId. Sidebar prefix 📁.</summary>
|
||||||
|
File,
|
||||||
|
|
||||||
|
/// <summary>Forum-style threaded posts. Sidebar prefix 📋. Currently a placeholder type.</summary>
|
||||||
|
Forum,
|
||||||
|
|
||||||
|
/// <summary>Announcement-style voice. Modified WebRTC where most participants are listeners. Sidebar prefix 🎤. Placeholder.</summary>
|
||||||
|
Stage
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,44 @@
|
|||||||
namespace RelayShared.Services;
|
namespace RelayShared.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public sealed class ChannelItem
|
public sealed class ChannelItem
|
||||||
{
|
{
|
||||||
|
/// <summary>Surreal record id (e.g. "channels:abc").</summary>
|
||||||
public string ChannelId { get; set; } = string.Empty;
|
public string ChannelId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Sidebar display name ("general", "welcome", etc.).</summary>
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Drives icon and behavior: Text/Voice/File/Forum/Stage.</summary>
|
||||||
public ChannelType Type { get; set; }
|
public ChannelType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sidebar category label (e.g. "General"). Empty groups fall under a default "Channels" header.</summary>
|
||||||
public string Group { get; set; } = string.Empty;
|
public string Group { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Creation timestamp. Drives sidebar sort order (oldest → newest).</summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True if the channel is announcement-style (welcome, files). Drives the 🔒 suffix in the sidebar.</summary>
|
||||||
public bool IsReadOnly { get; set; }
|
public bool IsReadOnly { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Permission-resolved: can the receiving user send messages here. Drives input enable/disable.</summary>
|
||||||
public bool CanPost { get; set; }
|
public bool CanPost { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Permission-resolved: can the receiving user edit/delete this channel. Drives context-menu visibility.</summary>
|
||||||
public bool CanManage { get; set; }
|
public bool CanManage { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-to-client channel list. Sent in response to WsAction.GetChannels and broadcast
|
||||||
|
/// to all sessions after every channel create / delete.
|
||||||
|
/// </summary>
|
||||||
public sealed class SocketChannelList
|
public sealed class SocketChannelList
|
||||||
{
|
{
|
||||||
public SignalType Type { get; set; } = SignalType.ChannelList;
|
public SignalType Type { get; set; } = SignalType.ChannelList;
|
||||||
|
|
||||||
|
/// <summary>Channels the receiving user is allowed to view. Permission filtering happens server-side.</summary>
|
||||||
public List<ChannelItem> Channels { get; set; } = [];
|
public List<ChannelItem> Channels { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
namespace RelayShared.Services;
|
namespace RelayShared.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public sealed class ChatMessageContent
|
public sealed class ChatMessageContent
|
||||||
{
|
{
|
||||||
|
/// <summary>The raw message body, including Markdown syntax and @mentions.</summary>
|
||||||
public string Text { get; set; } = string.Empty;
|
public string Text { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>When set, this message is a reply. Carries the Surreal record id of the message being replied to.</summary>
|
||||||
public string? ReplyToId { get; set; }
|
public string? ReplyToId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Display name of the user being replied to. Lets the client render the quote bar without a lookup.</summary>
|
||||||
public string? ReplyToSenderUsername { get; set; }
|
public string? ReplyToSenderUsername { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Trimmed preview of the replied-to text (≤100 chars). Captured at send time so the server never has to look it up.</summary>
|
||||||
public string? ReplyPreview { get; set; }
|
public string? ReplyPreview { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Extracted usernames + special tokens ("everyone", "here"). Drives the ping-badge in the sidebar.</summary>
|
||||||
public List<string>? Mentions { get; set; }
|
public List<string>? Mentions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64-encoded attachment bytes. Null when there's no attachment.</summary>
|
||||||
public string? AttachmentBase64 { get; set; }
|
public string? AttachmentBase64 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>MIME type of the attachment (e.g. "image/png"). Used to choose between BuildBase64ImageEmbed and BuildFileCard.</summary>
|
||||||
public string? AttachmentMimeType { get; set; }
|
public string? AttachmentMimeType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Original filename as chosen by the sender. Shown as the file card label and used for the download path.</summary>
|
||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,71 +2,159 @@ namespace RelayShared.Services;
|
|||||||
|
|
||||||
//TODO: review name of file, potentially rename for Encryption services rather than sockets
|
//TODO: review name of file, potentially rename for Encryption services rather than sockets
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public sealed class SocketRtcSignalMessage
|
public sealed class SocketRtcSignalMessage
|
||||||
{
|
{
|
||||||
|
/// <summary>Always SignalType.EncryptedSignal in flight.</summary>
|
||||||
public SignalType Type { get; set; }
|
public SignalType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Username of the user generating the SDP/ICE signal.</summary>
|
||||||
public string SenderUsername { get; set; } = string.Empty;
|
public string SenderUsername { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The voice channel this signal belongs to.</summary>
|
||||||
public string ChannelId { get; set; } = string.Empty;
|
public string ChannelId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM ciphertext of the JSON-serialised RtcSignalMessage.</summary>
|
||||||
public string CipherText { get; set; } = string.Empty;
|
public string CipherText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM 96-bit nonce.</summary>
|
||||||
public string Nonce { get; set; } = string.Empty;
|
public string Nonce { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM 128-bit authentication tag.</summary>
|
||||||
public string Tag { get; set; } = string.Empty;
|
public string Tag { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64 RSA-OAEP-encrypted AES key (encrypted with recipient's public key).</summary>
|
||||||
public string EncryptedKey { get; set; } = string.Empty;
|
public string EncryptedKey { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The workhorse envelope for chat messages and message lifecycle events.
|
||||||
|
/// Used for both directions and for new sends / edits / delete tombstones.
|
||||||
|
/// </summary>
|
||||||
public sealed class SocketEncryptedMessage
|
public sealed class SocketEncryptedMessage
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// EncryptedChat (server→client), ClientEncryptedChat (client→server new message),
|
||||||
|
/// ClientEditMessage / ClientDeleteMessage (client→server lifecycle), MessageEdited (server→client).
|
||||||
|
/// </summary>
|
||||||
public SignalType Type { get; set; } = SignalType.EncryptedChat;
|
public SignalType Type { get; set; } = SignalType.EncryptedChat;
|
||||||
|
|
||||||
|
/// <summary>Surreal record id (e.g. "channel_messages:abc"). Populated by the server on outbound delivery.</summary>
|
||||||
public string MessageId { get; set; } = string.Empty;
|
public string MessageId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Who wrote the message.</summary>
|
||||||
public string SenderUsername { get; set; } = string.Empty;
|
public string SenderUsername { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Who this specific delivery is encrypted for. Different per recipient on the same logical message.</summary>
|
||||||
public string RecipientUsername { get; set; } = string.Empty;
|
public string RecipientUsername { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The channel the message belongs to.</summary>
|
||||||
public string ChannelId { get; set; } = string.Empty;
|
public string ChannelId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM ciphertext of the JSON-serialised ChatMessageContent. Empty on tombstone deliveries.</summary>
|
||||||
public string CipherText { get; set; } = string.Empty;
|
public string CipherText { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM 96-bit nonce.</summary>
|
||||||
public string Nonce { get; set; } = string.Empty;
|
public string Nonce { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64 AES-GCM 128-bit authentication tag.</summary>
|
||||||
public string Tag { get; set; } = string.Empty;
|
public string Tag { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64 RSA-OAEP-encrypted AES key (encrypted with recipient's public key on outbound, server's on inbound).</summary>
|
||||||
public string EncryptedKey { get; set; } = string.Empty;
|
public string EncryptedKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>True when this message has been edited at least once. Drives the (edited) footer in the bubble.</summary>
|
||||||
public bool IsEdited { get; set; }
|
public bool IsEdited { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True for tombstone deliveries (history only). Client renders a placeholder; no decryption is attempted.</summary>
|
||||||
public bool IsDeleted { get; set; }
|
public bool IsDeleted { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public sealed class SocketMessageDeletedEvent
|
public sealed class SocketMessageDeletedEvent
|
||||||
{
|
{
|
||||||
public SignalType Type { get; set; } = SignalType.MessageDeleted;
|
public SignalType Type { get; set; } = SignalType.MessageDeleted;
|
||||||
|
|
||||||
|
/// <summary>The message being tombstoned.</summary>
|
||||||
public string MessageId { get; set; } = string.Empty;
|
public string MessageId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Channel scope — clients that aren't viewing this channel can defer the bubble update.</summary>
|
||||||
public string ChannelId { get; set; } = string.Empty;
|
public string ChannelId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "{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.
|
||||||
|
/// </summary>
|
||||||
public sealed class SocketTypingEvent
|
public sealed class SocketTypingEvent
|
||||||
{
|
{
|
||||||
public SignalType Type { get; set; } = SignalType.TypingIndicator;
|
public SignalType Type { get; set; } = SignalType.TypingIndicator;
|
||||||
|
|
||||||
|
/// <summary>Who is typing.</summary>
|
||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Which channel they're typing in. Clients ignore events for channels they're not viewing.</summary>
|
||||||
public string ChannelId { get; set; } = string.Empty;
|
public string ChannelId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One historical version of an edited message, re-encrypted for the requester.</summary>
|
||||||
public sealed class SocketEditHistoryEntry
|
public sealed class SocketEditHistoryEntry
|
||||||
{
|
{
|
||||||
|
/// <summary>Base64 AES-GCM ciphertext of the JSON-serialised previous ChatMessageContent.</summary>
|
||||||
public string CipherText { get; set; } = string.Empty;
|
public string CipherText { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Nonce { get; set; } = string.Empty;
|
public string Nonce { get; set; } = string.Empty;
|
||||||
public string Tag { get; set; } = string.Empty;
|
public string Tag { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Base64 RSA-OAEP-encrypted AES key (encrypted with requester's public key).</summary>
|
||||||
public string EncryptedKey { get; set; } = string.Empty;
|
public string EncryptedKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>When this version was the current text (i.e. when it was replaced).</summary>
|
||||||
public DateTime EditedAt { get; set; }
|
public DateTime EditedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Server reply to a GetEditHistory request. Entries are ordered oldest→newest.</summary>
|
||||||
public sealed class SocketEditHistoryResponse
|
public sealed class SocketEditHistoryResponse
|
||||||
{
|
{
|
||||||
public SignalType Type { get; set; } = SignalType.EditHistory;
|
public SignalType Type { get; set; } = SignalType.EditHistory;
|
||||||
|
|
||||||
|
/// <summary>Which message this history is for.</summary>
|
||||||
public string MessageId { get; set; } = string.Empty;
|
public string MessageId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Every previous version of the message. Empty if the message has never been edited.</summary>
|
||||||
public List<SocketEditHistoryEntry> Entries { get; set; } = [];
|
public List<SocketEditHistoryEntry> Entries { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public sealed class ServerPublicKeyMessage
|
public sealed class ServerPublicKeyMessage
|
||||||
{
|
{
|
||||||
public SignalType Type { get; set; } = SignalType.ServerPublicKey;
|
public SignalType Type { get; set; } = SignalType.ServerPublicKey;
|
||||||
|
|
||||||
|
/// <summary>Base64 SubjectPublicKeyInfo (DER) of the server's RSA public key.</summary>
|
||||||
public string PublicKey { get; set; } = string.Empty;
|
public string PublicKey { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The wire discriminator for every data-plane Socket*Message.</summary>
|
||||||
public enum SignalType
|
public enum SignalType
|
||||||
{
|
{
|
||||||
|
// RTC SDP/ICE wire types (used by the WebView RTC engine, not handled directly here)
|
||||||
Offer,
|
Offer,
|
||||||
Answer,
|
Answer,
|
||||||
Candidate,
|
Candidate,
|
||||||
@@ -74,15 +162,37 @@ public enum SignalType
|
|||||||
AnswerUpdated,
|
AnswerUpdated,
|
||||||
CandidateAdded,
|
CandidateAdded,
|
||||||
CallLeft,
|
CallLeft,
|
||||||
|
|
||||||
|
/// <summary>Server→client: paginated channel list (SocketChannelList).</summary>
|
||||||
ChannelList,
|
ChannelList,
|
||||||
|
|
||||||
|
/// <summary>Server→client: ServerPublicKeyMessage delivery.</summary>
|
||||||
ServerPublicKey,
|
ServerPublicKey,
|
||||||
|
|
||||||
|
/// <summary>Bidirectional: encrypted RTC SDP/ICE signal (SocketRtcSignalMessage).</summary>
|
||||||
EncryptedSignal,
|
EncryptedSignal,
|
||||||
|
|
||||||
|
/// <summary>Server→client: delivered chat message (SocketEncryptedMessage).</summary>
|
||||||
EncryptedChat,
|
EncryptedChat,
|
||||||
|
|
||||||
|
/// <summary>Client→server: new chat message send (SocketEncryptedMessage).</summary>
|
||||||
ClientEncryptedChat,
|
ClientEncryptedChat,
|
||||||
|
|
||||||
|
/// <summary>Client→server: request to edit own message (SocketEncryptedMessage with new content).</summary>
|
||||||
ClientEditMessage,
|
ClientEditMessage,
|
||||||
|
|
||||||
|
/// <summary>Client→server: request to delete own message (SocketEncryptedMessage with only MessageId).</summary>
|
||||||
ClientDeleteMessage,
|
ClientDeleteMessage,
|
||||||
|
|
||||||
|
/// <summary>Server→clients: edit broadcast carrying re-encrypted new content (SocketEncryptedMessage).</summary>
|
||||||
MessageEdited,
|
MessageEdited,
|
||||||
|
|
||||||
|
/// <summary>Server→clients: deletion tombstone (SocketMessageDeletedEvent).</summary>
|
||||||
MessageDeleted,
|
MessageDeleted,
|
||||||
|
|
||||||
|
/// <summary>Server→peers: typing indicator (SocketTypingEvent).</summary>
|
||||||
TypingIndicator,
|
TypingIndicator,
|
||||||
|
|
||||||
|
/// <summary>Server→requester: edit-history response (SocketEditHistoryResponse).</summary>
|
||||||
EditHistory
|
EditHistory
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,111 @@
|
|||||||
namespace RelayShared.Services;
|
namespace RelayShared.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
public enum WsAction
|
public enum WsAction
|
||||||
{
|
{
|
||||||
|
/// <summary>Verify a Core-issued user token. Fields used: Username, Token.</summary>
|
||||||
Authenticate,
|
Authenticate,
|
||||||
|
|
||||||
|
/// <summary>Register/update the client's RSA public key. Fields used: Username, PublicKey.</summary>
|
||||||
RegisterKey,
|
RegisterKey,
|
||||||
|
|
||||||
|
/// <summary>Request the server's public RSA key for outbound encryption. No fields.</summary>
|
||||||
GetServerKey,
|
GetServerKey,
|
||||||
|
|
||||||
|
/// <summary>Request the full channel list for this user. No fields.</summary>
|
||||||
GetChannels,
|
GetChannels,
|
||||||
|
|
||||||
|
/// <summary>Request decrypted message history for a channel. Fields used: Username, ChannelId.</summary>
|
||||||
GetHistory,
|
GetHistory,
|
||||||
|
|
||||||
|
/// <summary>Join a voice channel (presence tracking). Fields used: Username, ChannelId.</summary>
|
||||||
RtcJoin,
|
RtcJoin,
|
||||||
|
|
||||||
|
/// <summary>Leave a voice channel. Fields used: Username, ChannelId.</summary>
|
||||||
RtcLeave,
|
RtcLeave,
|
||||||
|
|
||||||
|
/// <summary>Broadcast "user is typing" to channel peers. Fields used: ChannelId.</summary>
|
||||||
SendTyping,
|
SendTyping,
|
||||||
|
|
||||||
|
/// <summary>Request the edit-history chain for a specific message. Fields used: Username, MessageId, ChannelId.</summary>
|
||||||
GetEditHistory,
|
GetEditHistory,
|
||||||
|
|
||||||
|
/// <summary>Create a new channel (permission-gated). Fields used: ChannelName, ChannelType, ChannelGroup.</summary>
|
||||||
CreateChannel,
|
CreateChannel,
|
||||||
|
|
||||||
|
/// <summary>Soft-delete a channel (permission-gated). Fields used: ChannelId.</summary>
|
||||||
DeleteChannel
|
DeleteChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-to-client event types for acks and errors.</summary>
|
||||||
public enum WsEvent
|
public enum WsEvent
|
||||||
{
|
{
|
||||||
|
/// <summary>Reply to Authenticate. Detail = username.</summary>
|
||||||
Authenticated,
|
Authenticated,
|
||||||
|
|
||||||
|
/// <summary>Reply to RegisterKey. Detail = username.</summary>
|
||||||
KeyRegistered,
|
KeyRegistered,
|
||||||
|
|
||||||
|
/// <summary>Generic error. Detail = human-readable reason shown to the user.</summary>
|
||||||
Error
|
Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public sealed class WsControlMessage
|
public sealed class WsControlMessage
|
||||||
{
|
{
|
||||||
|
/// <summary>The action to perform. Server dispatches on this.</summary>
|
||||||
public WsAction Action { get; set; }
|
public WsAction Action { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mixed-case username as the user typed it on sign-in. Server preserves casing for display.</summary>
|
||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Core-issued auth token. Only set on Authenticate.</summary>
|
||||||
public string? Token { get; set; }
|
public string? Token { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base64-encoded RSA public key. Only set on RegisterKey.</summary>
|
||||||
public string? PublicKey { get; set; }
|
public string? PublicKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Surreal record id of a channel (e.g. "channels:xyz"). Used by most channel-scoped actions.</summary>
|
||||||
public string? ChannelId { get; set; }
|
public string? ChannelId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Surreal record id of a message. Used by GetEditHistory.</summary>
|
||||||
public string? MessageId { get; set; }
|
public string? MessageId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Channel name on create (e.g. "memes"). Server normalises to lowercase-dashes.</summary>
|
||||||
public string? ChannelName { get; set; }
|
public string? ChannelName { get; set; }
|
||||||
public int ChannelType { get; set; } // cast to ChannelType enum
|
|
||||||
|
/// <summary>Integer cast of ChannelType enum (Text=0, Voice=1, …). Used on CreateChannel.</summary>
|
||||||
|
public int ChannelType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Group/category label shown in the sidebar (e.g. "General"). Optional on CreateChannel.</summary>
|
||||||
public string? ChannelGroup { get; set; }
|
public string? ChannelGroup { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-to-client ack envelope. Identified by the "Event" JSON property
|
||||||
|
/// (vs WsControlMessage's "Action" or Socket*Message's "Type").
|
||||||
|
/// </summary>
|
||||||
public sealed class WsEventMessage
|
public sealed class WsEventMessage
|
||||||
{
|
{
|
||||||
|
/// <summary>Which event this is acknowledging.</summary>
|
||||||
public WsEvent Event { get; set; }
|
public WsEvent Event { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable context (username on success, error message on Error).</summary>
|
||||||
public string? Detail { get; set; }
|
public string? Detail { get; set; }
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user