diff --git a/README.md b/README.md index 49868ad..fe31d4f 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,11 @@ graph TD ### Client - **Runs in your terminal** — no browser, no Electron, no 500MB of bundled Chromium -- **13 built-in themes** — including `hacker` for when you want to feel like you're in a movie +- **14 built-in themes** — including `hacker` for when you want to feel like you're in a movie - **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc. - **Colored nicknames** — pick your hex color, express yourself - **Clickable everything** — usernames, @mentions, #channels — just press Enter -- **File/image sharing** — local files or URLs +- **File/image sharing** — local files or URLs; drag & drop a file onto the terminal to send it - **Multi-server** — save and switch between servers - **Auto-reconnect** — drops happen, it rejoins your channels automatically - **Auto-updater** — updates in-place with automatic rollback if something goes wrong @@ -224,7 +224,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | Command | Description | | ------- | ----------- | -| `/join ` | Join a channel | +| `/join [password]` | Join a channel (password for protected channels) | | `/leave` | Leave current channel | | `/topic ` | Set channel topic (creator only) | | `/send ` | Upload a file or image | @@ -247,6 +247,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | ----- | ---- | | `default` | Gray on black — clean and quiet | | `transparent` | White on black — for fancy transparent terminals | +| `transparentlight` | Black on transparent — dark characters for light transparent terminals | | `classic` | White on blue — IRC nostalgia | | `light` | Black on white — for the brave | | `hacker` | Green on black — *I'm in* | diff --git a/docs/articles/architecture.md b/docs/articles/architecture.md index 7ab7075..5d35009 100644 --- a/docs/articles/architecture.md +++ b/docs/articles/architecture.md @@ -52,7 +52,7 @@ Terminal.Gui v2 TUI application: - **UI**: Main window, dialogs, chat renderer with ANSI color support - **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate) -- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency) +- **Themes**: 14 built-in color themes (including transparent dark/light themes with true terminal transparency) - **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens) ## Communication diff --git a/docs/todo.md b/docs/todo.md index a33c4ca..ee89ff5 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -3,7 +3,7 @@ - [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom - [x] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it) - [x] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that – it refreshes when user re-enters the channel again -- [ ] password protected rooms +- [x] password protected rooms - [ ] better audio lib, current one (NetCoreAudio) does not support seek or other audio actions - [ ] Use options pattern for both client & server - ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0 diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 2e8e4c1..d1d63e8 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -216,13 +216,15 @@ public sealed class AppOrchestrator : IDisposable return Task.CompletedTask; } - private async Task HandleCmdJoinChannel(string channelName) + private async Task HandleCmdJoinChannel(string channelName, string? password) { if (!_conn.IsConnected) return; try { - var history = await _conn.JoinChannelAsync(channelName); + var history = await JoinChannelWithPasswordPromptAsync(channelName, password); + if (history is null) return; // user cancelled the password prompt + InvokeUI(() => { _mainWindow.EnsureChannelInList(channelName); @@ -237,6 +239,31 @@ public sealed class AppOrchestrator : IDisposable } } + /// + /// Joins a channel, prompting for a password when the server requires one and + /// re-prompting on a wrong password. Returns the channel history, or null if + /// the user cancelled the prompt. + /// + private async Task?> JoinChannelWithPasswordPromptAsync(string channelName, string? password) + { + while (true) + { + try + { + return await _conn.JoinChannelAsync(channelName, password); + } + catch (ChannelPasswordRequiredException ex) + { + var prompt = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var message = password is not null ? ex.Message : null; + InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, message))); + + password = await prompt.Task; + if (password is null) return null; + } + } + } + private async Task HandleCmdLeaveChannel() { if (!_conn.IsConnected) return; @@ -554,7 +581,7 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => { if (channel.IsPublic) - _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic); + _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected); _mainWindow.SetChannelTopic(channel.Name, channel.Topic); }); }; @@ -707,7 +734,16 @@ public sealed class AppOrchestrator : IDisposable RunAsync(async () => { if (_conn.TrackChannel(channelName)) - await _conn.JoinChannelAsync(channelName); + { + var joined = await JoinChannelWithPasswordPromptAsync(channelName, null); + if (joined is null) + { + // User cancelled the password prompt — back to the default channel + _conn.UntrackChannel(channelName); + InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel)); + return; + } + } try { @@ -983,14 +1019,14 @@ public sealed class AppOrchestrator : IDisposable RunAsync(async () => { - var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic); + var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic, result.Password); if (channel is null) return; var history = await _conn.JoinChannelAsync(channel.Name); InvokeUI(() => { - _mainWindow.EnsureChannelInList(channel.Name); + _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected); _mainWindow.SetChannelTopic(channel.Name, channel.Topic); _mainWindow.SwitchToChannel(channel.Name); if (history.Count > 0) diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 71afd56..4e26c84 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -13,7 +13,7 @@ public class CommandHandler public event Func? OnSendFile; public event Func? OnOpenProfile; public event Func? OnOpenServers; - public event Func? OnJoinChannel; + public event Func? OnJoinChannel; public event Func? OnLeaveChannel; public event Func? OnSetTopic; public event Func? OnListUsers; @@ -126,7 +126,7 @@ public class CommandHandler private async Task HandleTheme(string args) { if (string.IsNullOrWhiteSpace(args)) - return new CommandResult(true, "Usage: /theme (Default, Dark, Light, Hacker, Solarized)", IsError: true); + return new CommandResult(true, "Usage: /theme — pick one from the User menu's theme list (e.g. Default, Transparent, TransparentLight, Hacker)", IsError: true); if (OnSetTheme is not null) await OnSetTheme(args.Trim()); @@ -193,11 +193,14 @@ public class CommandHandler private async Task HandleJoin(string args) { if (string.IsNullOrWhiteSpace(args)) - return new CommandResult(true, "Usage: /join ", IsError: true); + return new CommandResult(true, "Usage: /join [password]", IsError: true); + + var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries); + var channel = parts[0].TrimStart('#'); + var password = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : null; - var channel = args.Trim().TrimStart('#'); if (OnJoinChannel is not null) - await OnJoinChannel(channel); + await OnJoinChannel(channel, password); return new CommandResult(true); } @@ -343,7 +346,7 @@ public class CommandHandler /avatar - Set your avatar /profile [username] - View a profile /servers - Open saved servers - /join - Join a channel + /join [password] - Join a channel (password if protected) /leave - Leave current channel /topic - Set channel topic /users - List online users diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 2d3d351..3c689a8 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -228,10 +228,10 @@ public sealed class ApiClient : IDisposable return tempPath; } - public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true) + public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true, string? password = null) { EnsureAuthenticated(); - var request = new CreateChannelRequest(name, topic, isPublic); + var request = new CreateChannelRequest(name, topic, isPublic, password); using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync("/api/channels", request)); await EnsureSuccessAsync(response); diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index 381be64..6b30aa2 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -169,11 +169,21 @@ internal sealed class ConnectionManager : IAsyncDisposable // ── Channel Operations ──────────────────────────────────────────────── - public async Task> JoinChannelAsync(string channelName) + public async Task> JoinChannelAsync(string channelName, string? password = null) { if (_connection is null) throw new InvalidOperationException("Not connected"); - _joinedChannels.Add(channelName); - return await _connection.JoinChannelAsync(channelName); + try + { + var history = await _connection.JoinChannelAsync(channelName, password); + _joinedChannels.Add(channelName); + return history; + } + catch (ChannelPasswordRequiredException) + { + // Not actually joined — don't track, or reconnects would retry a doomed join + _joinedChannels.Remove(channelName); + throw; + } } public async Task LeaveChannelAsync(string channelName) diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 5ab24e8..995dfbb 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -5,6 +5,20 @@ using Microsoft.AspNetCore.SignalR.Client; namespace EchoHub.Client.Services; +/// +/// Thrown when joining a channel fails because a password is required or incorrect. +/// The UI catches this to prompt the user and retry. +/// +public sealed class ChannelPasswordRequiredException : Exception +{ + public string ChannelName { get; } + + public ChannelPasswordRequiredException(string channelName, string message) : base(message) + { + ChannelName = channelName; + } +} + public sealed class EchoHubConnection : IAsyncDisposable { private readonly HubConnection _connection; @@ -134,11 +148,15 @@ public sealed class EchoHubConnection : IAsyncDisposable OnConnectionStateChanged?.Invoke("Disconnected"); } - public async Task> JoinChannelAsync(string channelName) + public async Task> JoinChannelAsync(string channelName, string? password = null) { - var result = await _connection.InvokeAsync("JoinChannel", channelName); + var result = await _connection.InvokeAsync("JoinChannel", channelName, password); if (!result.Success) + { + if (result.PasswordRequired) + throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected."); throw new InvalidOperationException(result.Error ?? "Failed to join channel."); + } return DecryptMessages(result.History); } diff --git a/src/EchoHub.Client/Themes/ThemeManager.cs b/src/EchoHub.Client/Themes/ThemeManager.cs index 763057b..ebc7003 100644 --- a/src/EchoHub.Client/Themes/ThemeManager.cs +++ b/src/EchoHub.Client/Themes/ThemeManager.cs @@ -445,10 +445,44 @@ public static class ThemeManager } }; + private static readonly Theme TransparentLightTheme = new() + { + Name = "TransparentLight", + Base = new ThemeColors + { + Foreground = "Black", + Background = "None", + FocusForeground = "Blue", + FocusBackground = "None" + }, + Menu = new ThemeColors + { + Foreground = "Black", + Background = "None", + FocusForeground = "Blue", + FocusBackground = "None" + }, + Dialog = new ThemeColors + { + Foreground = "Black", + Background = "Gray", + FocusForeground = "Blue", + FocusBackground = "White" + }, + Status = new ThemeColors + { + Foreground = "DarkGray", + Background = "None", + FocusForeground = "DarkGray", + FocusBackground = "None" + } + }; + private static readonly List BuiltInThemes = [ DefaultTheme, TransparentTheme, + TransparentLightTheme, ClassicTheme, LightTheme, HackerTheme, diff --git a/src/EchoHub.Client/UI/Dialogs/ChannelPasswordDialog.cs b/src/EchoHub.Client/UI/Dialogs/ChannelPasswordDialog.cs new file mode 100644 index 0000000..cf497e0 --- /dev/null +++ b/src/EchoHub.Client/UI/Dialogs/ChannelPasswordDialog.cs @@ -0,0 +1,72 @@ +using Terminal.Gui.App; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace EchoHub.Client.UI.Dialogs; + +/// +/// Prompts for a channel password when joining a protected channel. +/// Returns the entered password, or null if the user cancels. +/// +public sealed class ChannelPasswordDialog +{ + public static string? Show(IApplication app, string channelName, string? message = null) + { + string? result = null; + + var dialog = new Dialog { Title = $"Join #{channelName}", Width = 50, Height = 10, CommandsToBubbleUp = [] }; + + var infoLabel = new Label + { + Text = message ?? $"#{channelName} is password protected.", + X = 1, + Y = 1 + }; + + var passwordLabel = new Label { Text = "Password:", X = 1, Y = 3 }; + var passwordField = new TextField { X = 11, Y = 3, Width = Dim.Fill(2), Secret = true }; + + var joinButton = new Button + { + Text = "Join", + IsDefault = true, + X = Pos.Center() - 9, + Y = 5 + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center() + 2, + Y = 5 + }; + + joinButton.Accepting += (s, e) => + { + var password = passwordField.Text; + if (string.IsNullOrEmpty(password)) + { + MessageBox.ErrorQuery(app, "Error", "Password is required.", "OK"); + return; + } + + result = password; + e.Handled = true; + app.RequestStop(); + }; + + cancelButton.Accepting += (s, e) => + { + result = null; + e.Handled = true; + app.RequestStop(); + }; + + dialog.Add(infoLabel, passwordLabel, passwordField, joinButton, cancelButton); + + passwordField.SetFocus(); + app.Run(dialog); + + return result; + } +} diff --git a/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs b/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs index 076b2ad..ae8691c 100644 --- a/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs +++ b/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs @@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase; namespace EchoHub.Client.UI.Dialogs; -public record CreateChannelResult(string Name, string? Topic, bool IsPublic); +public record CreateChannelResult(string Name, string? Topic, bool IsPublic, string? Password); public sealed class CreateChannelDialog { @@ -12,7 +12,7 @@ public sealed class CreateChannelDialog { CreateChannelResult? result = null; - var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14, CommandsToBubbleUp = [] }; + var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 16, CommandsToBubbleUp = [] }; var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 }; var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) }; @@ -20,19 +20,22 @@ public sealed class CreateChannelDialog var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 }; var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) }; + var passwordLabel = new Label { Text = "Password:", X = 1, Y = 5 }; + var passwordField = new TextField { X = 11, Y = 5, Width = Dim.Fill(2), Secret = true }; + var publicCheckbox = new CheckBox { Text = "Public (visible to all users)", X = 1, - Y = 5, + Y = 7, Value = CheckState.Checked }; var hintLabel = new Label { - Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)", + Text = "Name: a-z, 0-9, -, _ (2-100 chars). Empty password = open channel.", X = 1, - Y = 7, + Y = 9, }; var createButton = new Button @@ -40,14 +43,14 @@ public sealed class CreateChannelDialog Text = "Create", IsDefault = true, X = Pos.Center() - 10, - Y = 9 + Y = 11 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 5, - Y = 9 + Y = 11 }; createButton.Accepting += (s, e) => @@ -63,8 +66,12 @@ public sealed class CreateChannelDialog if (string.IsNullOrWhiteSpace(topic)) topic = null; + var password = passwordField.Text; + if (string.IsNullOrWhiteSpace(password)) + password = null; + var isPublic = publicCheckbox.Value == CheckState.Checked; - result = new CreateChannelResult(name, topic, isPublic); + result = new CreateChannelResult(name, topic, isPublic, password); e.Handled = true; app.RequestStop(); }; @@ -76,7 +83,8 @@ public sealed class CreateChannelDialog app.RequestStop(); }; - dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton); + dialog.Add(nameLabel, nameField, topicLabel, topicField, passwordLabel, passwordField, + publicCheckbox, hintLabel, createButton, cancelButton); nameField.SetFocus(); app.Run(dialog); diff --git a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs index a54a99c..0b53d8c 100644 --- a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs +++ b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs @@ -16,6 +16,7 @@ public class ChannelListSource : IListDataSource { private readonly List _channelNames = []; private readonly Dictionary _unreadCounts = []; + private readonly HashSet _protectedChannels = []; private string _activeChannel = string.Empty; public event NotifyCollectionChangedEventHandler? CollectionChanged; @@ -28,13 +29,17 @@ public class ChannelListSource : IListDataSource private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None); private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None); - public void Update(List channels, Dictionary unread, string activeChannel) + public void Update(List channels, Dictionary unread, string activeChannel, + IReadOnlySet? protectedChannels = null) { _channelNames.Clear(); _channelNames.AddRange(channels); _unreadCounts.Clear(); foreach (var kv in unread) _unreadCounts[kv.Key] = kv.Value; + _protectedChannels.Clear(); + if (protectedChannels is not null) + _protectedChannels.UnionWith(protectedChannels); _activeChannel = activeChannel; MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0; if (!SuspendCollectionChangedEvent) @@ -57,7 +62,8 @@ public class ChannelListSource : IListDataSource var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); var prefix = isActive ? "> " : " "; - var channelText = $"#{name}"; + // Trailing * marks password-protected (+k) channels + var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}"; var badge = hasUnread ? $" ({unread})" : ""; // Resolve Transparent backgrounds to the view's actual background diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 919a4a0..1404ce9 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -7,6 +7,7 @@ using EchoHub.Client.UI.Helpers; using EchoHub.Client.UI.ListSources; using EchoHub.Core.DTOs; using EchoHub.Core.Models; +using Serilog; using Terminal.Gui.App; using Terminal.Gui.Configuration; using Terminal.Gui.Drawing; @@ -50,6 +51,10 @@ public sealed partial class MainWindow : Runnable private static readonly Key AltQKey = Key.Q.WithAlt; private static readonly Key TabKey = Key.Tab; private static readonly Key CtrlKKey = Key.K.WithCtrl; + private static readonly Key CtrlVKey = Key.V.WithCtrl; + private static readonly Key CtrlXKey = Key.X.WithCtrl; + private static readonly Key CtrlCKey = Key.C.WithCtrl; + private static readonly Key CtrlYKey = Key.Y.WithCtrl; // Available slash commands for Tab autocomplete private static readonly string[] SlashCommands = @@ -63,6 +68,7 @@ public sealed partial class MainWindow : Runnable private readonly List _channelNames = []; private readonly Dictionary _channelTopics = []; private readonly Dictionary _channelPublic = []; + private readonly HashSet _channelProtected = []; private readonly ChannelListSource _channelListSource; private readonly ChatMessageManager _messageManager; private string _connectionStatus = "Disconnected"; @@ -253,6 +259,10 @@ public sealed partial class MainWindow : Runnable Height = Dim.Fill(), WordWrap = true }; + // Terminal.Gui binds Ctrl+W to Command.Cut, whose OS clipboard write can throw + // Win32Exception when another process holds the clipboard, crashing the app. + // Rebind it to delete-word-backward (readline behavior), which never touches the clipboard. + _inputField.KeyBindings.ReplaceCommands(Key.W.WithCtrl, Command.KillWordLeft); _inputField.KeyDown += OnInputKeyDown; _inputField.ContentsChanged += OnInputContentsChanged; _inputFrame.Add(_inputField); @@ -540,19 +550,81 @@ public sealed partial class MainWindow : Runnable ShowSearchDialog(); e.Handled = true; } + else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode) + { + // Explicit paste support: terminals that don't intercept Ctrl+V themselves + // otherwise leave users with only the right-click context menu. + GuardedClipboardAction(() => _inputField.Paste(), "paste"); + e.Handled = true; + } + else if (e.KeyCode == CtrlXKey.KeyCode) + { + GuardedClipboardAction(() => _inputField.Cut(), "cut"); + e.Handled = true; + } + else if (e.KeyCode == CtrlCKey.KeyCode) + { + GuardedClipboardAction(() => _inputField.Copy(), "copy"); + e.Handled = true; + } + } + + /// + /// Runs a clipboard-backed edit action, swallowing transient OS clipboard failures + /// (e.g. another process holding the Windows clipboard) that would otherwise + /// propagate out of the input loop and crash the app. + /// + private static void GuardedClipboardAction(Action action, string operation) + { + try + { + action(); + } + catch (Exception ex) + { + Log.Warning(ex, "Clipboard {Operation} failed", operation); + } } private bool _suppressEmojiReplace; + private int _lastInputLength; private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e) { + var text = _inputField.Text; + var previousLength = _lastInputLength; + _lastInputLength = text?.Length ?? 0; + if (_suppressEmojiReplace) return; - var text = _inputField.Text; if (string.IsNullOrEmpty(text)) return; + // A file dropped onto the terminal arrives as a pasted absolute path. + // Detect multi-char bursts that resolve to existing files and route them + // through /send instead of leaving a raw path in the input. + if (text.Length - previousLength > 3 && TryGetDroppedFiles(text, out var droppedFiles)) + { + var channel = _messageManager.CurrentChannel; + if (!string.IsNullOrEmpty(channel)) + { + _suppressEmojiReplace = true; + try + { + _inputField.Text = string.Empty; + } + finally + { + _suppressEmojiReplace = false; + } + + foreach (var file in droppedFiles) + OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\""); + return; + } + } + var replaced = EmojiHelper.ReplaceEmoji(text); if (replaced == text) return; @@ -562,9 +634,15 @@ public sealed partial class MainWindow : Runnable var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta); _suppressEmojiReplace = true; - _inputField.Text = replaced; - _inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow); - _suppressEmojiReplace = false; + try + { + _inputField.Text = replaced; + _inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow); + } + finally + { + _suppressEmojiReplace = false; + } } /// @@ -604,6 +682,80 @@ public sealed partial class MainWindow : Runnable _inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0); } + /// + /// Interprets pasted text as one or more dropped files. Terminals deliver a file drop + /// as the absolute path (quoted when it contains spaces; multiple files space-separated). + /// Returns true only when the entire input resolves to existing files. + /// + private static bool TryGetDroppedFiles(string text, out List files) + { + files = []; + + var trimmed = text.Trim(); + if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n')) + return false; + + // Single unquoted path, possibly with spaces (e.g. WSL or plain conhost drops) + var unquoted = StripQuotes(trimmed); + if (Path.IsPathFullyQualified(unquoted) && File.Exists(unquoted)) + { + files.Add(unquoted); + return true; + } + + // Multiple files: space-separated tokens, each optionally quoted + foreach (var token in TokenizeQuoted(trimmed)) + { + if (!Path.IsPathFullyQualified(token) || !File.Exists(token)) + { + files.Clear(); + return false; + } + files.Add(token); + } + + return files.Count > 0; + } + + private static string StripQuotes(string s) => + s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\'')) + ? s[1..^1] + : s; + + private static IEnumerable TokenizeQuoted(string input) + { + var current = new System.Text.StringBuilder(); + var quote = '\0'; + + foreach (var c in input) + { + if (quote != '\0') + { + if (c == quote) quote = '\0'; + else current.Append(c); + } + else if (c is '"' or '\'') + { + quote = c; + } + else if (c == ' ') + { + if (current.Length > 0) + { + yield return current.ToString(); + current.Clear(); + } + } + else + { + current.Append(c); + } + } + + if (current.Length > 0) + yield return current.ToString(); + } + private void OnChatViewportChanged() { var newWidth = _messageList.Viewport.Width; @@ -675,11 +827,14 @@ public sealed partial class MainWindow : Runnable _channelNames.Clear(); _channelTopics.Clear(); _channelPublic.Clear(); + _channelProtected.Clear(); foreach (var ch in channels) { _channelNames.Add(ch.Name); _channelTopics[ch.Name] = ch.Topic; _channelPublic[ch.Name] = ch.IsPublic; + if (ch.IsProtected) + _channelProtected.Add(ch.Name); } RefreshChannelList(); } @@ -687,13 +842,23 @@ public sealed partial class MainWindow : Runnable /// /// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// - public void EnsureChannelInList(string channelName, bool? isPublic = null) + public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null) { if (isPublic.HasValue) _channelPublic[channelName] = isPublic.Value; + if (isProtected.HasValue) + { + if (isProtected.Value) _channelProtected.Add(channelName); + else _channelProtected.Remove(channelName); + } + if (_channelNames.Contains(channelName)) + { + if (isProtected.HasValue) + RefreshChannelList(); return; + } _channelNames.Add(channelName); RefreshChannelList(); @@ -707,6 +872,7 @@ public sealed partial class MainWindow : Runnable _channelNames.Remove(channelName); _channelTopics.Remove(channelName); _channelPublic.Remove(channelName); + _channelProtected.Remove(channelName); RefreshChannelList(); } @@ -792,6 +958,8 @@ public sealed partial class MainWindow : Runnable { _channelPublic.TryGetValue(currentChannel, out var isPublic); var typeSuffix = isPublic ? "public" : "private"; + if (_channelProtected.Contains(currentChannel)) + typeSuffix += " +k"; Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr); } @@ -855,6 +1023,7 @@ public sealed partial class MainWindow : Runnable _messageManager.ClearAll(); _channelTopics.Clear(); _channelPublic.Clear(); + _channelProtected.Clear(); _channelListSource.Update([], [], string.Empty); _channelList.Source = _channelListSource; _chatFrame.Title = "Chat"; @@ -915,7 +1084,7 @@ public sealed partial class MainWindow : Runnable /// private void RefreshChannelList() { - _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel); + _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected); _channelList.Source = _channelListSource; // Restore selection to current channel diff --git a/src/EchoHub.Core/Constants/ValidationConstants.cs b/src/EchoHub.Core/Constants/ValidationConstants.cs index fd8659b..7822b81 100644 --- a/src/EchoHub.Core/Constants/ValidationConstants.cs +++ b/src/EchoHub.Core/Constants/ValidationConstants.cs @@ -9,6 +9,7 @@ public static partial class ValidationConstants public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$"; public const int MaxPasswordLength = 128; + public const int MinChannelPasswordLength = 3; public const int MaxDisplayNameLength = 100; public const int MaxBioLength = 500; public const int MaxStatusMessageLength = 100; diff --git a/src/EchoHub.Core/Contracts/IChannelService.cs b/src/EchoHub.Core/Contracts/IChannelService.cs index e445160..bf6cb74 100644 --- a/src/EchoHub.Core/Contracts/IChannelService.cs +++ b/src/EchoHub.Core/Contracts/IChannelService.cs @@ -6,8 +6,9 @@ public interface IChannelService { // Channel CRUD Task> GetChannelsAsync(Guid userId, int offset, int limit); - Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic); + Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null); Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic); + Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password); Task DeleteChannelAsync(Guid callerUserId, string channelName); // Channel queries @@ -16,7 +17,7 @@ public interface IChannelService Task GetChannelByNameAsync(string channelName); // Membership - Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName); + Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null); } -public record ChannelListItem(string Name, string? Topic, int OnlineCount); +public record ChannelListItem(string Name, string? Topic, int OnlineCount, bool IsPublic = true, bool IsProtected = false); diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index 4b10f28..230b7b4 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -10,7 +10,7 @@ public interface IChatService Task UserDisconnectedAsync(string connectionId); // Channel operations - Task<(List History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName); + Task<(List History, string? Error, bool PasswordRequired)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName, string? password = null); Task LeaveChannelAsync(string connectionId, string username, string channelName); // Messaging diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 3a7e624..694f1a4 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -21,7 +21,8 @@ public record ChannelDto( string? Topic, bool IsPublic, int MessageCount, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + bool IsProtected = false); public record UserDto( Guid Id, @@ -33,13 +34,13 @@ public record UserDto( public record SendMessageRequest(string ChannelName, string Content); -public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true); +public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true, string? Password = null); public record UpdateTopicRequest(string? Topic); public record SendUrlRequest(string Url); -public record JoinChannelResult(bool Success, List History, string? Error = null); +public record JoinChannelResult(bool Success, List History, string? Error = null, bool PasswordRequired = false); public record EmbedDto( string? SiteName, diff --git a/src/EchoHub.Core/Models/Channel.cs b/src/EchoHub.Core/Models/Channel.cs index b936e6e..b305705 100644 --- a/src/EchoHub.Core/Models/Channel.cs +++ b/src/EchoHub.Core/Models/Channel.cs @@ -6,6 +6,7 @@ public class Channel public required string Name { get; set; } public string? Topic { get; set; } public bool IsPublic { get; set; } = true; + public string? PasswordHash { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public Guid CreatedByUserId { get; set; } diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 4346181..3cdc77a 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -329,7 +329,7 @@ public sealed class IrcCommandHandler await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO, $"{ServerName} EchoHub-IRC o o"); await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT, - "CHANTYPES=# NICKLEN=50 CHANNELLEN=100 :are supported by this server"); + "CHANTYPES=# CHANMODES=b,k,, NICKLEN=50 CHANNELLEN=100 :are supported by this server"); await SendMotdAsync(); } @@ -371,8 +371,17 @@ public sealed class IrcCommandHandler var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries); - foreach (var rawChannel in channels) + // RFC 1459: optional second parameter carries comma-separated channel keys, + // paired with channels by position (JOIN #a,#b key1,key2). + var keys = msg.Parameters.Count > 1 + ? msg.Parameters[1].Split(',') + : []; + + for (var i = 0; i < channels.Length; i++) { + var rawChannel = channels[i]; + var key = i < keys.Length && !string.IsNullOrEmpty(keys[i]) ? keys[i] : null; + var channelName = IrcToEchoHubChannel(rawChannel); if (channelName is null) { @@ -381,13 +390,21 @@ public sealed class IrcCommandHandler continue; } - var (history, error) = await _chatService.JoinChannelAsync( - _conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName); + var (history, error, passwordRequired) = await _chatService.JoinChannelAsync( + _conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key); if (error is not null) { - await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, - $"#{channelName} :{error}"); + if (passwordRequired) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_BADCHANNELKEY, + $"#{channelName} :Cannot join channel (+k) — {error}"); + } + else + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, + $"#{channelName} :{error}"); + } continue; } @@ -512,8 +529,22 @@ public sealed class IrcCommandHandler } else { - await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CHANOPRIVSNEEDED, - $"#{channelName} :Topic can only be changed by the channel creator via the API"); + var topic = msg.Parameters[1]; + var result = await _channelService.UpdateTopicAsync( + _conn.UserId!.Value, channelName, string.IsNullOrWhiteSpace(topic) ? null : topic); + + if (!result.IsSuccess) + { + var numeric = result.Error == ChannelError.NotFound + ? IrcNumericReply.ERR_NOSUCHCHANNEL + : IrcNumericReply.ERR_CHANOPRIVSNEEDED; + await _conn.SendNumericAsync(ServerName, numeric, $"#{channelName} :{result.ErrorMessage}"); + return; + } + + // Notify SignalR clients and echo the change back to the IRC client + await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channelName); + await _conn.SendAsync($":{_conn.Hostmask} TOPIC #{channelName} :{result.Channel!.Topic ?? ""}"); } } @@ -627,10 +658,12 @@ public sealed class IrcCommandHandler var channels = await _channelService.GetChannelListAsync(); - foreach (var ch in channels) + // Private channels are hidden from discovery, matching the SignalR client's channel list + foreach (var ch in channels.Where(c => c.IsPublic)) { + var lockHint = ch.IsProtected ? "[+k] " : ""; await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LIST, - $"#{ch.Name} {ch.OnlineCount} :{ch.Topic ?? ""}"); + $"#{ch.Name} {ch.OnlineCount} :{lockHint}{ch.Topic ?? ""}"); } await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LISTEND, @@ -644,15 +677,94 @@ public sealed class IrcCommandHandler var target = msg.Parameters[0]; - if (target.StartsWith('#')) - { - await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS, - $"{target} +"); - } - else + if (!target.StartsWith('#')) { await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UMODEIS, "+"); + return; } + + var channelName = IrcToEchoHubChannel(target); + if (channelName is null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, + $"{target} :No such channel"); + return; + } + + // Query: MODE #channel + if (msg.Parameters.Count == 1) + { + var channel = await _channelService.GetChannelByNameAsync(channelName); + if (channel is null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, + $"#{channelName} :No such channel"); + return; + } + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS, + $"#{channelName} {(channel.IsProtected ? "+k" : "+")}"); + return; + } + + var modes = msg.Parameters[1]; + + // Clients commonly probe the ban list on join — reply with an empty list + if (modes is "b" or "+b") + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFBANLIST, + $"#{channelName} :End of channel ban list"); + return; + } + + switch (modes) + { + case "+k": + if (msg.Parameters.Count < 3) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS, + "MODE :Not enough parameters"); + return; + } + + var key = msg.Parameters[2]; + var setResult = await _channelService.SetChannelPasswordAsync(_conn.UserId!.Value, channelName, key); + if (!setResult.IsSuccess) + { + await SendModeErrorAsync(channelName, setResult); + return; + } + + await _conn.SendAsync($":{_conn.Hostmask} MODE #{channelName} +k {key}"); + return; + + case "-k": + var clearResult = await _channelService.SetChannelPasswordAsync(_conn.UserId!.Value, channelName, null); + if (!clearResult.IsSuccess) + { + await SendModeErrorAsync(channelName, clearResult); + return; + } + + await _conn.SendAsync($":{_conn.Hostmask} MODE #{channelName} -k *"); + return; + + default: + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNMODE, + $"{modes} :is unknown mode char to me for #{channelName}"); + return; + } + } + + private async Task SendModeErrorAsync(string channelName, ChannelOperationResult result) + { + var numeric = result.Error switch + { + ChannelError.NotFound => IrcNumericReply.ERR_NOSUCHCHANNEL, + ChannelError.Forbidden => IrcNumericReply.ERR_CHANOPRIVSNEEDED, + _ => IrcNumericReply.ERR_KEYSET, + }; + await _conn.SendNumericAsync(ServerName, numeric, $"#{channelName} :{result.ErrorMessage}"); } private async Task HandlePingAsync(IrcMessage msg) diff --git a/src/EchoHub.Server.Irc/IrcNumericReply.cs b/src/EchoHub.Server.Irc/IrcNumericReply.cs index 60fdb21..1359743 100644 --- a/src/EchoHub.Server.Irc/IrcNumericReply.cs +++ b/src/EchoHub.Server.Irc/IrcNumericReply.cs @@ -42,6 +42,7 @@ public static class IrcNumericReply // MODE public const string RPL_CHANNELMODEIS = "324"; public const string RPL_UMODEIS = "221"; + public const string RPL_ENDOFBANLIST = "368"; // Errors public const string ERR_NOSUCHNICK = "401"; @@ -56,6 +57,9 @@ public static class IrcNumericReply public const string ERR_NEEDMOREPARAMS = "461"; public const string ERR_ALREADYREGISTERED = "462"; public const string ERR_PASSWDMISMATCH = "464"; + public const string ERR_KEYSET = "467"; + public const string ERR_UNKNOWNMODE = "472"; + public const string ERR_BADCHANNELKEY = "475"; public const string ERR_CHANOPRIVSNEEDED = "482"; // SASL diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 610b26c..978d65a 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -65,7 +65,7 @@ public class ChannelsController : ControllerBase return Unauthorized(new ErrorResponse("Authentication required.")); var result = await _channelService.CreateChannelAsync( - Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic); + Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password); if (!result.IsSuccess) return MapChannelError(result); diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index 303cda5..efd244a 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -44,6 +44,7 @@ public class EchoHubDbContext : DbContext entity.HasIndex(c => c.Name).IsUnique(); entity.Property(c => c.Name).IsRequired().HasMaxLength(100); entity.Property(c => c.Topic).HasMaxLength(500); + entity.Property(c => c.PasswordHash).HasMaxLength(100); entity.HasMany(c => c.Messages) .WithOne(m => m.Channel) diff --git a/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.Designer.cs new file mode 100644 index 0000000..11d4669 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.Designer.cs @@ -0,0 +1,271 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260715232856_AddChannelPasswordHash")] + partial class AddChannelPasswordHash + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.HasOne("EchoHub.Core.Models.Channel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EchoHub.Core.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs new file mode 100644 index 0000000..c01f2c0 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddChannelPasswordHash : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordHash", + table: "Channels", + type: "TEXT", + maxLength: 100, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "Channels"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index bf38795..c9fcf62 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -37,6 +37,10 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("TEXT"); + b.Property("Topic") .HasMaxLength(500) .HasColumnType("TEXT"); diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index 723ac07..466778b 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -56,15 +56,15 @@ public class ChatHub : Hub } } - public async Task JoinChannel(string channelName) + public async Task JoinChannel(string channelName, string? password = null) { try { - var (history, error) = await _chatService.JoinChannelAsync( - Context.ConnectionId, CurrentUserId, CurrentUsername, channelName); + var (history, error, passwordRequired) = await _chatService.JoinChannelAsync( + Context.ConnectionId, CurrentUserId, CurrentUsername, channelName, password); if (error is not null) - return new JoinChannelResult(false, [], error); + return new JoinChannelResult(false, [], error, passwordRequired); await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim()); return new JoinChannelResult(true, history); diff --git a/src/EchoHub.Server/Services/ChannelService.cs b/src/EchoHub.Server/Services/ChannelService.cs index d4ef85e..65aa692 100644 --- a/src/EchoHub.Server/Services/ChannelService.cs +++ b/src/EchoHub.Server/Services/ChannelService.cs @@ -41,14 +41,14 @@ public class ChannelService : IChannelService .Skip(offset) .Take(limit) .Select(c => new ChannelDto( - c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt)) + c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt, c.PasswordHash != null)) .ToListAsync(); return new PaginatedResponse(channels, total, offset, limit); } public async Task CreateChannelAsync( - Guid creatorUserId, string name, string? topic, bool isPublic) + Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) { if (string.IsNullOrWhiteSpace(name)) return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required."); @@ -59,6 +59,10 @@ public class ChannelService : IChannelService return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."); + var passwordError = ValidateChannelPassword(ref password); + if (passwordError is not null) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -72,6 +76,7 @@ public class ChannelService : IChannelService Topic = topic?.Trim(), IsPublic = isPublic, CreatedByUserId = creatorUserId, + PasswordHash = password is not null ? BCrypt.Net.BCrypt.HashPassword(password) : null, }; db.Channels.Add(channel); @@ -85,7 +90,8 @@ public class ChannelService : IChannelService await db.SaveChangesAsync(); - var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt); + var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt, + channel.PasswordHash != null); return ChannelOperationResult.Success(dto); } @@ -112,7 +118,40 @@ public class ChannelService : IChannelService await db.SaveChangesAsync(); var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); - var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt, + dbChannel.PasswordHash != null); + return ChannelOperationResult.Success(dto); + } + + /// + /// Sets, changes, or clears (null) a channel's join password. Creator or admin only. + /// + public async Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password) + { + channelName = channelName.ToLowerInvariant().Trim(); + + var passwordError = ValidateChannelPassword(ref password); + if (passwordError is not null) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); + + var caller = await db.Users.FindAsync(callerUserId); + if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin)) + return ChannelOperationResult.Fail(ChannelError.Forbidden, + "Only the channel creator or an admin can change the channel password."); + + dbChannel.PasswordHash = password is not null ? BCrypt.Net.BCrypt.HashPassword(password) : null; + await db.SaveChangesAsync(); + + var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt, + dbChannel.PasswordHash != null); return ChannelOperationResult.Success(dto); } @@ -139,7 +178,8 @@ public class ChannelService : IChannelService db.Channels.Remove(dbChannel); await db.SaveChangesAsync(); - var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt, + dbChannel.PasswordHash != null); return ChannelOperationResult.Success(dto); } @@ -165,7 +205,8 @@ public class ChannelService : IChannelService return channels.Select(c => new ChannelListItem( c.Name, c.Topic, - _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); + _presenceTracker.GetOnlineUsersInChannel(c.Name).Count, + c.IsPublic, c.PasswordHash != null)).ToList(); } public async Task GetChannelByNameAsync(string channelName) @@ -179,15 +220,16 @@ public class ChannelService : IChannelService if (c is null) return null; var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id); - return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt); + return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt, c.PasswordHash != null); } - public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) + public async Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync( + Guid userId, string channelName, string? password = null) { channelName = channelName.ToLowerInvariant().Trim(); if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) - return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); + return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.", false); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -211,7 +253,7 @@ public class ChannelService : IChannelService } else { - return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list."); + return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.", false); } } @@ -219,6 +261,17 @@ public class ChannelService : IChannelService .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id); if (!hasMembership) { + // Password gate: existing members (incl. the creator) joined before, so only + // first-time joins of a protected channel need the password. + if (channel.PasswordHash is not null) + { + if (string.IsNullOrEmpty(password)) + return (false, $"Channel '{channelName}' is password protected.", true); + + if (!BCrypt.Net.BCrypt.Verify(password, channel.PasswordHash)) + return (false, $"Incorrect password for channel '{channelName}'.", true); + } + db.ChannelMemberships.Add(new ChannelMembership { UserId = userId, @@ -227,7 +280,28 @@ public class ChannelService : IChannelService await db.SaveChangesAsync(); } - return (true, null); + return (true, null, false); + } + + /// + /// Normalizes and validates a channel password. Whitespace-only becomes null (no password). + /// Returns an error message, or null when valid. + /// + private static string? ValidateChannelPassword(ref string? password) + { + if (string.IsNullOrWhiteSpace(password)) + { + password = null; + return null; + } + + if (password.Length < ValidationConstants.MinChannelPasswordLength) + return $"Channel password must be at least {ValidationConstants.MinChannelPasswordLength} characters."; + + if (password.Length > ValidationConstants.MaxPasswordLength) + return $"Channel password must not exceed {ValidationConstants.MaxPasswordLength} characters."; + + return null; } private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db) diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index a7bf032..91e7999 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -93,15 +93,15 @@ public class ChatService : IChatService return username; } - public async Task<(List History, string? Error)> JoinChannelAsync( - string connectionId, Guid userId, string username, string channelName) + public async Task<(List History, string? Error, bool PasswordRequired)> JoinChannelAsync( + string connectionId, Guid userId, string username, string channelName, string? password = null) { channelName = channelName.ToLowerInvariant().Trim(); - // Delegate channel validation + membership to ChannelService - var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName); + // Delegate channel validation + membership (incl. password gate) to ChannelService + var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password); if (!success) - return ([], error); + return ([], error, passwordRequired); var isNewJoin = _presenceTracker.JoinChannel(username, channelName); @@ -136,7 +136,7 @@ public class ChatService : IChatService } var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount); - return (history, null); + return (history, null, false); } public async Task LeaveChannelAsync(string connectionId, string username, string channelName) diff --git a/src/EchoHub.Tests/CommandHandlerTests.cs b/src/EchoHub.Tests/CommandHandlerTests.cs index 5c58bf7..b6f1657 100644 --- a/src/EchoHub.Tests/CommandHandlerTests.cs +++ b/src/EchoHub.Tests/CommandHandlerTests.cs @@ -193,7 +193,7 @@ public class CommandHandlerTests { var handler = CreateHandler(); string? capturedChannel = null; - handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; }; + handler.OnJoinChannel += (ch, _) => { capturedChannel = ch; return Task.CompletedTask; }; await handler.HandleAsync("/join #random"); Assert.Equal("random", capturedChannel); @@ -204,12 +204,25 @@ public class CommandHandlerTests { var handler = CreateHandler(); string? capturedChannel = null; - handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; }; + handler.OnJoinChannel += (ch, _) => { capturedChannel = ch; return Task.CompletedTask; }; await handler.HandleAsync("/join random"); Assert.Equal("random", capturedChannel); } + [Fact] + public async Task HandleAsync_Join_WithPassword_PassesPassword() + { + var handler = CreateHandler(); + string? capturedChannel = null; + string? capturedPassword = null; + handler.OnJoinChannel += (ch, pw) => { capturedChannel = ch; capturedPassword = pw; return Task.CompletedTask; }; + + await handler.HandleAsync("/join #secret hunter2"); + Assert.Equal("secret", capturedChannel); + Assert.Equal("hunter2", capturedPassword); + } + [Fact] public async Task HandleAsync_Join_NoArgs_ReturnsError() { diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs index 35c45f8..d6b641f 100644 --- a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -254,6 +254,39 @@ public class IrcCommandHandlerTests Assert.Equal("general", _chatService.JoinedChannels[0].Channel); } + [Fact] + public async Task Join_WithKey_PassesKeyToChatService() + { + _channelService.TopicResult = (null, true); + + var lines = await RunAuthenticated(["JOIN #secret hunter2"]); + + Assert.Contains(lines, l => l.Contains("JOIN #secret")); + Assert.Single(_chatService.JoinKeys); + Assert.Equal("hunter2", _chatService.JoinKeys[0]); + } + + [Fact] + public async Task Join_MultipleChannelsWithKeys_PairsKeysByPosition() + { + _channelService.TopicResult = (null, true); + + await RunAuthenticated(["JOIN #chan-a,#chan-b key1,key2"]); + + Assert.Equal(["key1", "key2"], _chatService.JoinKeys); + } + + [Fact] + public async Task Join_ProtectedChannelWithoutKey_GetsBadChannelKey() + { + _chatService.JoinError = "Channel 'secret' is password protected."; + _chatService.JoinPasswordRequired = true; + + var lines = await RunAuthenticated(["JOIN #secret"]); + + Assert.Contains(lines, l => l.Contains("475") && l.Contains("#secret") && l.Contains("+k")); + } + [Fact] public async Task Join_SendsTopic() { @@ -450,13 +483,27 @@ public class IrcCommandHandlerTests } [Fact] - public async Task Topic_SetAttempt_GetsPermissionDenied() + public async Task Topic_SetByNonCreator_GetsPermissionDenied() { + _channelService.UpdateTopicResult = ChannelOperationResult.Fail( + ChannelError.Forbidden, "Only the channel creator can update the topic."); + var lines = await RunAuthenticated(["TOPIC #general :New topic"]); Assert.Contains(lines, l => l.Contains("482") && l.Contains("channel creator")); } + [Fact] + public async Task Topic_SetByCreator_UpdatesAndEchoesTopic() + { + _channelService.UpdateTopicResult = ChannelOperationResult.Success( + new ChannelDto(Guid.NewGuid(), "general", "New topic", true, 0, DateTimeOffset.UtcNow)); + + var lines = await RunAuthenticated(["TOPIC #general :New topic"]); + + Assert.Contains(lines, l => l.Contains("TOPIC #general") && l.Contains("New topic")); + } + // ── WHO ────────────────────────────────────────────────────────────── [Fact] @@ -564,9 +611,45 @@ public class IrcCommandHandlerTests [Fact] public async Task Mode_Channel_ReturnsChannelModes() { + _channelService.ChannelByNameToReturn = + new ChannelDto(Guid.NewGuid(), "general", null, true, 0, DateTimeOffset.UtcNow); + var lines = await RunAuthenticated(["MODE #general"]); - Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general")); + Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general") && l.Contains("+")); + } + + [Fact] + public async Task Mode_ProtectedChannel_ReportsKeyMode() + { + _channelService.ChannelByNameToReturn = + new ChannelDto(Guid.NewGuid(), "secret", null, true, 0, DateTimeOffset.UtcNow, IsProtected: true); + + var lines = await RunAuthenticated(["MODE #secret"]); + + Assert.Contains(lines, l => l.Contains("324") && l.Contains("#secret") && l.Contains("+k")); + } + + [Fact] + public async Task Mode_SetKey_ByCreator_EchoesModeChange() + { + _channelService.SetPasswordResult = ChannelOperationResult.Success( + new ChannelDto(Guid.NewGuid(), "secret", null, true, 0, DateTimeOffset.UtcNow, IsProtected: true)); + + var lines = await RunAuthenticated(["MODE #secret +k hunter2"]); + + Assert.Contains(lines, l => l.Contains("MODE #secret +k hunter2")); + } + + [Fact] + public async Task Mode_SetKey_ByNonCreator_GetsPermissionDenied() + { + _channelService.SetPasswordResult = ChannelOperationResult.Fail( + ChannelError.Forbidden, "Only the channel creator or an admin can change the channel password."); + + var lines = await RunAuthenticated(["MODE #secret +k hunter2"]); + + Assert.Contains(lines, l => l.Contains("482")); } [Fact] diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 7c5407e..d726132 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -155,6 +155,7 @@ internal sealed class FakeChatService : IChatService // Configurable results public List HistoryToReturn { get; set; } = []; public string? JoinError { get; set; } + public bool JoinPasswordRequired { get; set; } public string? SendMessageError { get; set; } public List ChannelsForUserToReturn { get; set; } = []; public List OnlineUsersToReturn { get; set; } = []; @@ -171,11 +172,14 @@ internal sealed class FakeChatService : IChatService return Task.FromResult(null); } - public Task<(List History, string? Error)> JoinChannelAsync( - string connectionId, Guid userId, string username, string channelName) + public List JoinKeys { get; } = []; + + public Task<(List History, string? Error, bool PasswordRequired)> JoinChannelAsync( + string connectionId, Guid userId, string username, string channelName, string? password = null) { JoinedChannels.Add((channelName, username)); - return Task.FromResult((HistoryToReturn, JoinError)); + JoinKeys.Add(password); + return Task.FromResult((HistoryToReturn, JoinError, JoinPasswordRequired)); } public Task LeaveChannelAsync(string connectionId, string username, string channelName) @@ -224,17 +228,21 @@ internal sealed class FakeChannelService : IChannelService public ChannelOperationResult? CreateResult { get; set; } public ChannelOperationResult? UpdateTopicResult { get; set; } public ChannelOperationResult? DeleteResult { get; set; } - public (bool Success, string? Error) MembershipResult { get; set; } = (true, null); + public ChannelOperationResult? SetPasswordResult { get; set; } + public (bool Success, string? Error, bool PasswordRequired) MembershipResult { get; set; } = (true, null, false); public Task> GetChannelsAsync(Guid userId, int offset, int limit) => Task.FromResult(new PaginatedResponse([], 0, offset, limit)); - public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) => + public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) => Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); public Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) => Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + public Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password) => + Task.FromResult(SetPasswordResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + public Task DeleteChannelAsync(Guid callerUserId, string channelName) => Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); @@ -247,7 +255,7 @@ internal sealed class FakeChannelService : IChannelService public Task GetChannelByNameAsync(string channelName) => Task.FromResult(ChannelByNameToReturn); - public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) => + public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) => Task.FromResult(MembershipResult); }