mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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:
@@ -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* |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+271
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user