feat: add password protection for channels

- Updated IChatService to include password parameter in JoinChannelAsync method.
- Modified ChannelDto and related models to support password functionality.
- Implemented password handling in ChannelService for channel creation and membership validation.
- Enhanced IrcCommandHandler to manage channel join requests with passwords.
- Added ChannelPasswordDialog for user input when joining protected channels.
- Created database migration to add PasswordHash column to Channels table.
- Updated tests to cover new password functionality in channel joining and management.
This commit is contained in:
HueByte
2026-07-16 03:13:03 +02:00
parent 15187c4665
commit 3ca9dbfd91
31 changed files with 1056 additions and 96 deletions
+4 -3
View File
@@ -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 <channel>` | Join a channel |
| `/join <channel> [password]` | Join a channel (password for protected channels) |
| `/leave` | Leave current channel |
| `/topic <text>` | Set channel topic (creator only) |
| `/send <file or URL>` | 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* |
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+42 -6
View File
@@ -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
}
}
/// <summary>
/// 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.
/// </summary>
private async Task<List<MessageDto>?> JoinChannelWithPasswordPromptAsync(string channelName, string? password)
{
while (true)
{
try
{
return await _conn.JoinChannelAsync(channelName, password);
}
catch (ChannelPasswordRequiredException ex)
{
var prompt = new TaskCompletionSource<string?>(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)
@@ -13,7 +13,7 @@ public class CommandHandler
public event Func<string, string?, Task>? OnSendFile;
public event Func<string?, Task>? OnOpenProfile;
public event Func<Task>? OnOpenServers;
public event Func<string, Task>? OnJoinChannel;
public event Func<string, string?, Task>? OnJoinChannel;
public event Func<Task>? OnLeaveChannel;
public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers;
@@ -126,7 +126,7 @@ public class CommandHandler
private async Task<CommandResult> HandleTheme(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /theme <name> (Default, Dark, Light, Hacker, Solarized)", IsError: true);
return new CommandResult(true, "Usage: /theme <name> — 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<CommandResult> HandleJoin(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /join <channel>", IsError: true);
return new CommandResult(true, "Usage: /join <channel> [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 <URL or filepath> - Set your avatar
/profile [username] - View a profile
/servers - Open saved servers
/join <channel> - Join a channel
/join <channel> [password] - Join a channel (password if protected)
/leave - Leave current channel
/topic <text> - Set channel topic
/users - List online users
+2 -2
View File
@@ -228,10 +228,10 @@ public sealed class ApiClient : IDisposable
return tempPath;
}
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
public async Task<ChannelDto?> 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);
@@ -169,11 +169,21 @@ internal sealed class ConnectionManager : IAsyncDisposable
// ── Channel Operations ────────────────────────────────────────────────
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
public async Task<List<MessageDto>> JoinChannelAsync(string channelName, string? password = null)
{
if (_connection is null) throw new InvalidOperationException("Not connected");
try
{
var history = await _connection.JoinChannelAsync(channelName, password);
_joinedChannels.Add(channelName);
return await _connection.JoinChannelAsync(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)
@@ -5,6 +5,20 @@ using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Client.Services;
/// <summary>
/// Thrown when joining a channel fails because a password is required or incorrect.
/// The UI catches this to prompt the user and retry.
/// </summary>
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<List<MessageDto>> JoinChannelAsync(string channelName)
public async Task<List<MessageDto>> JoinChannelAsync(string channelName, string? password = null)
{
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName);
var result = await _connection.InvokeAsync<JoinChannelResult>("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);
}
+34
View File
@@ -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<Theme> BuiltInThemes =
[
DefaultTheme,
TransparentTheme,
TransparentLightTheme,
ClassicTheme,
LightTheme,
HackerTheme,
@@ -0,0 +1,72 @@
using Terminal.Gui.App;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
namespace EchoHub.Client.UI.Dialogs;
/// <summary>
/// Prompts for a channel password when joining a protected channel.
/// Returns the entered password, or null if the user cancels.
/// </summary>
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;
}
}
@@ -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);
@@ -16,6 +16,7 @@ public class ChannelListSource : IListDataSource
{
private readonly List<string> _channelNames = [];
private readonly Dictionary<string, int> _unreadCounts = [];
private readonly HashSet<string> _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<string> channels, Dictionary<string, int> unread, string activeChannel)
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
IReadOnlySet<string>? 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
+172 -3
View File
@@ -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<string> _channelNames = [];
private readonly Dictionary<string, string?> _channelTopics = [];
private readonly Dictionary<string, bool> _channelPublic = [];
private readonly HashSet<string> _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;
}
}
/// <summary>
/// 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.
/// </summary>
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,10 +634,16 @@ public sealed partial class MainWindow : Runnable
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
_suppressEmojiReplace = true;
try
{
_inputField.Text = replaced;
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
}
finally
{
_suppressEmojiReplace = false;
}
}
/// <summary>
/// Tab-complete slash commands in the input field.
@@ -604,6 +682,80 @@ public sealed partial class MainWindow : Runnable
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
}
/// <summary>
/// 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.
/// </summary>
private static bool TryGetDroppedFiles(string text, out List<string> 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<string> 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
/// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary>
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
/// </summary>
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
@@ -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;
@@ -6,8 +6,9 @@ public interface IChannelService
{
// Channel CRUD
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic);
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null);
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password);
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
// Channel queries
@@ -16,7 +17,7 @@ public interface IChannelService
Task<ChannelDto?> 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);
+1 -1
View File
@@ -10,7 +10,7 @@ public interface IChatService
Task<string?> UserDisconnectedAsync(string connectionId);
// Channel operations
Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName);
Task<(List<MessageDto> 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
+4 -3
View File
@@ -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<MessageDto> History, string? Error = null);
public record JoinChannelResult(bool Success, List<MessageDto> History, string? Error = null, bool PasswordRequired = false);
public record EmbedDto(
string? SiteName,
+1
View File
@@ -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; }
+126 -14
View File
@@ -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)
{
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)
@@ -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
@@ -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);
@@ -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)
@@ -0,0 +1,271 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddChannelPasswordHash : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PasswordHash",
table: "Channels",
type: "TEXT",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasswordHash",
table: "Channels");
}
}
}
@@ -37,6 +37,10 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
+4 -4
View File
@@ -56,15 +56,15 @@ public class ChatHub : Hub<IEchoHubClient>
}
}
public async Task<JoinChannelResult> JoinChannel(string channelName)
public async Task<JoinChannelResult> 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);
+85 -11
View File
@@ -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<ChannelDto>(channels, total, offset, limit);
}
public async Task<ChannelOperationResult> 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<EchoHubDbContext>();
@@ -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);
}
/// <summary>
/// Sets, changes, or clears (null) a channel's join password. Creator or admin only.
/// </summary>
public async Task<ChannelOperationResult> 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<EchoHubDbContext>();
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<ChannelDto?> 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<EchoHubDbContext>();
@@ -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);
}
/// <summary>
/// Normalizes and validates a channel password. Whitespace-only becomes null (no password).
/// Returns an error message, or null when valid.
/// </summary>
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)
+6 -6
View File
@@ -93,15 +93,15 @@ public class ChatService : IChatService
return username;
}
public async Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(
string connectionId, Guid userId, string username, string channelName)
public async Task<(List<MessageDto> 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)
+15 -2
View File
@@ -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()
{
@@ -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]
+14 -6
View File
@@ -155,6 +155,7 @@ internal sealed class FakeChatService : IChatService
// Configurable results
public List<MessageDto> HistoryToReturn { get; set; } = [];
public string? JoinError { get; set; }
public bool JoinPasswordRequired { get; set; }
public string? SendMessageError { get; set; }
public List<string> ChannelsForUserToReturn { get; set; } = [];
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
@@ -171,11 +172,14 @@ internal sealed class FakeChatService : IChatService
return Task.FromResult<string?>(null);
}
public Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(
string connectionId, Guid userId, string username, string channelName)
public List<string?> JoinKeys { get; } = [];
public Task<(List<MessageDto> 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<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) =>
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) =>
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password) =>
Task.FromResult(SetPasswordResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> 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<ChannelDto?> 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);
}