From 64bca5161912e9fd935866b8669830ba9f6f866b Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:49:00 +0200 Subject: [PATCH 01/10] feat: show date on messages older than a day and improve time formatting --- src/EchoHub.Client/UI/Chat/ChatMessageManager.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index cccd834..e8b32c1 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -101,7 +101,7 @@ public sealed class ChatMessageManager _channelMessages[channelName] = messages; } - var time = DateTimeOffset.Now.ToString("HH:mm"); + var time = FormatDateTime(DateTimeOffset.Now); var textLines = text.Split('\n'); messages.Add(new ChatLine( @@ -130,7 +130,7 @@ public sealed class ChatMessageManager /// public void AddStatusMessage(string channelName, string username, string status) { - var time = DateTimeOffset.Now.ToString("HH:mm"); + var time = FormatDateTime(DateTimeOffset.Now); var segments = new List { new($"[{time}] ", ChatColors.TimestampAttr), @@ -234,7 +234,7 @@ public sealed class ChatMessageManager private List FormatMessage(MessageDto message) { - var time = message.SentAt.ToLocalTime().ToString("HH:mm"); + var time = FormatDateTime(message.SentAt); var senderName = message.SenderUsername + ":"; var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor); @@ -425,6 +425,14 @@ public sealed class ChatMessageManager return result; } + private static string FormatDateTime(DateTimeOffset timestamp) + { + if (timestamp.Date == DateTimeOffset.Now.Date) + return timestamp.ToLocalTime().ToString("t"); + else + return timestamp.ToLocalTime().ToString("g"); + } + internal static string FormatFileSize(long? bytes) { if (bytes is null or 0) From 15187c4665707564b7a1bb24d71d1507c735f3bf Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:03:26 +0200 Subject: [PATCH 02/10] chore: bump version and add changelog --- docs/changelog/v0.2.12.md | 5 +++++ src/Directory.Build.props | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 docs/changelog/v0.2.12.md diff --git a/docs/changelog/v0.2.12.md b/docs/changelog/v0.2.12.md new file mode 100644 index 0000000..9432219 --- /dev/null +++ b/docs/changelog/v0.2.12.md @@ -0,0 +1,5 @@ +# v0.2.12 + +## New Features + +- Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0b9a26a..72d4af1 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.11 + 0.2.12 true $(NoWarn);CS1591 From 3ca9dbfd91a25eb8d78fbea7f18fcd8314bc8fc7 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 03:13:03 +0200 Subject: [PATCH 03/10] 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. --- README.md | 7 +- docs/articles/architecture.md | 2 +- docs/todo.md | 2 +- src/EchoHub.Client/AppOrchestrator.cs | 48 +++- src/EchoHub.Client/Commands/CommandHandler.cs | 15 +- src/EchoHub.Client/Services/ApiClient.cs | 4 +- .../Services/ConnectionManager.cs | 16 +- .../Services/EchoHubConnection.cs | 22 +- src/EchoHub.Client/Themes/ThemeManager.cs | 34 +++ .../UI/Dialogs/ChannelPasswordDialog.cs | 72 +++++ .../UI/Dialogs/CreateChannelDialog.cs | 26 +- .../UI/ListSources/ChannelListSource.cs | 10 +- src/EchoHub.Client/UI/MainWindow.cs | 181 +++++++++++- .../Constants/ValidationConstants.cs | 1 + src/EchoHub.Core/Contracts/IChannelService.cs | 7 +- src/EchoHub.Core/Contracts/IChatService.cs | 2 +- src/EchoHub.Core/DTOs/ChatDtos.cs | 7 +- src/EchoHub.Core/Models/Channel.cs | 1 + src/EchoHub.Server.Irc/IrcCommandHandler.cs | 144 ++++++++-- src/EchoHub.Server.Irc/IrcNumericReply.cs | 4 + .../Controllers/ChannelsController.cs | 2 +- src/EchoHub.Server/Data/EchoHubDbContext.cs | 1 + ...5232856_AddChannelPasswordHash.Designer.cs | 271 ++++++++++++++++++ .../20260715232856_AddChannelPasswordHash.cs | 29 ++ .../EchoHubDbContextModelSnapshot.cs | 4 + src/EchoHub.Server/Hubs/ChatHub.cs | 8 +- src/EchoHub.Server/Services/ChannelService.cs | 96 ++++++- src/EchoHub.Server/Services/ChatService.cs | 12 +- src/EchoHub.Tests/CommandHandlerTests.cs | 17 +- .../Irc/IrcCommandHandlerTests.cs | 87 +++++- src/EchoHub.Tests/Irc/TestHelpers.cs | 20 +- 31 files changed, 1056 insertions(+), 96 deletions(-) create mode 100644 src/EchoHub.Client/UI/Dialogs/ChannelPasswordDialog.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs diff --git a/README.md b/README.md index 49868ad..fe31d4f 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,11 @@ graph TD ### Client - **Runs in your terminal** — no browser, no Electron, no 500MB of bundled Chromium -- **13 built-in themes** — including `hacker` for when you want to feel like you're in a movie +- **14 built-in themes** — including `hacker` for when you want to feel like you're in a movie - **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc. - **Colored nicknames** — pick your hex color, express yourself - **Clickable everything** — usernames, @mentions, #channels — just press Enter -- **File/image sharing** — local files or URLs +- **File/image sharing** — local files or URLs; drag & drop a file onto the terminal to send it - **Multi-server** — save and switch between servers - **Auto-reconnect** — drops happen, it rejoins your channels automatically - **Auto-updater** — updates in-place with automatic rollback if something goes wrong @@ -224,7 +224,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | Command | Description | | ------- | ----------- | -| `/join ` | Join a channel | +| `/join [password]` | Join a channel (password for protected channels) | | `/leave` | Leave current channel | | `/topic ` | Set channel topic (creator only) | | `/send ` | Upload a file or image | @@ -247,6 +247,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | ----- | ---- | | `default` | Gray on black — clean and quiet | | `transparent` | White on black — for fancy transparent terminals | +| `transparentlight` | Black on transparent — dark characters for light transparent terminals | | `classic` | White on blue — IRC nostalgia | | `light` | Black on white — for the brave | | `hacker` | Green on black — *I'm in* | diff --git a/docs/articles/architecture.md b/docs/articles/architecture.md index 7ab7075..5d35009 100644 --- a/docs/articles/architecture.md +++ b/docs/articles/architecture.md @@ -52,7 +52,7 @@ Terminal.Gui v2 TUI application: - **UI**: Main window, dialogs, chat renderer with ANSI color support - **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate) -- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency) +- **Themes**: 14 built-in color themes (including transparent dark/light themes with true terminal transparency) - **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens) ## Communication diff --git a/docs/todo.md b/docs/todo.md index a33c4ca..ee89ff5 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -3,7 +3,7 @@ - [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom - [x] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it) - [x] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that – it refreshes when user re-enters the channel again -- [ ] password protected rooms +- [x] password protected rooms - [ ] better audio lib, current one (NetCoreAudio) does not support seek or other audio actions - [ ] Use options pattern for both client & server - ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0 diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 2e8e4c1..d1d63e8 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -216,13 +216,15 @@ public sealed class AppOrchestrator : IDisposable return Task.CompletedTask; } - private async Task HandleCmdJoinChannel(string channelName) + private async Task HandleCmdJoinChannel(string channelName, string? password) { if (!_conn.IsConnected) return; try { - var history = await _conn.JoinChannelAsync(channelName); + var history = await JoinChannelWithPasswordPromptAsync(channelName, password); + if (history is null) return; // user cancelled the password prompt + InvokeUI(() => { _mainWindow.EnsureChannelInList(channelName); @@ -237,6 +239,31 @@ public sealed class AppOrchestrator : IDisposable } } + /// + /// Joins a channel, prompting for a password when the server requires one and + /// re-prompting on a wrong password. Returns the channel history, or null if + /// the user cancelled the prompt. + /// + private async Task?> JoinChannelWithPasswordPromptAsync(string channelName, string? password) + { + while (true) + { + try + { + return await _conn.JoinChannelAsync(channelName, password); + } + catch (ChannelPasswordRequiredException ex) + { + var prompt = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var message = password is not null ? ex.Message : null; + InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, message))); + + password = await prompt.Task; + if (password is null) return null; + } + } + } + private async Task HandleCmdLeaveChannel() { if (!_conn.IsConnected) return; @@ -554,7 +581,7 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => { if (channel.IsPublic) - _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic); + _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected); _mainWindow.SetChannelTopic(channel.Name, channel.Topic); }); }; @@ -707,7 +734,16 @@ public sealed class AppOrchestrator : IDisposable RunAsync(async () => { if (_conn.TrackChannel(channelName)) - await _conn.JoinChannelAsync(channelName); + { + var joined = await JoinChannelWithPasswordPromptAsync(channelName, null); + if (joined is null) + { + // User cancelled the password prompt — back to the default channel + _conn.UntrackChannel(channelName); + InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel)); + return; + } + } try { @@ -983,14 +1019,14 @@ public sealed class AppOrchestrator : IDisposable RunAsync(async () => { - var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic); + var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic, result.Password); if (channel is null) return; var history = await _conn.JoinChannelAsync(channel.Name); InvokeUI(() => { - _mainWindow.EnsureChannelInList(channel.Name); + _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected); _mainWindow.SetChannelTopic(channel.Name, channel.Topic); _mainWindow.SwitchToChannel(channel.Name); if (history.Count > 0) diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 71afd56..4e26c84 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -13,7 +13,7 @@ public class CommandHandler public event Func? OnSendFile; public event Func? OnOpenProfile; public event Func? OnOpenServers; - public event Func? OnJoinChannel; + public event Func? OnJoinChannel; public event Func? OnLeaveChannel; public event Func? OnSetTopic; public event Func? OnListUsers; @@ -126,7 +126,7 @@ public class CommandHandler private async Task HandleTheme(string args) { if (string.IsNullOrWhiteSpace(args)) - return new CommandResult(true, "Usage: /theme (Default, Dark, Light, Hacker, Solarized)", IsError: true); + return new CommandResult(true, "Usage: /theme — pick one from the User menu's theme list (e.g. Default, Transparent, TransparentLight, Hacker)", IsError: true); if (OnSetTheme is not null) await OnSetTheme(args.Trim()); @@ -193,11 +193,14 @@ public class CommandHandler private async Task HandleJoin(string args) { if (string.IsNullOrWhiteSpace(args)) - return new CommandResult(true, "Usage: /join ", IsError: true); + return new CommandResult(true, "Usage: /join [password]", IsError: true); + + var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries); + var channel = parts[0].TrimStart('#'); + var password = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : null; - var channel = args.Trim().TrimStart('#'); if (OnJoinChannel is not null) - await OnJoinChannel(channel); + await OnJoinChannel(channel, password); return new CommandResult(true); } @@ -343,7 +346,7 @@ public class CommandHandler /avatar - Set your avatar /profile [username] - View a profile /servers - Open saved servers - /join - Join a channel + /join [password] - Join a channel (password if protected) /leave - Leave current channel /topic - Set channel topic /users - List online users diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 2d3d351..3c689a8 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -228,10 +228,10 @@ public sealed class ApiClient : IDisposable return tempPath; } - public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true) + public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true, string? password = null) { EnsureAuthenticated(); - var request = new CreateChannelRequest(name, topic, isPublic); + var request = new CreateChannelRequest(name, topic, isPublic, password); using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync("/api/channels", request)); await EnsureSuccessAsync(response); diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index 381be64..6b30aa2 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -169,11 +169,21 @@ internal sealed class ConnectionManager : IAsyncDisposable // ── Channel Operations ──────────────────────────────────────────────── - public async Task> JoinChannelAsync(string channelName) + public async Task> JoinChannelAsync(string channelName, string? password = null) { if (_connection is null) throw new InvalidOperationException("Not connected"); - _joinedChannels.Add(channelName); - return await _connection.JoinChannelAsync(channelName); + try + { + var history = await _connection.JoinChannelAsync(channelName, password); + _joinedChannels.Add(channelName); + return history; + } + catch (ChannelPasswordRequiredException) + { + // Not actually joined — don't track, or reconnects would retry a doomed join + _joinedChannels.Remove(channelName); + throw; + } } public async Task LeaveChannelAsync(string channelName) diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 5ab24e8..995dfbb 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -5,6 +5,20 @@ using Microsoft.AspNetCore.SignalR.Client; namespace EchoHub.Client.Services; +/// +/// Thrown when joining a channel fails because a password is required or incorrect. +/// The UI catches this to prompt the user and retry. +/// +public sealed class ChannelPasswordRequiredException : Exception +{ + public string ChannelName { get; } + + public ChannelPasswordRequiredException(string channelName, string message) : base(message) + { + ChannelName = channelName; + } +} + public sealed class EchoHubConnection : IAsyncDisposable { private readonly HubConnection _connection; @@ -134,11 +148,15 @@ public sealed class EchoHubConnection : IAsyncDisposable OnConnectionStateChanged?.Invoke("Disconnected"); } - public async Task> JoinChannelAsync(string channelName) + public async Task> JoinChannelAsync(string channelName, string? password = null) { - var result = await _connection.InvokeAsync("JoinChannel", channelName); + var result = await _connection.InvokeAsync("JoinChannel", channelName, password); if (!result.Success) + { + if (result.PasswordRequired) + throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected."); throw new InvalidOperationException(result.Error ?? "Failed to join channel."); + } return DecryptMessages(result.History); } diff --git a/src/EchoHub.Client/Themes/ThemeManager.cs b/src/EchoHub.Client/Themes/ThemeManager.cs index 763057b..ebc7003 100644 --- a/src/EchoHub.Client/Themes/ThemeManager.cs +++ b/src/EchoHub.Client/Themes/ThemeManager.cs @@ -445,10 +445,44 @@ public static class ThemeManager } }; + private static readonly Theme TransparentLightTheme = new() + { + Name = "TransparentLight", + Base = new ThemeColors + { + Foreground = "Black", + Background = "None", + FocusForeground = "Blue", + FocusBackground = "None" + }, + Menu = new ThemeColors + { + Foreground = "Black", + Background = "None", + FocusForeground = "Blue", + FocusBackground = "None" + }, + Dialog = new ThemeColors + { + Foreground = "Black", + Background = "Gray", + FocusForeground = "Blue", + FocusBackground = "White" + }, + Status = new ThemeColors + { + Foreground = "DarkGray", + Background = "None", + FocusForeground = "DarkGray", + FocusBackground = "None" + } + }; + private static readonly List BuiltInThemes = [ DefaultTheme, TransparentTheme, + TransparentLightTheme, ClassicTheme, LightTheme, HackerTheme, diff --git a/src/EchoHub.Client/UI/Dialogs/ChannelPasswordDialog.cs b/src/EchoHub.Client/UI/Dialogs/ChannelPasswordDialog.cs new file mode 100644 index 0000000..cf497e0 --- /dev/null +++ b/src/EchoHub.Client/UI/Dialogs/ChannelPasswordDialog.cs @@ -0,0 +1,72 @@ +using Terminal.Gui.App; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace EchoHub.Client.UI.Dialogs; + +/// +/// Prompts for a channel password when joining a protected channel. +/// Returns the entered password, or null if the user cancels. +/// +public sealed class ChannelPasswordDialog +{ + public static string? Show(IApplication app, string channelName, string? message = null) + { + string? result = null; + + var dialog = new Dialog { Title = $"Join #{channelName}", Width = 50, Height = 10, CommandsToBubbleUp = [] }; + + var infoLabel = new Label + { + Text = message ?? $"#{channelName} is password protected.", + X = 1, + Y = 1 + }; + + var passwordLabel = new Label { Text = "Password:", X = 1, Y = 3 }; + var passwordField = new TextField { X = 11, Y = 3, Width = Dim.Fill(2), Secret = true }; + + var joinButton = new Button + { + Text = "Join", + IsDefault = true, + X = Pos.Center() - 9, + Y = 5 + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center() + 2, + Y = 5 + }; + + joinButton.Accepting += (s, e) => + { + var password = passwordField.Text; + if (string.IsNullOrEmpty(password)) + { + MessageBox.ErrorQuery(app, "Error", "Password is required.", "OK"); + return; + } + + result = password; + e.Handled = true; + app.RequestStop(); + }; + + cancelButton.Accepting += (s, e) => + { + result = null; + e.Handled = true; + app.RequestStop(); + }; + + dialog.Add(infoLabel, passwordLabel, passwordField, joinButton, cancelButton); + + passwordField.SetFocus(); + app.Run(dialog); + + return result; + } +} diff --git a/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs b/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs index 076b2ad..ae8691c 100644 --- a/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs +++ b/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs @@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase; namespace EchoHub.Client.UI.Dialogs; -public record CreateChannelResult(string Name, string? Topic, bool IsPublic); +public record CreateChannelResult(string Name, string? Topic, bool IsPublic, string? Password); public sealed class CreateChannelDialog { @@ -12,7 +12,7 @@ public sealed class CreateChannelDialog { CreateChannelResult? result = null; - var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14, CommandsToBubbleUp = [] }; + var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 16, CommandsToBubbleUp = [] }; var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 }; var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) }; @@ -20,19 +20,22 @@ public sealed class CreateChannelDialog var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 }; var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) }; + var passwordLabel = new Label { Text = "Password:", X = 1, Y = 5 }; + var passwordField = new TextField { X = 11, Y = 5, Width = Dim.Fill(2), Secret = true }; + var publicCheckbox = new CheckBox { Text = "Public (visible to all users)", X = 1, - Y = 5, + Y = 7, Value = CheckState.Checked }; var hintLabel = new Label { - Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)", + Text = "Name: a-z, 0-9, -, _ (2-100 chars). Empty password = open channel.", X = 1, - Y = 7, + Y = 9, }; var createButton = new Button @@ -40,14 +43,14 @@ public sealed class CreateChannelDialog Text = "Create", IsDefault = true, X = Pos.Center() - 10, - Y = 9 + Y = 11 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 5, - Y = 9 + Y = 11 }; createButton.Accepting += (s, e) => @@ -63,8 +66,12 @@ public sealed class CreateChannelDialog if (string.IsNullOrWhiteSpace(topic)) topic = null; + var password = passwordField.Text; + if (string.IsNullOrWhiteSpace(password)) + password = null; + var isPublic = publicCheckbox.Value == CheckState.Checked; - result = new CreateChannelResult(name, topic, isPublic); + result = new CreateChannelResult(name, topic, isPublic, password); e.Handled = true; app.RequestStop(); }; @@ -76,7 +83,8 @@ public sealed class CreateChannelDialog app.RequestStop(); }; - dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton); + dialog.Add(nameLabel, nameField, topicLabel, topicField, passwordLabel, passwordField, + publicCheckbox, hintLabel, createButton, cancelButton); nameField.SetFocus(); app.Run(dialog); diff --git a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs index a54a99c..0b53d8c 100644 --- a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs +++ b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs @@ -16,6 +16,7 @@ public class ChannelListSource : IListDataSource { private readonly List _channelNames = []; private readonly Dictionary _unreadCounts = []; + private readonly HashSet _protectedChannels = []; private string _activeChannel = string.Empty; public event NotifyCollectionChangedEventHandler? CollectionChanged; @@ -28,13 +29,17 @@ public class ChannelListSource : IListDataSource private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None); private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None); - public void Update(List channels, Dictionary unread, string activeChannel) + public void Update(List channels, Dictionary unread, string activeChannel, + IReadOnlySet? protectedChannels = null) { _channelNames.Clear(); _channelNames.AddRange(channels); _unreadCounts.Clear(); foreach (var kv in unread) _unreadCounts[kv.Key] = kv.Value; + _protectedChannels.Clear(); + if (protectedChannels is not null) + _protectedChannels.UnionWith(protectedChannels); _activeChannel = activeChannel; MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0; if (!SuspendCollectionChangedEvent) @@ -57,7 +62,8 @@ public class ChannelListSource : IListDataSource var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); var prefix = isActive ? "> " : " "; - var channelText = $"#{name}"; + // Trailing * marks password-protected (+k) channels + var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}"; var badge = hasUnread ? $" ({unread})" : ""; // Resolve Transparent backgrounds to the view's actual background diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 919a4a0..1404ce9 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -7,6 +7,7 @@ using EchoHub.Client.UI.Helpers; using EchoHub.Client.UI.ListSources; using EchoHub.Core.DTOs; using EchoHub.Core.Models; +using Serilog; using Terminal.Gui.App; using Terminal.Gui.Configuration; using Terminal.Gui.Drawing; @@ -50,6 +51,10 @@ public sealed partial class MainWindow : Runnable private static readonly Key AltQKey = Key.Q.WithAlt; private static readonly Key TabKey = Key.Tab; private static readonly Key CtrlKKey = Key.K.WithCtrl; + private static readonly Key CtrlVKey = Key.V.WithCtrl; + private static readonly Key CtrlXKey = Key.X.WithCtrl; + private static readonly Key CtrlCKey = Key.C.WithCtrl; + private static readonly Key CtrlYKey = Key.Y.WithCtrl; // Available slash commands for Tab autocomplete private static readonly string[] SlashCommands = @@ -63,6 +68,7 @@ public sealed partial class MainWindow : Runnable private readonly List _channelNames = []; private readonly Dictionary _channelTopics = []; private readonly Dictionary _channelPublic = []; + private readonly HashSet _channelProtected = []; private readonly ChannelListSource _channelListSource; private readonly ChatMessageManager _messageManager; private string _connectionStatus = "Disconnected"; @@ -253,6 +259,10 @@ public sealed partial class MainWindow : Runnable Height = Dim.Fill(), WordWrap = true }; + // Terminal.Gui binds Ctrl+W to Command.Cut, whose OS clipboard write can throw + // Win32Exception when another process holds the clipboard, crashing the app. + // Rebind it to delete-word-backward (readline behavior), which never touches the clipboard. + _inputField.KeyBindings.ReplaceCommands(Key.W.WithCtrl, Command.KillWordLeft); _inputField.KeyDown += OnInputKeyDown; _inputField.ContentsChanged += OnInputContentsChanged; _inputFrame.Add(_inputField); @@ -540,19 +550,81 @@ public sealed partial class MainWindow : Runnable ShowSearchDialog(); e.Handled = true; } + else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode) + { + // Explicit paste support: terminals that don't intercept Ctrl+V themselves + // otherwise leave users with only the right-click context menu. + GuardedClipboardAction(() => _inputField.Paste(), "paste"); + e.Handled = true; + } + else if (e.KeyCode == CtrlXKey.KeyCode) + { + GuardedClipboardAction(() => _inputField.Cut(), "cut"); + e.Handled = true; + } + else if (e.KeyCode == CtrlCKey.KeyCode) + { + GuardedClipboardAction(() => _inputField.Copy(), "copy"); + e.Handled = true; + } + } + + /// + /// Runs a clipboard-backed edit action, swallowing transient OS clipboard failures + /// (e.g. another process holding the Windows clipboard) that would otherwise + /// propagate out of the input loop and crash the app. + /// + private static void GuardedClipboardAction(Action action, string operation) + { + try + { + action(); + } + catch (Exception ex) + { + Log.Warning(ex, "Clipboard {Operation} failed", operation); + } } private bool _suppressEmojiReplace; + private int _lastInputLength; private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e) { + var text = _inputField.Text; + var previousLength = _lastInputLength; + _lastInputLength = text?.Length ?? 0; + if (_suppressEmojiReplace) return; - var text = _inputField.Text; if (string.IsNullOrEmpty(text)) return; + // A file dropped onto the terminal arrives as a pasted absolute path. + // Detect multi-char bursts that resolve to existing files and route them + // through /send instead of leaving a raw path in the input. + if (text.Length - previousLength > 3 && TryGetDroppedFiles(text, out var droppedFiles)) + { + var channel = _messageManager.CurrentChannel; + if (!string.IsNullOrEmpty(channel)) + { + _suppressEmojiReplace = true; + try + { + _inputField.Text = string.Empty; + } + finally + { + _suppressEmojiReplace = false; + } + + foreach (var file in droppedFiles) + OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\""); + return; + } + } + var replaced = EmojiHelper.ReplaceEmoji(text); if (replaced == text) return; @@ -562,9 +634,15 @@ public sealed partial class MainWindow : Runnable var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta); _suppressEmojiReplace = true; - _inputField.Text = replaced; - _inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow); - _suppressEmojiReplace = false; + try + { + _inputField.Text = replaced; + _inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow); + } + finally + { + _suppressEmojiReplace = false; + } } /// @@ -604,6 +682,80 @@ public sealed partial class MainWindow : Runnable _inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0); } + /// + /// Interprets pasted text as one or more dropped files. Terminals deliver a file drop + /// as the absolute path (quoted when it contains spaces; multiple files space-separated). + /// Returns true only when the entire input resolves to existing files. + /// + private static bool TryGetDroppedFiles(string text, out List files) + { + files = []; + + var trimmed = text.Trim(); + if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n')) + return false; + + // Single unquoted path, possibly with spaces (e.g. WSL or plain conhost drops) + var unquoted = StripQuotes(trimmed); + if (Path.IsPathFullyQualified(unquoted) && File.Exists(unquoted)) + { + files.Add(unquoted); + return true; + } + + // Multiple files: space-separated tokens, each optionally quoted + foreach (var token in TokenizeQuoted(trimmed)) + { + if (!Path.IsPathFullyQualified(token) || !File.Exists(token)) + { + files.Clear(); + return false; + } + files.Add(token); + } + + return files.Count > 0; + } + + private static string StripQuotes(string s) => + s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\'')) + ? s[1..^1] + : s; + + private static IEnumerable TokenizeQuoted(string input) + { + var current = new System.Text.StringBuilder(); + var quote = '\0'; + + foreach (var c in input) + { + if (quote != '\0') + { + if (c == quote) quote = '\0'; + else current.Append(c); + } + else if (c is '"' or '\'') + { + quote = c; + } + else if (c == ' ') + { + if (current.Length > 0) + { + yield return current.ToString(); + current.Clear(); + } + } + else + { + current.Append(c); + } + } + + if (current.Length > 0) + yield return current.ToString(); + } + private void OnChatViewportChanged() { var newWidth = _messageList.Viewport.Width; @@ -675,11 +827,14 @@ public sealed partial class MainWindow : Runnable _channelNames.Clear(); _channelTopics.Clear(); _channelPublic.Clear(); + _channelProtected.Clear(); foreach (var ch in channels) { _channelNames.Add(ch.Name); _channelTopics[ch.Name] = ch.Topic; _channelPublic[ch.Name] = ch.IsPublic; + if (ch.IsProtected) + _channelProtected.Add(ch.Name); } RefreshChannelList(); } @@ -687,13 +842,23 @@ public sealed partial class MainWindow : Runnable /// /// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// - public void EnsureChannelInList(string channelName, bool? isPublic = null) + public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null) { if (isPublic.HasValue) _channelPublic[channelName] = isPublic.Value; + if (isProtected.HasValue) + { + if (isProtected.Value) _channelProtected.Add(channelName); + else _channelProtected.Remove(channelName); + } + if (_channelNames.Contains(channelName)) + { + if (isProtected.HasValue) + RefreshChannelList(); return; + } _channelNames.Add(channelName); RefreshChannelList(); @@ -707,6 +872,7 @@ public sealed partial class MainWindow : Runnable _channelNames.Remove(channelName); _channelTopics.Remove(channelName); _channelPublic.Remove(channelName); + _channelProtected.Remove(channelName); RefreshChannelList(); } @@ -792,6 +958,8 @@ public sealed partial class MainWindow : Runnable { _channelPublic.TryGetValue(currentChannel, out var isPublic); var typeSuffix = isPublic ? "public" : "private"; + if (_channelProtected.Contains(currentChannel)) + typeSuffix += " +k"; Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr); } @@ -855,6 +1023,7 @@ public sealed partial class MainWindow : Runnable _messageManager.ClearAll(); _channelTopics.Clear(); _channelPublic.Clear(); + _channelProtected.Clear(); _channelListSource.Update([], [], string.Empty); _channelList.Source = _channelListSource; _chatFrame.Title = "Chat"; @@ -915,7 +1084,7 @@ public sealed partial class MainWindow : Runnable /// private void RefreshChannelList() { - _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel); + _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected); _channelList.Source = _channelListSource; // Restore selection to current channel diff --git a/src/EchoHub.Core/Constants/ValidationConstants.cs b/src/EchoHub.Core/Constants/ValidationConstants.cs index fd8659b..7822b81 100644 --- a/src/EchoHub.Core/Constants/ValidationConstants.cs +++ b/src/EchoHub.Core/Constants/ValidationConstants.cs @@ -9,6 +9,7 @@ public static partial class ValidationConstants public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$"; public const int MaxPasswordLength = 128; + public const int MinChannelPasswordLength = 3; public const int MaxDisplayNameLength = 100; public const int MaxBioLength = 500; public const int MaxStatusMessageLength = 100; diff --git a/src/EchoHub.Core/Contracts/IChannelService.cs b/src/EchoHub.Core/Contracts/IChannelService.cs index e445160..bf6cb74 100644 --- a/src/EchoHub.Core/Contracts/IChannelService.cs +++ b/src/EchoHub.Core/Contracts/IChannelService.cs @@ -6,8 +6,9 @@ public interface IChannelService { // Channel CRUD Task> GetChannelsAsync(Guid userId, int offset, int limit); - Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic); + Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null); Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic); + Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password); Task DeleteChannelAsync(Guid callerUserId, string channelName); // Channel queries @@ -16,7 +17,7 @@ public interface IChannelService Task GetChannelByNameAsync(string channelName); // Membership - Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName); + Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null); } -public record ChannelListItem(string Name, string? Topic, int OnlineCount); +public record ChannelListItem(string Name, string? Topic, int OnlineCount, bool IsPublic = true, bool IsProtected = false); diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index 4b10f28..230b7b4 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -10,7 +10,7 @@ public interface IChatService Task UserDisconnectedAsync(string connectionId); // Channel operations - Task<(List History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName); + Task<(List History, string? Error, bool PasswordRequired)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName, string? password = null); Task LeaveChannelAsync(string connectionId, string username, string channelName); // Messaging diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 3a7e624..694f1a4 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -21,7 +21,8 @@ public record ChannelDto( string? Topic, bool IsPublic, int MessageCount, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + bool IsProtected = false); public record UserDto( Guid Id, @@ -33,13 +34,13 @@ public record UserDto( public record SendMessageRequest(string ChannelName, string Content); -public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true); +public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true, string? Password = null); public record UpdateTopicRequest(string? Topic); public record SendUrlRequest(string Url); -public record JoinChannelResult(bool Success, List History, string? Error = null); +public record JoinChannelResult(bool Success, List History, string? Error = null, bool PasswordRequired = false); public record EmbedDto( string? SiteName, diff --git a/src/EchoHub.Core/Models/Channel.cs b/src/EchoHub.Core/Models/Channel.cs index b936e6e..b305705 100644 --- a/src/EchoHub.Core/Models/Channel.cs +++ b/src/EchoHub.Core/Models/Channel.cs @@ -6,6 +6,7 @@ public class Channel public required string Name { get; set; } public string? Topic { get; set; } public bool IsPublic { get; set; } = true; + public string? PasswordHash { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public Guid CreatedByUserId { get; set; } diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 4346181..3cdc77a 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -329,7 +329,7 @@ public sealed class IrcCommandHandler await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO, $"{ServerName} EchoHub-IRC o o"); await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT, - "CHANTYPES=# NICKLEN=50 CHANNELLEN=100 :are supported by this server"); + "CHANTYPES=# CHANMODES=b,k,, NICKLEN=50 CHANNELLEN=100 :are supported by this server"); await SendMotdAsync(); } @@ -371,8 +371,17 @@ public sealed class IrcCommandHandler var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries); - foreach (var rawChannel in channels) + // RFC 1459: optional second parameter carries comma-separated channel keys, + // paired with channels by position (JOIN #a,#b key1,key2). + var keys = msg.Parameters.Count > 1 + ? msg.Parameters[1].Split(',') + : []; + + for (var i = 0; i < channels.Length; i++) { + var rawChannel = channels[i]; + var key = i < keys.Length && !string.IsNullOrEmpty(keys[i]) ? keys[i] : null; + var channelName = IrcToEchoHubChannel(rawChannel); if (channelName is null) { @@ -381,13 +390,21 @@ public sealed class IrcCommandHandler continue; } - var (history, error) = await _chatService.JoinChannelAsync( - _conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName); + var (history, error, passwordRequired) = await _chatService.JoinChannelAsync( + _conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key); if (error is not null) { - await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, - $"#{channelName} :{error}"); + if (passwordRequired) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_BADCHANNELKEY, + $"#{channelName} :Cannot join channel (+k) — {error}"); + } + else + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, + $"#{channelName} :{error}"); + } continue; } @@ -512,8 +529,22 @@ public sealed class IrcCommandHandler } else { - await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CHANOPRIVSNEEDED, - $"#{channelName} :Topic can only be changed by the channel creator via the API"); + var topic = msg.Parameters[1]; + var result = await _channelService.UpdateTopicAsync( + _conn.UserId!.Value, channelName, string.IsNullOrWhiteSpace(topic) ? null : topic); + + if (!result.IsSuccess) + { + var numeric = result.Error == ChannelError.NotFound + ? IrcNumericReply.ERR_NOSUCHCHANNEL + : IrcNumericReply.ERR_CHANOPRIVSNEEDED; + await _conn.SendNumericAsync(ServerName, numeric, $"#{channelName} :{result.ErrorMessage}"); + return; + } + + // Notify SignalR clients and echo the change back to the IRC client + await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channelName); + await _conn.SendAsync($":{_conn.Hostmask} TOPIC #{channelName} :{result.Channel!.Topic ?? ""}"); } } @@ -627,10 +658,12 @@ public sealed class IrcCommandHandler var channels = await _channelService.GetChannelListAsync(); - foreach (var ch in channels) + // Private channels are hidden from discovery, matching the SignalR client's channel list + foreach (var ch in channels.Where(c => c.IsPublic)) { + var lockHint = ch.IsProtected ? "[+k] " : ""; await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LIST, - $"#{ch.Name} {ch.OnlineCount} :{ch.Topic ?? ""}"); + $"#{ch.Name} {ch.OnlineCount} :{lockHint}{ch.Topic ?? ""}"); } await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LISTEND, @@ -644,15 +677,94 @@ public sealed class IrcCommandHandler var target = msg.Parameters[0]; - if (target.StartsWith('#')) - { - await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS, - $"{target} +"); - } - else + if (!target.StartsWith('#')) { await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UMODEIS, "+"); + return; } + + var channelName = IrcToEchoHubChannel(target); + if (channelName is null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, + $"{target} :No such channel"); + return; + } + + // Query: MODE #channel + if (msg.Parameters.Count == 1) + { + var channel = await _channelService.GetChannelByNameAsync(channelName); + if (channel is null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, + $"#{channelName} :No such channel"); + return; + } + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS, + $"#{channelName} {(channel.IsProtected ? "+k" : "+")}"); + return; + } + + var modes = msg.Parameters[1]; + + // Clients commonly probe the ban list on join — reply with an empty list + if (modes is "b" or "+b") + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFBANLIST, + $"#{channelName} :End of channel ban list"); + return; + } + + switch (modes) + { + case "+k": + if (msg.Parameters.Count < 3) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS, + "MODE :Not enough parameters"); + return; + } + + var key = msg.Parameters[2]; + var setResult = await _channelService.SetChannelPasswordAsync(_conn.UserId!.Value, channelName, key); + if (!setResult.IsSuccess) + { + await SendModeErrorAsync(channelName, setResult); + return; + } + + await _conn.SendAsync($":{_conn.Hostmask} MODE #{channelName} +k {key}"); + return; + + case "-k": + var clearResult = await _channelService.SetChannelPasswordAsync(_conn.UserId!.Value, channelName, null); + if (!clearResult.IsSuccess) + { + await SendModeErrorAsync(channelName, clearResult); + return; + } + + await _conn.SendAsync($":{_conn.Hostmask} MODE #{channelName} -k *"); + return; + + default: + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNMODE, + $"{modes} :is unknown mode char to me for #{channelName}"); + return; + } + } + + private async Task SendModeErrorAsync(string channelName, ChannelOperationResult result) + { + var numeric = result.Error switch + { + ChannelError.NotFound => IrcNumericReply.ERR_NOSUCHCHANNEL, + ChannelError.Forbidden => IrcNumericReply.ERR_CHANOPRIVSNEEDED, + _ => IrcNumericReply.ERR_KEYSET, + }; + await _conn.SendNumericAsync(ServerName, numeric, $"#{channelName} :{result.ErrorMessage}"); } private async Task HandlePingAsync(IrcMessage msg) diff --git a/src/EchoHub.Server.Irc/IrcNumericReply.cs b/src/EchoHub.Server.Irc/IrcNumericReply.cs index 60fdb21..1359743 100644 --- a/src/EchoHub.Server.Irc/IrcNumericReply.cs +++ b/src/EchoHub.Server.Irc/IrcNumericReply.cs @@ -42,6 +42,7 @@ public static class IrcNumericReply // MODE public const string RPL_CHANNELMODEIS = "324"; public const string RPL_UMODEIS = "221"; + public const string RPL_ENDOFBANLIST = "368"; // Errors public const string ERR_NOSUCHNICK = "401"; @@ -56,6 +57,9 @@ public static class IrcNumericReply public const string ERR_NEEDMOREPARAMS = "461"; public const string ERR_ALREADYREGISTERED = "462"; public const string ERR_PASSWDMISMATCH = "464"; + public const string ERR_KEYSET = "467"; + public const string ERR_UNKNOWNMODE = "472"; + public const string ERR_BADCHANNELKEY = "475"; public const string ERR_CHANOPRIVSNEEDED = "482"; // SASL diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 610b26c..978d65a 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -65,7 +65,7 @@ public class ChannelsController : ControllerBase return Unauthorized(new ErrorResponse("Authentication required.")); var result = await _channelService.CreateChannelAsync( - Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic); + Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password); if (!result.IsSuccess) return MapChannelError(result); diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index 303cda5..efd244a 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -44,6 +44,7 @@ public class EchoHubDbContext : DbContext entity.HasIndex(c => c.Name).IsUnique(); entity.Property(c => c.Name).IsRequired().HasMaxLength(100); entity.Property(c => c.Topic).HasMaxLength(500); + entity.Property(c => c.PasswordHash).HasMaxLength(100); entity.HasMany(c => c.Messages) .WithOne(m => m.Channel) diff --git a/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.Designer.cs new file mode 100644 index 0000000..11d4669 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.Designer.cs @@ -0,0 +1,271 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260715232856_AddChannelPasswordHash")] + partial class AddChannelPasswordHash + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.HasOne("EchoHub.Core.Models.Channel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EchoHub.Core.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs new file mode 100644 index 0000000..c01f2c0 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddChannelPasswordHash : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordHash", + table: "Channels", + type: "TEXT", + maxLength: 100, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "Channels"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index bf38795..c9fcf62 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -37,6 +37,10 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("TEXT"); + b.Property("Topic") .HasMaxLength(500) .HasColumnType("TEXT"); diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index 723ac07..466778b 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -56,15 +56,15 @@ public class ChatHub : Hub } } - public async Task JoinChannel(string channelName) + public async Task JoinChannel(string channelName, string? password = null) { try { - var (history, error) = await _chatService.JoinChannelAsync( - Context.ConnectionId, CurrentUserId, CurrentUsername, channelName); + var (history, error, passwordRequired) = await _chatService.JoinChannelAsync( + Context.ConnectionId, CurrentUserId, CurrentUsername, channelName, password); if (error is not null) - return new JoinChannelResult(false, [], error); + return new JoinChannelResult(false, [], error, passwordRequired); await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim()); return new JoinChannelResult(true, history); diff --git a/src/EchoHub.Server/Services/ChannelService.cs b/src/EchoHub.Server/Services/ChannelService.cs index d4ef85e..65aa692 100644 --- a/src/EchoHub.Server/Services/ChannelService.cs +++ b/src/EchoHub.Server/Services/ChannelService.cs @@ -41,14 +41,14 @@ public class ChannelService : IChannelService .Skip(offset) .Take(limit) .Select(c => new ChannelDto( - c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt)) + c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt, c.PasswordHash != null)) .ToListAsync(); return new PaginatedResponse(channels, total, offset, limit); } public async Task CreateChannelAsync( - Guid creatorUserId, string name, string? topic, bool isPublic) + Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) { if (string.IsNullOrWhiteSpace(name)) return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required."); @@ -59,6 +59,10 @@ public class ChannelService : IChannelService return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."); + var passwordError = ValidateChannelPassword(ref password); + if (passwordError is not null) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -72,6 +76,7 @@ public class ChannelService : IChannelService Topic = topic?.Trim(), IsPublic = isPublic, CreatedByUserId = creatorUserId, + PasswordHash = password is not null ? BCrypt.Net.BCrypt.HashPassword(password) : null, }; db.Channels.Add(channel); @@ -85,7 +90,8 @@ public class ChannelService : IChannelService await db.SaveChangesAsync(); - var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt); + var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt, + channel.PasswordHash != null); return ChannelOperationResult.Success(dto); } @@ -112,7 +118,40 @@ public class ChannelService : IChannelService await db.SaveChangesAsync(); var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); - var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt, + dbChannel.PasswordHash != null); + return ChannelOperationResult.Success(dto); + } + + /// + /// Sets, changes, or clears (null) a channel's join password. Creator or admin only. + /// + public async Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password) + { + channelName = channelName.ToLowerInvariant().Trim(); + + var passwordError = ValidateChannelPassword(ref password); + if (passwordError is not null) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); + + var caller = await db.Users.FindAsync(callerUserId); + if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin)) + return ChannelOperationResult.Fail(ChannelError.Forbidden, + "Only the channel creator or an admin can change the channel password."); + + dbChannel.PasswordHash = password is not null ? BCrypt.Net.BCrypt.HashPassword(password) : null; + await db.SaveChangesAsync(); + + var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt, + dbChannel.PasswordHash != null); return ChannelOperationResult.Success(dto); } @@ -139,7 +178,8 @@ public class ChannelService : IChannelService db.Channels.Remove(dbChannel); await db.SaveChangesAsync(); - var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt, + dbChannel.PasswordHash != null); return ChannelOperationResult.Success(dto); } @@ -165,7 +205,8 @@ public class ChannelService : IChannelService return channels.Select(c => new ChannelListItem( c.Name, c.Topic, - _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); + _presenceTracker.GetOnlineUsersInChannel(c.Name).Count, + c.IsPublic, c.PasswordHash != null)).ToList(); } public async Task GetChannelByNameAsync(string channelName) @@ -179,15 +220,16 @@ public class ChannelService : IChannelService if (c is null) return null; var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id); - return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt); + return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt, c.PasswordHash != null); } - public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) + public async Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync( + Guid userId, string channelName, string? password = null) { channelName = channelName.ToLowerInvariant().Trim(); if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) - return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); + return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.", false); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -211,7 +253,7 @@ public class ChannelService : IChannelService } else { - return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list."); + return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.", false); } } @@ -219,6 +261,17 @@ public class ChannelService : IChannelService .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id); if (!hasMembership) { + // Password gate: existing members (incl. the creator) joined before, so only + // first-time joins of a protected channel need the password. + if (channel.PasswordHash is not null) + { + if (string.IsNullOrEmpty(password)) + return (false, $"Channel '{channelName}' is password protected.", true); + + if (!BCrypt.Net.BCrypt.Verify(password, channel.PasswordHash)) + return (false, $"Incorrect password for channel '{channelName}'.", true); + } + db.ChannelMemberships.Add(new ChannelMembership { UserId = userId, @@ -227,7 +280,28 @@ public class ChannelService : IChannelService await db.SaveChangesAsync(); } - return (true, null); + return (true, null, false); + } + + /// + /// Normalizes and validates a channel password. Whitespace-only becomes null (no password). + /// Returns an error message, or null when valid. + /// + private static string? ValidateChannelPassword(ref string? password) + { + if (string.IsNullOrWhiteSpace(password)) + { + password = null; + return null; + } + + if (password.Length < ValidationConstants.MinChannelPasswordLength) + return $"Channel password must be at least {ValidationConstants.MinChannelPasswordLength} characters."; + + if (password.Length > ValidationConstants.MaxPasswordLength) + return $"Channel password must not exceed {ValidationConstants.MaxPasswordLength} characters."; + + return null; } private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db) diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index a7bf032..91e7999 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -93,15 +93,15 @@ public class ChatService : IChatService return username; } - public async Task<(List History, string? Error)> JoinChannelAsync( - string connectionId, Guid userId, string username, string channelName) + public async Task<(List History, string? Error, bool PasswordRequired)> JoinChannelAsync( + string connectionId, Guid userId, string username, string channelName, string? password = null) { channelName = channelName.ToLowerInvariant().Trim(); - // Delegate channel validation + membership to ChannelService - var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName); + // Delegate channel validation + membership (incl. password gate) to ChannelService + var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password); if (!success) - return ([], error); + return ([], error, passwordRequired); var isNewJoin = _presenceTracker.JoinChannel(username, channelName); @@ -136,7 +136,7 @@ public class ChatService : IChatService } var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount); - return (history, null); + return (history, null, false); } public async Task LeaveChannelAsync(string connectionId, string username, string channelName) diff --git a/src/EchoHub.Tests/CommandHandlerTests.cs b/src/EchoHub.Tests/CommandHandlerTests.cs index 5c58bf7..b6f1657 100644 --- a/src/EchoHub.Tests/CommandHandlerTests.cs +++ b/src/EchoHub.Tests/CommandHandlerTests.cs @@ -193,7 +193,7 @@ public class CommandHandlerTests { var handler = CreateHandler(); string? capturedChannel = null; - handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; }; + handler.OnJoinChannel += (ch, _) => { capturedChannel = ch; return Task.CompletedTask; }; await handler.HandleAsync("/join #random"); Assert.Equal("random", capturedChannel); @@ -204,12 +204,25 @@ public class CommandHandlerTests { var handler = CreateHandler(); string? capturedChannel = null; - handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; }; + handler.OnJoinChannel += (ch, _) => { capturedChannel = ch; return Task.CompletedTask; }; await handler.HandleAsync("/join random"); Assert.Equal("random", capturedChannel); } + [Fact] + public async Task HandleAsync_Join_WithPassword_PassesPassword() + { + var handler = CreateHandler(); + string? capturedChannel = null; + string? capturedPassword = null; + handler.OnJoinChannel += (ch, pw) => { capturedChannel = ch; capturedPassword = pw; return Task.CompletedTask; }; + + await handler.HandleAsync("/join #secret hunter2"); + Assert.Equal("secret", capturedChannel); + Assert.Equal("hunter2", capturedPassword); + } + [Fact] public async Task HandleAsync_Join_NoArgs_ReturnsError() { diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs index 35c45f8..d6b641f 100644 --- a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -254,6 +254,39 @@ public class IrcCommandHandlerTests Assert.Equal("general", _chatService.JoinedChannels[0].Channel); } + [Fact] + public async Task Join_WithKey_PassesKeyToChatService() + { + _channelService.TopicResult = (null, true); + + var lines = await RunAuthenticated(["JOIN #secret hunter2"]); + + Assert.Contains(lines, l => l.Contains("JOIN #secret")); + Assert.Single(_chatService.JoinKeys); + Assert.Equal("hunter2", _chatService.JoinKeys[0]); + } + + [Fact] + public async Task Join_MultipleChannelsWithKeys_PairsKeysByPosition() + { + _channelService.TopicResult = (null, true); + + await RunAuthenticated(["JOIN #chan-a,#chan-b key1,key2"]); + + Assert.Equal(["key1", "key2"], _chatService.JoinKeys); + } + + [Fact] + public async Task Join_ProtectedChannelWithoutKey_GetsBadChannelKey() + { + _chatService.JoinError = "Channel 'secret' is password protected."; + _chatService.JoinPasswordRequired = true; + + var lines = await RunAuthenticated(["JOIN #secret"]); + + Assert.Contains(lines, l => l.Contains("475") && l.Contains("#secret") && l.Contains("+k")); + } + [Fact] public async Task Join_SendsTopic() { @@ -450,13 +483,27 @@ public class IrcCommandHandlerTests } [Fact] - public async Task Topic_SetAttempt_GetsPermissionDenied() + public async Task Topic_SetByNonCreator_GetsPermissionDenied() { + _channelService.UpdateTopicResult = ChannelOperationResult.Fail( + ChannelError.Forbidden, "Only the channel creator can update the topic."); + var lines = await RunAuthenticated(["TOPIC #general :New topic"]); Assert.Contains(lines, l => l.Contains("482") && l.Contains("channel creator")); } + [Fact] + public async Task Topic_SetByCreator_UpdatesAndEchoesTopic() + { + _channelService.UpdateTopicResult = ChannelOperationResult.Success( + new ChannelDto(Guid.NewGuid(), "general", "New topic", true, 0, DateTimeOffset.UtcNow)); + + var lines = await RunAuthenticated(["TOPIC #general :New topic"]); + + Assert.Contains(lines, l => l.Contains("TOPIC #general") && l.Contains("New topic")); + } + // ── WHO ────────────────────────────────────────────────────────────── [Fact] @@ -564,9 +611,45 @@ public class IrcCommandHandlerTests [Fact] public async Task Mode_Channel_ReturnsChannelModes() { + _channelService.ChannelByNameToReturn = + new ChannelDto(Guid.NewGuid(), "general", null, true, 0, DateTimeOffset.UtcNow); + var lines = await RunAuthenticated(["MODE #general"]); - Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general")); + Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general") && l.Contains("+")); + } + + [Fact] + public async Task Mode_ProtectedChannel_ReportsKeyMode() + { + _channelService.ChannelByNameToReturn = + new ChannelDto(Guid.NewGuid(), "secret", null, true, 0, DateTimeOffset.UtcNow, IsProtected: true); + + var lines = await RunAuthenticated(["MODE #secret"]); + + Assert.Contains(lines, l => l.Contains("324") && l.Contains("#secret") && l.Contains("+k")); + } + + [Fact] + public async Task Mode_SetKey_ByCreator_EchoesModeChange() + { + _channelService.SetPasswordResult = ChannelOperationResult.Success( + new ChannelDto(Guid.NewGuid(), "secret", null, true, 0, DateTimeOffset.UtcNow, IsProtected: true)); + + var lines = await RunAuthenticated(["MODE #secret +k hunter2"]); + + Assert.Contains(lines, l => l.Contains("MODE #secret +k hunter2")); + } + + [Fact] + public async Task Mode_SetKey_ByNonCreator_GetsPermissionDenied() + { + _channelService.SetPasswordResult = ChannelOperationResult.Fail( + ChannelError.Forbidden, "Only the channel creator or an admin can change the channel password."); + + var lines = await RunAuthenticated(["MODE #secret +k hunter2"]); + + Assert.Contains(lines, l => l.Contains("482")); } [Fact] diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 7c5407e..d726132 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -155,6 +155,7 @@ internal sealed class FakeChatService : IChatService // Configurable results public List HistoryToReturn { get; set; } = []; public string? JoinError { get; set; } + public bool JoinPasswordRequired { get; set; } public string? SendMessageError { get; set; } public List ChannelsForUserToReturn { get; set; } = []; public List OnlineUsersToReturn { get; set; } = []; @@ -171,11 +172,14 @@ internal sealed class FakeChatService : IChatService return Task.FromResult(null); } - public Task<(List History, string? Error)> JoinChannelAsync( - string connectionId, Guid userId, string username, string channelName) + public List JoinKeys { get; } = []; + + public Task<(List History, string? Error, bool PasswordRequired)> JoinChannelAsync( + string connectionId, Guid userId, string username, string channelName, string? password = null) { JoinedChannels.Add((channelName, username)); - return Task.FromResult((HistoryToReturn, JoinError)); + JoinKeys.Add(password); + return Task.FromResult((HistoryToReturn, JoinError, JoinPasswordRequired)); } public Task LeaveChannelAsync(string connectionId, string username, string channelName) @@ -224,17 +228,21 @@ internal sealed class FakeChannelService : IChannelService public ChannelOperationResult? CreateResult { get; set; } public ChannelOperationResult? UpdateTopicResult { get; set; } public ChannelOperationResult? DeleteResult { get; set; } - public (bool Success, string? Error) MembershipResult { get; set; } = (true, null); + public ChannelOperationResult? SetPasswordResult { get; set; } + public (bool Success, string? Error, bool PasswordRequired) MembershipResult { get; set; } = (true, null, false); public Task> GetChannelsAsync(Guid userId, int offset, int limit) => Task.FromResult(new PaginatedResponse([], 0, offset, limit)); - public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) => + public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) => Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); public Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) => Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + public Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password) => + Task.FromResult(SetPasswordResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + public Task DeleteChannelAsync(Guid callerUserId, string channelName) => Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); @@ -247,7 +255,7 @@ internal sealed class FakeChannelService : IChannelService public Task GetChannelByNameAsync(string channelName) => Task.FromResult(ChannelByNameToReturn); - public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) => + public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) => Task.FromResult(MembershipResult); } From ea8e583ee5c9565be65f148d5d79f17e3d30703c Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 03:50:08 +0200 Subject: [PATCH 04/10] feat: add image validation and ASCII conversion services --- .../Services/FileValidationHelper.cs | 0 .../Services/ImageToAsciiService.cs | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/{EchoHub.Server => EchoHub.Core}/Services/FileValidationHelper.cs (100%) rename src/{EchoHub.Server => EchoHub.Core}/Services/ImageToAsciiService.cs (100%) diff --git a/src/EchoHub.Server/Services/FileValidationHelper.cs b/src/EchoHub.Core/Services/FileValidationHelper.cs similarity index 100% rename from src/EchoHub.Server/Services/FileValidationHelper.cs rename to src/EchoHub.Core/Services/FileValidationHelper.cs diff --git a/src/EchoHub.Server/Services/ImageToAsciiService.cs b/src/EchoHub.Core/Services/ImageToAsciiService.cs similarity index 100% rename from src/EchoHub.Server/Services/ImageToAsciiService.cs rename to src/EchoHub.Core/Services/ImageToAsciiService.cs From e05b420ce91abb2f32bb452f4b35ace377b90187 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 03:50:23 +0200 Subject: [PATCH 05/10] feat: Implement end-to-end encryption for channels - Added EncryptionSalt and WrappedRoomKey properties to Channel model. - Introduced RoomCrypto class for client-side encryption and decryption. - Updated ChannelService to handle encrypted channels, including creation and rekeying. - Modified ChannelsController to expose crypto metadata and rekey functionality. - Enhanced IrcCommandHandler to block joining encrypted channels over IRC. - Updated database schema with migration for new encryption fields. - Refactored file validation and image processing services to accommodate encrypted channels. - Added unit tests for RoomCrypto functionality and updated existing tests for channel services. --- README.md | 6 +- docs/changelog/index.md | 1 + docs/changelog/toc.yml | 2 + docs/changelog/v0.2.12.md | 32 ++ docs/todo.md | 2 +- src/EchoHub.Client/AppOrchestrator.cs | 258 +++++++++++++++- src/EchoHub.Client/Commands/CommandHandler.cs | 17 ++ src/EchoHub.Client/Config/ClientConfig.cs | 7 + src/EchoHub.Client/Services/ApiClient.cs | 38 ++- .../Services/ConnectionManager.cs | 12 +- .../Services/EchoHubConnection.cs | 54 +++- src/EchoHub.Client/Services/RoomKeyStore.cs | 109 +++++++ .../UI/Chat/ChatMessageManager.cs | 16 + src/EchoHub.Client/UI/MainWindow.cs | 14 +- src/EchoHub.Core/Contracts/IChannelService.cs | 7 +- src/EchoHub.Core/DTOs/ChatDtos.cs | 35 ++- src/EchoHub.Core/EchoHub.Core.csproj | 6 +- src/EchoHub.Core/Models/Channel.cs | 6 + src/EchoHub.Core/Security/RoomCrypto.cs | 132 +++++++++ .../Services/FileValidationHelper.cs | 2 +- .../Services/ImageToAsciiService.cs | 2 +- src/EchoHub.Server.Irc/IrcCommandHandler.cs | 10 + .../Controllers/ChannelsController.cs | 123 ++++++-- .../Controllers/UsersController.cs | 1 + src/EchoHub.Server/Data/EchoHubDbContext.cs | 2 + ...7_AddChannelEncryptionEnvelope.Designer.cs | 279 ++++++++++++++++++ ...0716012917_AddChannelEncryptionEnvelope.cs | 40 +++ .../EchoHubDbContextModelSnapshot.cs | 8 + src/EchoHub.Server/Hubs/ChatHub.cs | 12 +- src/EchoHub.Server/Program.cs | 1 + src/EchoHub.Server/Services/ChannelService.cs | 103 ++++++- .../FileValidationHelperTests.cs | 2 +- src/EchoHub.Tests/ImageToAsciiServiceTests.cs | 1 + .../Irc/IrcCommandHandlerTests.cs | 11 + src/EchoHub.Tests/Irc/TestHelpers.cs | 17 +- src/EchoHub.Tests/RoomCryptoTests.cs | 99 +++++++ 36 files changed, 1400 insertions(+), 67 deletions(-) create mode 100644 src/EchoHub.Client/Services/RoomKeyStore.cs create mode 100644 src/EchoHub.Core/Security/RoomCrypto.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs create mode 100644 src/EchoHub.Tests/RoomCryptoTests.cs diff --git a/README.md b/README.md index fe31d4f..a56de2c 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ graph TD - **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; drag & drop a file onto the terminal to send it +- **File/image sharing** — local files or URLs; drag & drop a file onto the terminal to send it; save the original behind any ASCII-art image +- **End-to-end encrypted rooms** — password-protected channels are encrypted with a passphrase-derived key that never reaches the server, so not even the server owner can read messages or files (they can still see counts and storage size) - **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 +225,8 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | Command | Description | | ------- | ----------- | -| `/join [password]` | Join a channel (password for protected channels) | +| `/join [password]` | Join a channel (passphrase for encrypted channels) | +| `/passwd ` | Change the current encrypted channel's passphrase (creator only) | | `/leave` | Leave current channel | | `/topic ` | Set channel topic (creator only) | | `/send ` | Upload a file or image | diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 11a614c..e35c226 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,6 +4,7 @@ Release history for EchoHub. ## Releases +- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix - [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata - [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes - [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 85d2776..afef17b 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.12 + href: v0.2.12.md - name: v0.2.11 href: v0.2.11.md - name: v0.2.10 diff --git a/docs/changelog/v0.2.12.md b/docs/changelog/v0.2.12.md index 9432219..ce425e1 100644 --- a/docs/changelog/v0.2.12.md +++ b/docs/changelog/v0.2.12.md @@ -1,5 +1,37 @@ # v0.2.12 +Private channels are now genuinely private: password-protected channels are end-to-end encrypted, so the server (and its operators) can gate joins and measure storage but cannot read message or file contents. The IRC gateway grows real MODE/TOPIC support and channel keys, and the client gets image "save original", a transparent-light theme, drag-and-drop file sending, Ctrl+V paste, and a fix for the intermittent Ctrl+W crash. + ## New Features +- **End-to-end encrypted channels** — creating a channel with a password now provisions a zero-knowledge room: + - The passphrase never leaves the client. It derives (PBKDF2-SHA256, 210k iterations) two keys: an *auth key* sent to the server as the join credential, and a *key-encryption key* that never leaves the machine. + - A random room content key encrypts every message and file with AES-256-GCM. The server only ever stores the room key *wrapped* under the passphrase, so it can gate joins and report a channel's message count, storage size, and attachments — but cannot decrypt any of it. Even the server owner cannot read a private room's contents. + - Members' clients cache the derived room key locally (in the per-server config, like saved sessions) so the passphrase isn't retyped every launch; joining on a new device prompts for it once. + - Change the passphrase with `/passwd ` (channel creator only). The room key is re-wrapped, not rotated, so **existing history stays readable** and members who join later with the new passphrase can still read older messages. + - Files and images are encrypted client-side before upload; for images the ASCII-art preview is rendered on the client and stored room-encrypted too. Sending images by URL is disabled in encrypted channels (the server can't fetch-and-render without the key). + - End-to-end encrypted channels cannot be joined over the IRC gateway (that would require the server to hold the room key) — IRC `JOIN` returns `475` directing users to the EchoHub client. +- Password-protected channels — set an optional password when creating a channel (masked field in the Create Channel dialog, `password` on `POST /api/channels`). Passwords are BCrypt-hashed server-side; the join gate applies on first join only (existing members and the creator are unaffected). Protected channels show a `*` marker in the channel list and `+k` in the status bar +- Save original images — image messages now show a clickable "[↓ save original]" line under the ASCII-art preview that downloads the full-resolution original to your Downloads folder (decrypting locally in encrypted channels) +- `/join [password]` — join protected channels inline, or let the client prompt: joining a protected channel without a password opens a masked prompt that re-prompts on a wrong password +- IRC channel keys — `JOIN #room ` works against room passwords (RFC 1459 comma-paired key lists supported); keyless or wrong-key joins get `475 ERR_BADCHANNELKEY` +- IRC `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k ` sets and `-k` clears the room password (channel creator or admin only), ban-list probes get a clean empty reply, and `CHANMODES` is advertised in ISUPPORT +- IRC `TOPIC` set support — the channel creator can change the topic from IRC; the change broadcasts to connected TUI clients (previously topic changes were rejected with a stub error) +- Drag & drop file sending — dropping a file (image, audio, anything) onto the terminal detects the pasted path and sends it through `/send` automatically, including multiple files at once +- Ctrl+V pastes into the message input (previously paste was only available via the right-click menu); Ctrl+Y works as an alias +- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`) - Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages. + +## Bug Fixes + +- Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing +- Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement +- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]` + +## API Changes + +- `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR) +- New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change) +- The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content +- `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads +- New EF migrations `AddChannelPasswordHash` and `AddChannelEncryptionEnvelope` (applied automatically on server start) diff --git a/docs/todo.md b/docs/todo.md index ee89ff5..4eed91c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -3,7 +3,7 @@ - [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom - [x] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it) - [x] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that – it refreshes when user re-enters the channel again -- [x] password protected rooms +- [x] password protected rooms (end-to-end encrypted — server cannot read contents) - [ ] better audio lib, current one (NetCoreAudio) does not support seek or other audio actions - [ ] Use options pattern for both client & server - ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0 diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index d1d63e8..b742136 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -8,6 +8,8 @@ using EchoHub.Client.UI.Dialogs; using EchoHub.Core.Constants; using EchoHub.Core.DTOs; using EchoHub.Core.Models; +using EchoHub.Core.Security; +using EchoHub.Core.Services; using Serilog; using Terminal.Gui.App; using Terminal.Gui.Views; @@ -88,6 +90,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested; _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; + _mainWindow.OnImageSaveRequested += HandleImageSaveRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested; _mainWindow.OnUserProfileRequested += HandleViewProfile; @@ -109,6 +112,7 @@ public sealed class AppOrchestrator : IDisposable _commandHandler.OnOpenProfile += HandleCmdOpenProfile; _commandHandler.OnOpenServers += HandleCmdOpenServers; _commandHandler.OnJoinChannel += HandleCmdJoinChannel; + _commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword; _commandHandler.OnLeaveChannel += HandleCmdLeaveChannel; _commandHandler.OnSetTopic += HandleCmdSetTopic; _commandHandler.OnListUsers += HandleCmdListUsers; @@ -167,11 +171,24 @@ public sealed class AppOrchestrator : IDisposable try { + var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); + if (Uri.TryCreate(target, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) { + if (hasRoomKey) + { + InvokeUI(() => _mainWindow.ShowError( + "Sending by URL isn't available in encrypted channels — download the file and /send it instead.")); + return; + } + await _conn.Api!.SendUrlAsync(channel, target, size); } + else if (hasRoomKey) + { + await UploadEncryptedFileAsync(channel, target, size, roomKey); + } else { await using var stream = File.OpenRead(target); @@ -186,6 +203,46 @@ public sealed class AppOrchestrator : IDisposable } } + /// + /// Upload into an end-to-end encrypted channel: the blob is encrypted with the room + /// key before it leaves this machine, and for images the ASCII preview is rendered + /// locally and sent room-encrypted — the server never sees image or file contents. + /// + private async Task UploadEncryptedFileAsync(string channel, string path, string? size, byte[] roomKey) + { + var fileName = Path.GetFileName(path); + var bytes = await File.ReadAllBytesAsync(path); + + string declaredType; + string plainContent; + using (var ms = new MemoryStream(bytes)) + { + if (FileValidationHelper.IsValidImage(ms)) + { + declaredType = "image"; + var (w, h) = ImageToAsciiService.GetDimensions(size); + ms.Position = 0; + plainContent = new ImageToAsciiService().ConvertToAscii(ms, w, h); + } + else if (FileValidationHelper.IsAudioFile(fileName)) + { + declaredType = "audio"; + plainContent = fileName; + } + else + { + declaredType = "file"; + plainContent = fileName; + } + } + + var encryptedContent = RoomCrypto.EncryptText(plainContent, roomKey); + var encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey); + + await using var blobStream = new MemoryStream(encryptedBlob); + await _conn.Api!.UploadFileAsync(channel, blobStream, fileName, size, declaredType, encryptedContent); + } + private async Task HandleCmdSetAvatar(string target) { if (!_conn.IsAuthenticated) return; @@ -241,16 +298,51 @@ public sealed class AppOrchestrator : IDisposable /// /// Joins a channel, prompting for a password when the server requires one and - /// re-prompting on a wrong password. Returns the channel history, or null if - /// the user cancelled the prompt. + /// re-prompting on a wrong password. For end-to-end encrypted channels the typed + /// passphrase never goes to the server — a PBKDF2-derived auth key is sent instead, + /// and the room content key is unwrapped locally. Returns the channel history, + /// or null if the user cancelled the prompt. /// private async Task?> JoinChannelWithPasswordPromptAsync(string channelName, string? password) { + ChannelCryptoDto? crypto = null; + try + { + crypto = await _conn.Api!.GetChannelCryptoAsync(channelName); + } + catch (Exception ex) + { + Log.Debug(ex, "Crypto metadata unavailable for {Channel}", channelName); + } + while (true) { + byte[]? kek = null; + var wirePassword = password; + if (password is not null && crypto is { IsEncrypted: true, EncryptionSalt: not null }) + { + var derived = RoomCrypto.DeriveKeys(password, Convert.FromBase64String(crypto.EncryptionSalt)); + wirePassword = derived.AuthKeyHex; + kek = derived.KeyEncryptionKey; + } + try { - return await _conn.JoinChannelAsync(channelName, password); + var outcome = await _conn.JoinChannelAsync(channelName, wirePassword); + + if (outcome.WrappedRoomKey is not null && !_conn.RoomKeys.HasKey(channelName)) + { + if (kek is not null && RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, kek, out var roomKey)) + { + _conn.RoomKeys.StoreKey(channelName, roomKey); + // Re-fetch so history decrypts with the now-available room key + return await _conn.GetHistoryAsync(channelName); + } + + return await UnlockRoomKeyAsync(channelName, outcome); + } + + return outcome.History; } catch (ChannelPasswordRequiredException ex) { @@ -264,6 +356,85 @@ public sealed class AppOrchestrator : IDisposable } } + /// + /// Member of an encrypted channel without a cached room key (e.g. a new device): + /// prompt for the passphrase until the room key unwraps or the user gives up. + /// + private async Task?> UnlockRoomKeyAsync(string channelName, JoinOutcome outcome) + { + if (outcome.EncryptionSalt is null || outcome.WrappedRoomKey is null) + return outcome.History; + + var salt = Convert.FromBase64String(outcome.EncryptionSalt); + var message = "Enter the passphrase to unlock messages."; + + while (true) + { + var prompt = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var promptMessage = message; + InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, promptMessage))); + + var passphrase = await prompt.Task; + if (passphrase is null) + return outcome.History; // stays locked; placeholders render instead of content + + var derived = RoomCrypto.DeriveKeys(passphrase, salt); + if (RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, derived.KeyEncryptionKey, out var roomKey)) + { + _conn.RoomKeys.StoreKey(channelName, roomKey); + return await _conn.GetHistoryAsync(channelName); + } + + message = "Wrong passphrase — try again."; + } + } + + /// + /// Changes the current encrypted channel's passphrase: re-derives the join credential + /// and re-wraps the cached room content key under the new passphrase. History is + /// never re-encrypted — the room key itself doesn't change. + /// + private async Task HandleCmdChangeRoomPassword(string oldPassphrase, string newPassphrase) + { + if (!_conn.IsAuthenticated || !_conn.IsConnected) return; + + var channel = _mainWindow.CurrentChannel; + if (string.IsNullOrEmpty(channel)) return; + + try + { + var crypto = await _conn.Api!.GetChannelCryptoAsync(channel); + if (crypto is not { IsEncrypted: true } || crypto.EncryptionSalt is null) + { + InvokeUI(() => _mainWindow.ShowError($"#{channel} is not an end-to-end encrypted channel.")); + return; + } + + if (!_conn.RoomKeys.TryGetKey(channel, out var roomKey)) + { + InvokeUI(() => _mainWindow.ShowError("Unlock this channel first (rejoin it with its passphrase), then retry.")); + return; + } + + var oldDerived = RoomCrypto.DeriveKeys(oldPassphrase, Convert.FromBase64String(crypto.EncryptionSalt)); + var newSalt = RoomCrypto.GenerateSalt(); + var newDerived = RoomCrypto.DeriveKeys(newPassphrase, newSalt); + + await _conn.Api!.RekeyChannelAsync(channel, new RekeyChannelRequest( + oldDerived.AuthKeyHex, + newDerived.AuthKeyHex, + Convert.ToBase64String(newSalt), + RoomCrypto.WrapRoomKey(roomKey, newDerived.KeyEncryptionKey))); + + InvokeUI(() => _messageManager.AddSystemMessage(channel, + "Passphrase changed. History stays readable; new members and new devices need the new passphrase.")); + } + catch (Exception ex) + { + InvokeUI(() => _mainWindow.ShowError($"Passphrase change failed: {ex.Message}")); + } + } + private async Task HandleCmdLeaveChannel() { if (!_conn.IsConnected) return; @@ -1019,10 +1190,35 @@ public sealed class AppOrchestrator : IDisposable RunAsync(async () => { - var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic, result.Password); + // Password rooms are end-to-end encrypted: derive the join credential and + // wrap a fresh room content key locally — the passphrase never leaves here. + string? wirePassword = null, saltB64 = null, wrappedKey = null; + byte[]? roomKey = null; + if (result.Password is not null) + { + if (result.Password.Length < ValidationConstants.MinChannelPasswordLength) + { + InvokeUI(() => _mainWindow.ShowError( + $"Channel password must be at least {ValidationConstants.MinChannelPasswordLength} characters.")); + return; + } + + var salt = RoomCrypto.GenerateSalt(); + var derived = RoomCrypto.DeriveKeys(result.Password, salt); + roomKey = RoomCrypto.GenerateRoomKey(); + wirePassword = derived.AuthKeyHex; + saltB64 = Convert.ToBase64String(salt); + wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey); + } + + var channel = await _conn.Api!.CreateChannelAsync( + result.Name, result.Topic, result.IsPublic, wirePassword, saltB64, wrappedKey); if (channel is null) return; - var history = await _conn.JoinChannelAsync(channel.Name); + if (roomKey is not null) + _conn.RoomKeys.StoreKey(channel.Name, roomKey); + + var history = (await _conn.JoinChannelAsync(channel.Name)).History; InvokeUI(() => { @@ -1084,11 +1280,59 @@ public sealed class AppOrchestrator : IDisposable RunAsync(async () => { InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); - var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName); + var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName)); }, "Failed to play audio"); } + /// + /// Downloads an attachment to a temp file, decrypting it locally when the current + /// channel is end-to-end encrypted (the server stores those blobs as ciphertext). + /// + private async Task DownloadAttachmentAsync(string attachmentUrl, string fileName) + { + var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName); + + var channel = _mainWindow.CurrentChannel; + if (!string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out var roomKey)) + { + try + { + var blob = await File.ReadAllBytesAsync(tempPath); + await File.WriteAllBytesAsync(tempPath, RoomCrypto.DecryptBytes(blob, roomKey)); + } + catch (Exception ex) + { + Log.Warning(ex, "Attachment {File} did not decrypt with the room key — keeping raw bytes", fileName); + } + } + + return tempPath; + } + + private void HandleImageSaveRequested(string attachmentUrl, string fileName) + { + if (!_conn.IsAuthenticated) return; + + RunAsync(async () => + { + InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); + var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); + + var downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); + Directory.CreateDirectory(downloads); + + var stem = Path.GetFileNameWithoutExtension(fileName); + var ext = Path.GetExtension(fileName); + var destination = Path.Combine(downloads, fileName); + for (var i = 1; File.Exists(destination); i++) + destination = Path.Combine(downloads, $"{stem} ({i}){ext}"); + + File.Move(tempPath, destination); + InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Image saved to: {destination}")); + }, "Failed to save image"); + } + /// /// File extensions considered safe to open with the system default application. /// Everything else is downloaded only — never auto-opened via UseShellExecute. @@ -1106,7 +1350,7 @@ public sealed class AppOrchestrator : IDisposable RunAsync(async () => { InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); - var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName); + var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); var ext = Path.GetExtension(fileName); if (SafeOpenExtensions.Contains(ext)) diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 4e26c84..52d8caf 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -14,6 +14,7 @@ public class CommandHandler public event Func? OnOpenProfile; public event Func? OnOpenServers; public event Func? OnJoinChannel; + public event Func? OnChangeRoomPassword; public event Func? OnLeaveChannel; public event Func? OnSetTopic; public event Func? OnListUsers; @@ -51,6 +52,7 @@ public class CommandHandler "avatar" => await HandleAvatar(args), "servers" => await HandleServers(), "join" => await HandleJoin(args), + "passwd" => await HandlePasswd(args), "leave" => await HandleLeave(), "topic" => await HandleTopic(args), "users" => await HandleUsers(), @@ -204,6 +206,20 @@ public class CommandHandler return new CommandResult(true); } + private async Task HandlePasswd(string args) + { + var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 2) + return new CommandResult(true, "Usage: /passwd — changes the current encrypted channel's passphrase", IsError: true); + + if (parts[1].Length < 3) + return new CommandResult(true, "New passphrase must be at least 3 characters.", IsError: true); + + if (OnChangeRoomPassword is not null) + await OnChangeRoomPassword(parts[0], parts[1]); + return new CommandResult(true); + } + private async Task HandleLeave() { if (OnLeaveChannel is not null) @@ -347,6 +363,7 @@ public class CommandHandler /profile [username] - View a profile /servers - Open saved servers /join [password] - Join a channel (password if protected) + /passwd - Change current encrypted channel's passphrase /leave - Leave current channel /topic - Set channel topic /users - List online users diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 02154bd..15b6b60 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -23,6 +23,13 @@ public class SavedServer public string? RefreshToken { get; set; } public bool RememberMe { get; set; } public DateTimeOffset LastConnected { get; set; } + + /// + /// Cached room content keys for end-to-end encrypted channels on this server, + /// keyed by channel name (base64). Like RefreshToken, these live only on the + /// user's machine — the server never sees them. + /// + public Dictionary ChannelKeys { get; set; } = []; } public class AccountPreset diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 3c689a8..cf64417 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -185,7 +185,8 @@ public sealed class ApiClient : IDisposable return result?.AvatarAscii; } - public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null) + public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null, + string? declaredType = null, string? encryptedContent = null) { EnsureAuthenticated(); using var content = new MultipartFormDataContent(); @@ -193,6 +194,13 @@ public sealed class ApiClient : IDisposable streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); content.Add(streamContent, "file", fileName); + // E2E channels: the blob is ciphertext, so the client declares the type and + // supplies the room-encrypted message content the server can't produce. + if (declaredType is not null) + content.Add(new StringContent(declaredType), "type"); + if (encryptedContent is not null) + content.Add(new StringContent(encryptedContent), "content"); + var sizeQuery = size is not null ? $"?size={size}" : ""; using var response = await AuthenticatedRequestAsync(() => _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content)); @@ -228,16 +236,40 @@ public sealed class ApiClient : IDisposable return tempPath; } - public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true, string? password = null) + public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true, + string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null) { EnsureAuthenticated(); - var request = new CreateChannelRequest(name, topic, isPublic, password); + var request = new CreateChannelRequest(name, topic, isPublic, password, encryptionSalt, wrappedRoomKey); using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync("/api/channels", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); } + /// + /// Fetches a channel's public crypto metadata (whether it's E2E-encrypted and its + /// key-derivation salt). Returns null when the channel doesn't exist. + /// + public async Task GetChannelCryptoAsync(string channelName) + { + EnsureAuthenticated(); + using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/crypto"); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + return null; + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task RekeyChannelAsync(string channelName, RekeyChannelRequest request) + { + EnsureAuthenticated(); + using var response = await AuthenticatedRequestAsync(() => + _http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/rekey", request)); + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(); + } + public async Task UpdateChannelTopicAsync(string channelName, string? topic) { EnsureAuthenticated(); diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index 6b30aa2..ec4e83b 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -24,6 +24,7 @@ internal sealed class ConnectionManager : IAsyncDisposable private EchoHubConnection? _connection; private ApiClient? _apiClient; private readonly ClientEncryptionService _encryption = new(); + private readonly RoomKeyStore _roomKeys = new(); private readonly HashSet _joinedChannels = []; // ── Properties ──────────────────────────────────────────────────────── @@ -31,6 +32,7 @@ internal sealed class ConnectionManager : IAsyncDisposable public bool IsConnected => _connection?.IsConnected == true; public bool IsAuthenticated => _apiClient is not null; public ApiClient? Api => _apiClient; + public RoomKeyStore RoomKeys => _roomKeys; // ── Events (forwarded from SignalR) ─────────────────────────────────── @@ -101,7 +103,8 @@ internal sealed class ConnectionManager : IAsyncDisposable if (_connection is not null) await _connection.DisposeAsync(); - _connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption); + _roomKeys.LoadForServer(info.ServerUrl); + _connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption, _roomKeys); WireConnectionEvents(_connection); await _connection.ConnectAsync(); @@ -156,6 +159,7 @@ internal sealed class ConnectionManager : IAsyncDisposable _apiClient?.Dispose(); _apiClient = null; _joinedChannels.Clear(); + _roomKeys.Clear(); } /// @@ -169,14 +173,14 @@ internal sealed class ConnectionManager : IAsyncDisposable // ── Channel Operations ──────────────────────────────────────────────── - public async Task> JoinChannelAsync(string channelName, string? password = null) + public async Task JoinChannelAsync(string channelName, string? password = null) { if (_connection is null) throw new InvalidOperationException("Not connected"); try { - var history = await _connection.JoinChannelAsync(channelName, password); + var outcome = await _connection.JoinChannelAsync(channelName, password); _joinedChannels.Add(channelName); - return history; + return outcome; } catch (ChannelPasswordRequiredException) { diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 995dfbb..0724fc3 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -1,10 +1,17 @@ using EchoHub.Core.Constants; using EchoHub.Core.DTOs; using EchoHub.Core.Models; +using EchoHub.Core.Security; using Microsoft.AspNetCore.SignalR.Client; namespace EchoHub.Client.Services; +/// +/// Result of joining a channel: decrypted history plus, for end-to-end encrypted +/// channels, the key envelope needed to unlock the room content key. +/// +public sealed record JoinOutcome(List History, string? EncryptionSalt, string? WrappedRoomKey); + /// /// Thrown when joining a channel fails because a password is required or incorrect. /// The UI catches this to prompt the user and retry. @@ -21,8 +28,12 @@ public sealed class ChannelPasswordRequiredException : Exception public sealed class EchoHubConnection : IAsyncDisposable { + public const string LockedMessagePlaceholder = + "[encrypted — rejoin this channel with its passphrase to unlock]"; + private readonly HubConnection _connection; private readonly ClientEncryptionService _encryption; + private readonly RoomKeyStore _roomKeys; public event Action? OnMessageReceived; public event Action? OnUserJoined; @@ -40,9 +51,10 @@ public sealed class EchoHubConnection : IAsyncDisposable public bool IsConnected => _connection.State == HubConnectionState.Connected; - public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption) + public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption, RoomKeyStore roomKeys) { _encryption = encryption; + _roomKeys = roomKeys; var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath; _connection = new HubConnectionBuilder() @@ -79,9 +91,7 @@ public sealed class EchoHubConnection : IAsyncDisposable { _connection.On(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message => { - // Decrypt message content received from server - var decrypted = message with { Content = _encryption.Decrypt(message.Content) }; - OnMessageReceived?.Invoke(decrypted); + OnMessageReceived?.Invoke(DecryptMessage(message)); }); _connection.On(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) => @@ -148,7 +158,7 @@ public sealed class EchoHubConnection : IAsyncDisposable OnConnectionStateChanged?.Invoke("Disconnected"); } - public async Task> JoinChannelAsync(string channelName, string? password = null) + public async Task JoinChannelAsync(string channelName, string? password = null) { var result = await _connection.InvokeAsync("JoinChannel", channelName, password); if (!result.Success) @@ -157,7 +167,7 @@ public sealed class EchoHubConnection : IAsyncDisposable throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected."); throw new InvalidOperationException(result.Error ?? "Failed to join channel."); } - return DecryptMessages(result.History); + return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey); } public async Task LeaveChannelAsync(string channelName) @@ -167,7 +177,10 @@ public sealed class EchoHubConnection : IAsyncDisposable public async Task SendMessageAsync(string channelName, string content) { - // Encrypt content before sending to server + // Room layer first (end-to-end, server can't read), then transport encryption + if (_roomKeys.TryGetKey(channelName, out var roomKey)) + content = RoomCrypto.EncryptText(content, roomKey); + var encrypted = _encryption.Encrypt(content); await _connection.InvokeAsync("SendMessage", channelName, encrypted); } @@ -190,7 +203,32 @@ public sealed class EchoHubConnection : IAsyncDisposable private List DecryptMessages(List messages) { - return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList(); + return messages.Select(DecryptMessage).ToList(); + } + + /// + /// Strips the transport encryption, then the room layer for E2E channels. + /// Without the room key the content is replaced by a locked placeholder — + /// re-fetch history after unlocking to render it. + /// + private MessageDto DecryptMessage(MessageDto message) + { + var content = _encryption.Decrypt(message.Content); + + if (RoomCrypto.IsRoomCiphertext(content)) + { + if (_roomKeys.TryGetKey(message.ChannelName, out var roomKey) + && RoomCrypto.TryDecryptText(content, roomKey, out var plaintext)) + { + content = plaintext; + } + else + { + content = LockedMessagePlaceholder; + } + } + + return message with { Content = content }; } public async ValueTask DisposeAsync() diff --git a/src/EchoHub.Client/Services/RoomKeyStore.cs b/src/EchoHub.Client/Services/RoomKeyStore.cs new file mode 100644 index 0000000..b274bcc --- /dev/null +++ b/src/EchoHub.Client/Services/RoomKeyStore.cs @@ -0,0 +1,109 @@ +using EchoHub.Client.Config; +using Serilog; + +namespace EchoHub.Client.Services; + +/// +/// Holds room content keys for end-to-end encrypted channels: in-memory for the +/// active session, persisted per-server in the client config (like saved sessions) +/// so users don't retype the passphrase every launch. Keys never leave this machine. +/// +public sealed class RoomKeyStore +{ + private readonly Dictionary _keys = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _lock = new(); + private string? _serverUrl; + + /// Binds the store to a server and loads that server's cached keys from config. + public void LoadForServer(string serverUrl) + { + lock (_lock) + { + _serverUrl = serverUrl; + _keys.Clear(); + + var server = FindServer(ConfigManager.Load(), serverUrl); + if (server is null) return; + + foreach (var (channel, base64) in server.ChannelKeys) + { + try + { + _keys[channel] = Convert.FromBase64String(base64); + } + catch (FormatException) + { + Log.Warning("Ignoring malformed cached room key for #{Channel}", channel); + } + } + } + } + + public bool TryGetKey(string channelName, out byte[] key) + { + lock (_lock) + { + if (_keys.TryGetValue(channelName, out var k)) + { + key = k; + return true; + } + } + + key = []; + return false; + } + + public bool HasKey(string channelName) => TryGetKey(channelName, out _); + + /// Stores a key for the session and persists it to the server's config entry. + public void StoreKey(string channelName, byte[] key) + { + lock (_lock) + { + _keys[channelName] = key; + Persist(server => server.ChannelKeys[channelName] = Convert.ToBase64String(key)); + } + } + + public void RemoveKey(string channelName) + { + lock (_lock) + { + _keys.Remove(channelName); + Persist(server => server.ChannelKeys.Remove(channelName)); + } + } + + public void Clear() + { + lock (_lock) + { + _keys.Clear(); + _serverUrl = null; + } + } + + private void Persist(Action mutate) + { + if (_serverUrl is null) return; + + try + { + var config = ConfigManager.Load(); + var server = FindServer(config, _serverUrl); + if (server is null) return; // server not saved yet — key stays in-memory only + + mutate(server); + ConfigManager.Save(config); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to persist room key cache"); + } + } + + private static SavedServer? FindServer(ClientConfig config, string url) => + config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index e8b32c1..fdda23e 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -255,6 +255,22 @@ public sealed class ChatMessageManager lines.Add(new ChatLine($" {trimmed}")); } } + + // Clickable action to download the original image below the ASCII art + if (message.AttachmentUrl is not null) + { + var imageName = message.AttachmentFileName ?? "image"; + var imageSize = FormatFileSize(message.AttachmentFileSize); + var saveLine = new ChatLine(new List + { + new(" ", null), + new($"[↓ save original] {imageName} [{imageSize}]", ChatColors.FileAttr), + }); + saveLine.AttachmentUrl = message.AttachmentUrl; + saveLine.AttachmentFileName = imageName; + saveLine.Type = MessageType.Image; + lines.Add(saveLine); + } break; case MessageType.Audio: diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 1404ce9..d6cf1de 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -60,7 +60,7 @@ public sealed partial class MainWindow : Runnable private static readonly string[] SlashCommands = [ "/status", "/nick", "/color", "/theme", "/send", - "/avatar", "/profile", "/servers", "/join", "/leave", + "/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/topic", "/users", "/kick", "/ban", "/unban", "/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help" ]; @@ -154,6 +154,11 @@ public sealed partial class MainWindow : Runnable /// public event Action? OnFileDownloadRequested; + /// + /// Fired when the user activates an image's "[save original]" line. Parameters: attachmentUrl, fileName. + /// + public event Action? OnImageSaveRequested; + /// /// Fired when the user activates a username (in userlist or message). Parameter is the username. /// @@ -466,6 +471,13 @@ public sealed partial class MainWindow : Runnable e.Handled = true; return; } + + if (line.Type == MessageType.Image) + { + OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + e.Handled = true; + return; + } } var lineText = line.ToString(); diff --git a/src/EchoHub.Core/Contracts/IChannelService.cs b/src/EchoHub.Core/Contracts/IChannelService.cs index bf6cb74..948d427 100644 --- a/src/EchoHub.Core/Contracts/IChannelService.cs +++ b/src/EchoHub.Core/Contracts/IChannelService.cs @@ -6,15 +6,20 @@ public interface IChannelService { // Channel CRUD Task> GetChannelsAsync(Guid userId, int offset, int limit); - Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null); + Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, + string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null); Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic); Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password); + Task RekeyChannelAsync(Guid callerUserId, string channelName, + string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey); Task DeleteChannelAsync(Guid callerUserId, string channelName); // Channel queries Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName); Task> GetChannelListAsync(); Task GetChannelByNameAsync(string channelName); + Task GetChannelCryptoAsync(string channelName); + Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName); // Membership Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null); diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 694f1a4..02a1a4b 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -22,7 +22,8 @@ public record ChannelDto( bool IsPublic, int MessageCount, DateTimeOffset CreatedAt, - bool IsProtected = false); + bool IsProtected = false, + bool IsEncrypted = false); public record UserDto( Guid Id, @@ -34,13 +35,41 @@ public record UserDto( public record SendMessageRequest(string ChannelName, string Content); -public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true, string? Password = null); +public record CreateChannelRequest( + string Name, + string? Topic = null, + bool IsPublic = true, + string? Password = null, + string? EncryptionSalt = null, + string? WrappedRoomKey = null); + +/// +/// Public crypto metadata for a channel — enough for a client to derive its join +/// credential from a passphrase. Never includes the wrapped room key. +/// +public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt); + +/// +/// Passphrase change for an encrypted channel: the client proves knowledge of the old +/// passphrase (old auth key), then supplies the re-wrapped room key under the new one. +/// +public record RekeyChannelRequest( + string OldPassword, + string NewPassword, + string NewEncryptionSalt, + string NewWrappedRoomKey); public record UpdateTopicRequest(string? Topic); public record SendUrlRequest(string Url); -public record JoinChannelResult(bool Success, List History, string? Error = null, bool PasswordRequired = false); +public record JoinChannelResult( + bool Success, + List History, + string? Error = null, + bool PasswordRequired = false, + string? EncryptionSalt = null, + string? WrappedRoomKey = null); public record EmbedDto( string? SiteName, diff --git a/src/EchoHub.Core/EchoHub.Core.csproj b/src/EchoHub.Core/EchoHub.Core.csproj index b760144..e8fc813 100644 --- a/src/EchoHub.Core/EchoHub.Core.csproj +++ b/src/EchoHub.Core/EchoHub.Core.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/EchoHub.Core/Models/Channel.cs b/src/EchoHub.Core/Models/Channel.cs index b305705..27d7c06 100644 --- a/src/EchoHub.Core/Models/Channel.cs +++ b/src/EchoHub.Core/Models/Channel.cs @@ -7,6 +7,12 @@ public class Channel public string? Topic { get; set; } public bool IsPublic { get; set; } = true; public string? PasswordHash { get; set; } + + // End-to-end encryption envelope (client-generated; server cannot decrypt room content). + // EncryptionSalt: PBKDF2 salt for passphrase-derived keys. WrappedRoomKey: the room + // content key encrypted under the passphrase-derived key-encryption key. + public string? EncryptionSalt { get; set; } + public string? WrappedRoomKey { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public Guid CreatedByUserId { get; set; } diff --git a/src/EchoHub.Core/Security/RoomCrypto.cs b/src/EchoHub.Core/Security/RoomCrypto.cs new file mode 100644 index 0000000..f4d3a8b --- /dev/null +++ b/src/EchoHub.Core/Security/RoomCrypto.cs @@ -0,0 +1,132 @@ +using System.Security.Cryptography; +using System.Text; + +namespace EchoHub.Core.Security; + +/// +/// Client-side envelope encryption for private (end-to-end encrypted) channels. +/// +/// Design: at creation the client generates a random 256-bit room content key (RCK) +/// that encrypts all room content. The RCK is stored on the server *wrapped* +/// (AES-GCM encrypted) by a key derived from the passphrase, next to a BCrypt hash +/// of a separately derived auth key used as the join gate. The passphrase, the +/// key-encryption key, and the RCK never leave the client, so the server can gate +/// joins and count/measure content without being able to read it. Changing the +/// passphrase only re-wraps the RCK — history is never re-encrypted. +/// +/// Derivation: PBKDF2-SHA256(passphrase, salt, 210000 iterations) → 64 bytes; +/// first 32 bytes are the auth key (sent to the server as lowercase hex), +/// last 32 bytes are the key-encryption key (never sent). +/// +public static class RoomCrypto +{ + public const string CiphertextPrefix = "$RC1$"; + + private const int Pbkdf2Iterations = 210_000; + private const int SaltSizeBytes = 16; + private const int KeySizeBytes = 32; + private const int NonceSizeBytes = 12; + private const int TagSizeBytes = 16; + + public sealed record DerivedKeys(string AuthKeyHex, byte[] KeyEncryptionKey); + + public static byte[] GenerateSalt() => RandomNumberGenerator.GetBytes(SaltSizeBytes); + + public static byte[] GenerateRoomKey() => RandomNumberGenerator.GetBytes(KeySizeBytes); + + /// + /// Derives the auth key (join gate credential) and key-encryption key from a passphrase. + /// + public static DerivedKeys DeriveKeys(string passphrase, byte[] salt) + { + var okm = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(passphrase), salt, Pbkdf2Iterations, + HashAlgorithmName.SHA256, KeySizeBytes * 2); + + var authKey = Convert.ToHexString(okm.AsSpan(0, KeySizeBytes)).ToLowerInvariant(); + var kek = okm[KeySizeBytes..]; + CryptographicOperations.ZeroMemory(okm.AsSpan(0, KeySizeBytes)); + return new DerivedKeys(authKey, kek); + } + + /// Encrypts UTF-8 text with the room key. Output: $RC1$base64(nonce||tag||ciphertext). + public static string EncryptText(string plaintext, byte[] key) => + CiphertextPrefix + Convert.ToBase64String(EncryptBytes(Encoding.UTF8.GetBytes(plaintext), key)); + + /// + /// Decrypts text produced by . Returns false when the input + /// is not room ciphertext or the key does not match. + /// + public static bool TryDecryptText(string content, byte[] key, out string plaintext) + { + plaintext = string.Empty; + if (!IsRoomCiphertext(content)) + return false; + + try + { + var blob = Convert.FromBase64String(content[CiphertextPrefix.Length..]); + plaintext = Encoding.UTF8.GetString(DecryptBytes(blob, key)); + return true; + } + catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException) + { + return false; + } + } + + public static bool IsRoomCiphertext(string? content) => + content is not null && content.StartsWith(CiphertextPrefix, StringComparison.Ordinal); + + /// Encrypts a binary blob (file contents) with the room key: nonce||tag||ciphertext. + public static byte[] EncryptBytes(byte[] plaintext, byte[] key) + { + var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagSizeBytes]; + + using var aes = new AesGcm(key, TagSizeBytes); + aes.Encrypt(nonce, plaintext, ciphertext, tag); + + var blob = new byte[NonceSizeBytes + TagSizeBytes + ciphertext.Length]; + nonce.CopyTo(blob, 0); + tag.CopyTo(blob, NonceSizeBytes); + ciphertext.CopyTo(blob, NonceSizeBytes + TagSizeBytes); + return blob; + } + + /// Decrypts a blob produced by . Throws on key mismatch. + public static byte[] DecryptBytes(byte[] blob, byte[] key) + { + if (blob.Length < NonceSizeBytes + TagSizeBytes) + throw new CryptographicException("Ciphertext blob is too short."); + + var nonce = blob.AsSpan(0, NonceSizeBytes); + var tag = blob.AsSpan(NonceSizeBytes, TagSizeBytes); + var ciphertext = blob.AsSpan(NonceSizeBytes + TagSizeBytes); + var plaintext = new byte[ciphertext.Length]; + + using var aes = new AesGcm(key, TagSizeBytes); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + return plaintext; + } + + /// Wraps the room content key under the key-encryption key for server storage. + public static string WrapRoomKey(byte[] roomKey, byte[] kek) => + Convert.ToBase64String(EncryptBytes(roomKey, kek)); + + /// Unwraps the stored room content key. Returns false when the KEK (passphrase) is wrong. + public static bool TryUnwrapRoomKey(string wrappedRoomKey, byte[] kek, out byte[] roomKey) + { + roomKey = []; + try + { + roomKey = DecryptBytes(Convert.FromBase64String(wrappedRoomKey), kek); + return roomKey.Length == KeySizeBytes; + } + catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException) + { + return false; + } + } +} diff --git a/src/EchoHub.Core/Services/FileValidationHelper.cs b/src/EchoHub.Core/Services/FileValidationHelper.cs index f1d3b40..ce606de 100644 --- a/src/EchoHub.Core/Services/FileValidationHelper.cs +++ b/src/EchoHub.Core/Services/FileValidationHelper.cs @@ -1,4 +1,4 @@ -namespace EchoHub.Server.Services; +namespace EchoHub.Core.Services; public static class FileValidationHelper { diff --git a/src/EchoHub.Core/Services/ImageToAsciiService.cs b/src/EchoHub.Core/Services/ImageToAsciiService.cs index a1520de..770f648 100644 --- a/src/EchoHub.Core/Services/ImageToAsciiService.cs +++ b/src/EchoHub.Core/Services/ImageToAsciiService.cs @@ -4,7 +4,7 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; -namespace EchoHub.Server.Services; +namespace EchoHub.Core.Services; public class ImageToAsciiService { diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 3cdc77a..0e27201 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -390,6 +390,16 @@ public sealed class IrcCommandHandler continue; } + // End-to-end encrypted channels can't be read over IRC (the gateway would + // have to hold the room key server-side, defeating the privacy guarantee). + var crypto = await _channelService.GetChannelCryptoAsync(channelName); + if (crypto?.IsEncrypted == true) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_BADCHANNELKEY, + $"#{channelName} :Cannot join channel — end-to-end encrypted, use the EchoHub client"); + continue; + } + var (history, error, passwordRequired) = await _chatService.JoinChannelAsync( _conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key); diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 978d65a..c9b4088 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; +using EchoHub.Core.Services; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Data; @@ -65,7 +66,8 @@ public class ChannelsController : ControllerBase return Unauthorized(new ErrorResponse("Authentication required.")); var result = await _channelService.CreateChannelAsync( - Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password); + Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password, + request.EncryptionSalt, request.WrappedRoomKey); if (!result.IsSuccess) return MapChannelError(result); @@ -75,6 +77,43 @@ public class ChannelsController : ControllerBase return Created($"/api/channels/{result.Channel.Name}", result.Channel); } + /// + /// Public crypto metadata for a channel: whether it is end-to-end encrypted and the + /// PBKDF2 salt clients need to derive their join credential. Never returns the + /// wrapped room key — that is only handed out after a successful join. + /// + [HttpGet("{channel}/crypto")] + public async Task GetChannelCrypto(string channel) + { + var crypto = await _channelService.GetChannelCryptoAsync(channel); + if (crypto is null) + return NotFound(new ErrorResponse($"Channel '{channel}' does not exist.")); + + return Ok(crypto); + } + + /// + /// Changes an encrypted channel's passphrase by re-wrapping its room key. + /// The caller proves knowledge of the old passphrase via the old auth key; + /// history is never re-encrypted (the room content key does not change). + /// + [HttpPost("{channel}/rekey")] + public async Task RekeyChannel(string channel, [FromBody] RekeyChannelRequest request) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Unauthorized(new ErrorResponse("Authentication required.")); + + var result = await _channelService.RekeyChannelAsync( + Guid.Parse(userIdClaim), channel, + request.OldPassword, request.NewPassword, + request.NewEncryptionSalt, request.NewWrappedRoomKey); + if (!result.IsSuccess) + return MapChannelError(result); + + return Ok(result.Channel); + } + [HttpPut("{channel}/topic")] public async Task UpdateTopic(string channel, [FromBody] UpdateTopicRequest request) { @@ -131,34 +170,68 @@ public class ChannelsController : ControllerBase var file = Request.Form.Files[0]; - // Detect file type early so we can apply the correct size limit - using var stream = file.OpenReadStream(); - var isImage = FileValidationHelper.IsValidImage(stream); - var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName); - - var maxSize = isImage ? HubConstants.MaxImageSizeBytes - : isAudio ? HubConstants.MaxAudioFileSizeBytes - : HubConstants.MaxFileSizeBytes; - - if (file.Length > maxSize) - return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB.")); - - var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); - - var messageType = isImage ? MessageType.Image - : isAudio ? MessageType.Audio - : MessageType.File; + MessageType messageType; string content; + string fileId; - if (isImage) + if (channelDto.IsEncrypted) { - var (w, h) = ImageToAsciiService.GetDimensions(size); - using var imageStream = System.IO.File.OpenRead(filePath); - content = _asciiService.ConvertToAscii(imageStream, w, h); + // E2E-encrypted channel: the blob is ciphertext the server cannot inspect. + // The client declares the type and supplies pre-rendered, room-encrypted + // content (ASCII art for images, encrypted filename otherwise). + messageType = Request.Form["type"].ToString().ToLowerInvariant() switch + { + "image" => MessageType.Image, + "audio" => MessageType.Audio, + _ => MessageType.File, + }; + + var declaredMax = messageType switch + { + MessageType.Image => HubConstants.MaxImageSizeBytes, + MessageType.Audio => HubConstants.MaxAudioFileSizeBytes, + _ => HubConstants.MaxFileSizeBytes, + }; + if (file.Length > declaredMax) + return BadRequest(new ErrorResponse($"File size exceeds maximum of {declaredMax / (1024 * 1024)} MB.")); + + var clientContent = Request.Form["content"].ToString(); + content = string.IsNullOrEmpty(clientContent) ? file.FileName : clientContent; + + using var encryptedStream = file.OpenReadStream(); + (fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName); } else { - content = file.FileName; + // Detect file type early so we can apply the correct size limit + using var stream = file.OpenReadStream(); + var isImage = FileValidationHelper.IsValidImage(stream); + var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName); + + var maxSize = isImage ? HubConstants.MaxImageSizeBytes + : isAudio ? HubConstants.MaxAudioFileSizeBytes + : HubConstants.MaxFileSizeBytes; + + if (file.Length > maxSize) + return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB.")); + + string filePath; + (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); + + messageType = isImage ? MessageType.Image + : isAudio ? MessageType.Audio + : MessageType.File; + + if (isImage) + { + var (w, h) = ImageToAsciiService.GetDimensions(size); + using var imageStream = System.IO.File.OpenRead(filePath); + content = _asciiService.ConvertToAscii(imageStream, w, h); + } + else + { + content = file.FileName; + } } var attachmentUrl = $"/api/files/{fileId}"; @@ -219,6 +292,10 @@ public class ChannelsController : ControllerBase if (channelDto is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); + if (channelDto.IsEncrypted) + return BadRequest(new ErrorResponse( + "Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead.")); + if (string.IsNullOrWhiteSpace(request.Url)) return BadRequest(new ErrorResponse("URL is required.")); diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 8621454..55e4970 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; +using EchoHub.Core.Services; using EchoHub.Core.DTOs; using EchoHub.Server.Services; using Microsoft.AspNetCore.Authorization; diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index efd244a..3039dbd 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -45,6 +45,8 @@ public class EchoHubDbContext : DbContext entity.Property(c => c.Name).IsRequired().HasMaxLength(100); entity.Property(c => c.Topic).HasMaxLength(500); entity.Property(c => c.PasswordHash).HasMaxLength(100); + entity.Property(c => c.EncryptionSalt).HasMaxLength(64); + entity.Property(c => c.WrappedRoomKey).HasMaxLength(200); entity.HasMany(c => c.Messages) .WithOne(m => m.Channel) diff --git a/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.Designer.cs new file mode 100644 index 0000000..458545a --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.Designer.cs @@ -0,0 +1,279 @@ +// +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("20260716012917_AddChannelEncryptionEnvelope")] + partial class AddChannelEncryptionEnvelope + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("EncryptionSalt") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WrappedRoomKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.HasOne("EchoHub.Core.Models.Channel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EchoHub.Core.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs new file mode 100644 index 0000000..a0a6664 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddChannelEncryptionEnvelope : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EncryptionSalt", + table: "Channels", + type: "TEXT", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "WrappedRoomKey", + table: "Channels", + type: "TEXT", + maxLength: 200, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EncryptionSalt", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "WrappedRoomKey", + table: "Channels"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index c9fcf62..0cba3e3 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -29,6 +29,10 @@ namespace EchoHub.Server.Data.Migrations b.Property("CreatedByUserId") .HasColumnType("TEXT"); + b.Property("EncryptionSalt") + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.Property("IsPublic") .HasColumnType("INTEGER"); @@ -45,6 +49,10 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(500) .HasColumnType("TEXT"); + b.Property("WrappedRoomKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("Name") diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index 466778b..8f0cc68 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -14,9 +14,12 @@ public class ChatHub : Hub private readonly IChatService _chatService; private readonly ILogger _logger; - public ChatHub(IChatService chatService, ILogger logger) + private readonly IChannelService _channelService; + + public ChatHub(IChatService chatService, IChannelService channelService, ILogger logger) { _chatService = chatService; + _channelService = channelService; _logger = logger; } @@ -67,7 +70,12 @@ public class ChatHub : Hub return new JoinChannelResult(false, [], error, passwordRequired); await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim()); - return new JoinChannelResult(true, history); + + // Members of encrypted channels receive the key envelope so they can unwrap + // the room content key with their passphrase (the server can't). + var (encryptionSalt, wrappedRoomKey) = await _channelService.GetChannelKeyEnvelopeAsync(channelName); + return new JoinChannelResult(true, history, + EncryptionSalt: encryptionSalt, WrappedRoomKey: wrappedRoomKey); } catch (Exception ex) { diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 7693bd9..66434f3 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -2,6 +2,7 @@ using System.Text; using System.Threading.RateLimiting; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; +using EchoHub.Core.Services; using EchoHub.Core.Models; using EchoHub.Server.Auth; using EchoHub.Server.Data; diff --git a/src/EchoHub.Server/Services/ChannelService.cs b/src/EchoHub.Server/Services/ChannelService.cs index 65aa692..305dbfe 100644 --- a/src/EchoHub.Server/Services/ChannelService.cs +++ b/src/EchoHub.Server/Services/ChannelService.cs @@ -41,14 +41,16 @@ 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.PasswordHash != null)) + c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt, + c.PasswordHash != null, c.WrappedRoomKey != null)) .ToListAsync(); return new PaginatedResponse(channels, total, offset, limit); } public async Task CreateChannelAsync( - Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) + Guid creatorUserId, string name, string? topic, bool isPublic, + string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null) { if (string.IsNullOrWhiteSpace(name)) return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required."); @@ -63,6 +65,12 @@ public class ChannelService : IChannelService if (passwordError is not null) return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError); + // The E2E envelope (client-generated) only makes sense on password-gated channels + var hasEnvelope = !string.IsNullOrWhiteSpace(encryptionSalt) && !string.IsNullOrWhiteSpace(wrappedRoomKey); + if (hasEnvelope && password is null) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, + "Encrypted channels require a password."); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -77,6 +85,8 @@ public class ChannelService : IChannelService IsPublic = isPublic, CreatedByUserId = creatorUserId, PasswordHash = password is not null ? BCrypt.Net.BCrypt.HashPassword(password) : null, + EncryptionSalt = hasEnvelope ? encryptionSalt : null, + WrappedRoomKey = hasEnvelope ? wrappedRoomKey : null, }; db.Channels.Add(channel); @@ -91,7 +101,7 @@ public class ChannelService : IChannelService await db.SaveChangesAsync(); var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt, - channel.PasswordHash != null); + channel.PasswordHash != null, channel.WrappedRoomKey != null); return ChannelOperationResult.Success(dto); } @@ -119,12 +129,14 @@ public class ChannelService : IChannelService 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); + dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null); return ChannelOperationResult.Success(dto); } /// /// Sets, changes, or clears (null) a channel's join password. Creator or admin only. + /// Not available on end-to-end encrypted channels — those change passphrase via + /// so the room key envelope stays consistent. /// public async Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password) { @@ -141,6 +153,10 @@ public class ChannelService : IChannelService if (dbChannel is null) return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); + if (dbChannel.WrappedRoomKey is not null) + return ChannelOperationResult.Fail(ChannelError.Protected, + "This channel is end-to-end encrypted — change its passphrase from the EchoHub client (/passwd)."); + var caller = await db.Users.FindAsync(callerUserId); if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin)) return ChannelOperationResult.Fail(ChannelError.Forbidden, @@ -151,7 +167,55 @@ public class ChannelService : IChannelService 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); + dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null); + return ChannelOperationResult.Success(dto); + } + + /// + /// Changes an encrypted channel's passphrase by swapping the join-gate hash and the + /// wrapped room key. The room content key itself never changes, so history stays + /// readable — the client re-wraps it under the new passphrase-derived key. + /// Creator only: admins cannot rekey a room whose passphrase they don't know. + /// + public async Task RekeyChannelAsync(Guid callerUserId, string channelName, + string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey) + { + channelName = channelName.ToLowerInvariant().Trim(); + + string? validatedNew = newPassword; + var passwordError = ValidateChannelPassword(ref validatedNew); + if (passwordError is not null) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError); + if (validatedNew is null || string.IsNullOrWhiteSpace(newEncryptionSalt) || string.IsNullOrWhiteSpace(newWrappedRoomKey)) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, + "New password, salt, and wrapped room key are required."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); + + if (dbChannel.WrappedRoomKey is null || dbChannel.PasswordHash is null) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, + "This channel is not end-to-end encrypted."); + + if (dbChannel.CreatedByUserId != callerUserId) + return ChannelOperationResult.Fail(ChannelError.Forbidden, + "Only the channel creator can change the passphrase."); + + if (!BCrypt.Net.BCrypt.Verify(oldPassword, dbChannel.PasswordHash)) + return ChannelOperationResult.Fail(ChannelError.Forbidden, "The current passphrase is incorrect."); + + dbChannel.PasswordHash = BCrypt.Net.BCrypt.HashPassword(validatedNew); + dbChannel.EncryptionSalt = newEncryptionSalt; + dbChannel.WrappedRoomKey = newWrappedRoomKey; + 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, + true, true); return ChannelOperationResult.Success(dto); } @@ -179,7 +243,7 @@ public class ChannelService : IChannelService await db.SaveChangesAsync(); var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt, - dbChannel.PasswordHash != null); + dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null); return ChannelOperationResult.Success(dto); } @@ -220,7 +284,32 @@ 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, c.PasswordHash != null); + return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt, + c.PasswordHash != null, c.WrappedRoomKey != null); + } + + public async Task GetChannelCryptoAsync(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName); + if (c is null) return null; + + return new ChannelCryptoDto(c.WrappedRoomKey != null, c.EncryptionSalt); + } + + public async Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName); + return (c?.EncryptionSalt, c?.WrappedRoomKey); } public async Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync( diff --git a/src/EchoHub.Tests/FileValidationHelperTests.cs b/src/EchoHub.Tests/FileValidationHelperTests.cs index 59bc283..1066339 100644 --- a/src/EchoHub.Tests/FileValidationHelperTests.cs +++ b/src/EchoHub.Tests/FileValidationHelperTests.cs @@ -1,4 +1,4 @@ -using EchoHub.Server.Services; +using EchoHub.Core.Services; using Xunit; namespace EchoHub.Tests; diff --git a/src/EchoHub.Tests/ImageToAsciiServiceTests.cs b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs index 9e9238c..c8232d0 100644 --- a/src/EchoHub.Tests/ImageToAsciiServiceTests.cs +++ b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs @@ -1,4 +1,5 @@ using EchoHub.Core.Constants; +using EchoHub.Core.Services; using EchoHub.Server.Services; using Xunit; diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs index d6b641f..f0ac952 100644 --- a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -287,6 +287,17 @@ public class IrcCommandHandlerTests Assert.Contains(lines, l => l.Contains("475") && l.Contains("#secret") && l.Contains("+k")); } + [Fact] + public async Task Join_EncryptedChannel_IsBlockedOverIrc() + { + _channelService.CryptoToReturn = new ChannelCryptoDto(true, "c2FsdA=="); + + var lines = await RunAuthenticated(["JOIN #vault"]); + + Assert.Contains(lines, l => l.Contains("475") && l.Contains("#vault") && l.Contains("end-to-end encrypted")); + Assert.Empty(_chatService.JoinedChannels); + } + [Fact] public async Task Join_SendsTopic() { diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index d726132..7de0906 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -234,9 +234,24 @@ internal sealed class FakeChannelService : IChannelService public Task> GetChannelsAsync(Guid userId, int offset, int limit) => Task.FromResult(new PaginatedResponse([], 0, offset, limit)); - public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) => + public ChannelCryptoDto? CryptoToReturn { get; set; } + public ChannelOperationResult? RekeyResult { get; set; } + public (string? EncryptionSalt, string? WrappedRoomKey) KeyEnvelopeToReturn { get; set; } + + public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, + string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null) => Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + public Task GetChannelCryptoAsync(string channelName) => + Task.FromResult(CryptoToReturn); + + public Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName) => + Task.FromResult(KeyEnvelopeToReturn); + + public Task RekeyChannelAsync(Guid callerUserId, string channelName, + string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey) => + Task.FromResult(RekeyResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + public Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) => Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); diff --git a/src/EchoHub.Tests/RoomCryptoTests.cs b/src/EchoHub.Tests/RoomCryptoTests.cs new file mode 100644 index 0000000..0bc6855 --- /dev/null +++ b/src/EchoHub.Tests/RoomCryptoTests.cs @@ -0,0 +1,99 @@ +using EchoHub.Core.Security; +using Xunit; + +namespace EchoHub.Tests; + +public class RoomCryptoTests +{ + [Fact] + public void EncryptText_RoundTrips() + { + var key = RoomCrypto.GenerateRoomKey(); + var ciphertext = RoomCrypto.EncryptText("hello secret room", key); + + Assert.StartsWith("$RC1$", ciphertext); + Assert.True(RoomCrypto.TryDecryptText(ciphertext, key, out var plaintext)); + Assert.Equal("hello secret room", plaintext); + } + + [Fact] + public void TryDecryptText_WrongKey_ReturnsFalse() + { + var ciphertext = RoomCrypto.EncryptText("hello", RoomCrypto.GenerateRoomKey()); + + Assert.False(RoomCrypto.TryDecryptText(ciphertext, RoomCrypto.GenerateRoomKey(), out _)); + } + + [Fact] + public void TryDecryptText_PlainText_ReturnsFalse() + { + Assert.False(RoomCrypto.TryDecryptText("just a normal message", RoomCrypto.GenerateRoomKey(), out _)); + } + + [Fact] + public void EncryptBytes_RoundTrips() + { + var key = RoomCrypto.GenerateRoomKey(); + var payload = new byte[4096]; + Random.Shared.NextBytes(payload); + + var blob = RoomCrypto.EncryptBytes(payload, key); + var decrypted = RoomCrypto.DecryptBytes(blob, key); + + Assert.Equal(payload, decrypted); + } + + [Fact] + public void DeriveKeys_IsDeterministic_AndSaltSensitive() + { + var salt = RoomCrypto.GenerateSalt(); + var a = RoomCrypto.DeriveKeys("correct horse battery staple", salt); + var b = RoomCrypto.DeriveKeys("correct horse battery staple", salt); + var other = RoomCrypto.DeriveKeys("correct horse battery staple", RoomCrypto.GenerateSalt()); + + Assert.Equal(a.AuthKeyHex, b.AuthKeyHex); + Assert.Equal(a.KeyEncryptionKey, b.KeyEncryptionKey); + Assert.NotEqual(a.AuthKeyHex, other.AuthKeyHex); + Assert.NotEqual(a.AuthKeyHex, Convert.ToHexString(a.KeyEncryptionKey).ToLowerInvariant()); + } + + [Fact] + public void WrapRoomKey_UnwrapsWithSameKek_FailsWithWrongKek() + { + var salt = RoomCrypto.GenerateSalt(); + var keys = RoomCrypto.DeriveKeys("passphrase-1", salt); + var wrongKeys = RoomCrypto.DeriveKeys("passphrase-2", salt); + var roomKey = RoomCrypto.GenerateRoomKey(); + + var wrapped = RoomCrypto.WrapRoomKey(roomKey, keys.KeyEncryptionKey); + + Assert.True(RoomCrypto.TryUnwrapRoomKey(wrapped, keys.KeyEncryptionKey, out var unwrapped)); + Assert.Equal(roomKey, unwrapped); + Assert.False(RoomCrypto.TryUnwrapRoomKey(wrapped, wrongKeys.KeyEncryptionKey, out _)); + } + + [Fact] + public void Rewrap_PreservesRoomKey_AcrossPassphraseChange() + { + // Simulates a passphrase change: unwrap with old KEK, wrap with new KEK. + var roomKey = RoomCrypto.GenerateRoomKey(); + + var oldSalt = RoomCrypto.GenerateSalt(); + var oldKeys = RoomCrypto.DeriveKeys("old-passphrase", oldSalt); + var wrappedOld = RoomCrypto.WrapRoomKey(roomKey, oldKeys.KeyEncryptionKey); + + Assert.True(RoomCrypto.TryUnwrapRoomKey(wrappedOld, oldKeys.KeyEncryptionKey, out var recovered)); + + var newSalt = RoomCrypto.GenerateSalt(); + var newKeys = RoomCrypto.DeriveKeys("new-passphrase", newSalt); + var wrappedNew = RoomCrypto.WrapRoomKey(recovered, newKeys.KeyEncryptionKey); + + Assert.True(RoomCrypto.TryUnwrapRoomKey(wrappedNew, newKeys.KeyEncryptionKey, out var final)); + Assert.Equal(roomKey, final); + + // Old messages encrypted before the change still decrypt with the unwrapped key + var oldMessage = RoomCrypto.EncryptText("written before rekey", roomKey); + Assert.True(RoomCrypto.TryDecryptText(oldMessage, final, out var plaintext)); + Assert.Equal("written before rekey", plaintext); + } +} From 2d33773c244f00d6befb1fdba01d1c115cf9afbf Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 05:02:12 +0200 Subject: [PATCH 06/10] feat: Add support for message attachments - Introduced Attachment model to handle file attachments associated with messages. - Updated ModerationController to manage message deletions and attachment cleanup. - Enhanced ChatService to include attachments in message retrieval. - Implemented migration for legacy single-attachment messages to the new Attachments model. - Added unit tests for attachment handling in message formatting and parsing. - Updated database context and migrations to support new Attachments table. --- README.md | 1 + docs/changelog/v0.2.12.md | 14 +- src/EchoHub.Client/AppOrchestrator.cs | 261 ++++++++++---- src/EchoHub.Client/Commands/CommandHandler.cs | 25 +- src/EchoHub.Client/Config/ClientConfig.cs | 6 + src/EchoHub.Client/Services/ApiClient.cs | 36 +- src/EchoHub.Client/Services/ClipboardFiles.cs | 154 ++++++++ .../Services/EchoHubConnection.cs | 43 ++- .../Services/NativeFolderPicker.cs | 141 ++++++++ .../Services/OutgoingAttachment.cs | 13 + src/EchoHub.Client/UI/Chat/ChatLine.cs | 4 +- .../UI/Chat/ChatMessageManager.cs | 154 ++++---- .../UI/Helpers/DroppedFileParser.cs | 104 ++++++ src/EchoHub.Client/UI/MainWindow.cs | 196 +++++------ src/EchoHub.Core/Constants/HubConstants.cs | 1 + src/EchoHub.Core/DTOs/ChatDtos.cs | 17 +- src/EchoHub.Core/Models/Attachment.cs | 28 ++ src/EchoHub.Core/Models/AttachmentKind.cs | 12 + src/EchoHub.Core/Models/Message.cs | 19 +- src/EchoHub.Server.Irc/IrcMessageFormatter.cs | 67 ++-- .../Controllers/ChannelsController.cs | 205 ++++++----- .../Controllers/ModerationController.cs | 51 ++- src/EchoHub.Server/Data/EchoHubDbContext.cs | 16 + ...16020211_AddMessageAttachments.Designer.cs | 331 ++++++++++++++++++ .../20260716020211_AddMessageAttachments.cs | 50 +++ .../EchoHubDbContextModelSnapshot.cs | 52 +++ src/EchoHub.Server/Services/ChatService.cs | 28 +- .../Setup/DataMigrationService.cs | 54 +++ src/EchoHub.Tests/DroppedFileParserTests.cs | 116 ++++++ src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs | 9 +- .../Irc/IrcCommandHandlerTests.cs | 3 +- .../Irc/IrcMessageFormatterTests.cs | 18 +- src/EchoHub.Tests/IrcMessageFormatterTests.cs | 68 ++-- 33 files changed, 1843 insertions(+), 454 deletions(-) create mode 100644 src/EchoHub.Client/Services/ClipboardFiles.cs create mode 100644 src/EchoHub.Client/Services/NativeFolderPicker.cs create mode 100644 src/EchoHub.Client/Services/OutgoingAttachment.cs create mode 100644 src/EchoHub.Client/UI/Helpers/DroppedFileParser.cs create mode 100644 src/EchoHub.Core/Models/Attachment.cs create mode 100644 src/EchoHub.Core/Models/AttachmentKind.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.cs create mode 100644 src/EchoHub.Tests/DroppedFileParserTests.cs diff --git a/README.md b/README.md index a56de2c..0b3f8be 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | ------- | ----------- | | `/join [password]` | Join a channel (passphrase for encrypted channels) | | `/passwd ` | Change the current encrypted channel's passphrase (creator only) | +| `/downloadpath [path]` | Set the download folder (no path = native folder picker) | | `/leave` | Leave current channel | | `/topic ` | Set channel topic (creator only) | | `/send ` | Upload a file or image | diff --git a/docs/changelog/v0.2.12.md b/docs/changelog/v0.2.12.md index ce425e1..8b5a20c 100644 --- a/docs/changelog/v0.2.12.md +++ b/docs/changelog/v0.2.12.md @@ -13,12 +13,18 @@ Private channels are now genuinely private: password-protected channels are end- - End-to-end encrypted channels cannot be joined over the IRC gateway (that would require the server to hold the room key) — IRC `JOIN` returns `475` directing users to the EchoHub client. - Password-protected channels — set an optional password when creating a channel (masked field in the Create Channel dialog, `password` on `POST /api/channels`). Passwords are BCrypt-hashed server-side; the join gate applies on first join only (existing members and the creator are unaffected). Protected channels show a `*` marker in the channel list and `+k` in the status bar - Save original images — image messages now show a clickable "[↓ save original]" line under the ASCII-art preview that downloads the full-resolution original to your Downloads folder (decrypting locally in encrypted channels) +- **Messages with attachments (Discord-style)** — a message is now text **plus** a list of attachments instead of being either text or a single file. One message can carry a caption and several files (images, audio, docs) together: + - Compose with a **staging tray**: `/send ` or dropping files onto the terminal stages them (shown on the input bar); the next Enter sends your typed caption and all staged files as one message. `/clear` drops staged files. `/send ` still posts an image immediately. + - Each image attachment renders its own ASCII preview with its own "save original" action; audio/file attachments each get their own play/download line. + - In encrypted channels every attachment is encrypted individually (blob + ASCII preview), and the caption is room-encrypted — the server still stores only ciphertext and can report count/size but not contents. + - Up to 10 attachments per message. +- **Message deletion** — press Delete on a selected message to remove it. You can always delete your own messages; moderators and above can delete others' messages, but only from users **below their own role** (a mod can't delete an admin's or owner's message). Deleting a message also removes its attachment blobs from server storage. +- **Customizable download folder** — `/downloadpath` opens your OS-native folder picker (Windows Explorer / macOS Finder / Linux GTK or KDE) to choose where downloaded attachments and saved images go; `/downloadpath ` sets it directly (the fallback when no native picker is available). Downloaded files now land in that folder (with automatic `(n)` de-duplication) instead of a temp directory. - `/join [password]` — join protected channels inline, or let the client prompt: joining a protected channel without a password opens a masked prompt that re-prompts on a wrong password - IRC channel keys — `JOIN #room ` works against room passwords (RFC 1459 comma-paired key lists supported); keyless or wrong-key joins get `475 ERR_BADCHANNELKEY` - IRC `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k ` sets and `-k` clears the room password (channel creator or admin only), ban-list probes get a clean empty reply, and `CHANMODES` is advertised in ISUPPORT - IRC `TOPIC` set support — the channel creator can change the topic from IRC; the change broadcasts to connected TUI clients (previously topic changes were rejected with a stub error) -- Drag & drop file sending — dropping a file (image, audio, anything) onto the terminal detects the pasted path and sends it through `/send` automatically, including multiple files at once -- Ctrl+V pastes into the message input (previously paste was only available via the right-click menu); Ctrl+Y works as an alias +- Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux - New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`) - Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages. @@ -34,4 +40,6 @@ Private channels are now genuinely private: password-protected channels are end- - New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change) - The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content - `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads -- New EF migrations `AddChannelPasswordHash` and `AddChannelEncryptionEnvelope` (applied automatically on server start) +- **Message shape change**: `MessageDto` drops `Type`/`AttachmentUrl`/`AttachmentFileName`/`AttachmentFileSize` and gains `Attachments` (a list of `AttachmentDto { Kind, Url, FileName, FileSize, AsciiPreview }`, null/empty for plain text). New `Attachment` entity + table with a cascade FK to `Message` +- New endpoint `POST /api/channels/{channel}/messages` (multipart: `content` + N `files`, plus `kind`/`preview` per file for encrypted channels) replaces the single-file `upload` endpoint; `DELETE /api/moderation/messages/{id}` now enforces the own-or-higher-role rule +- New EF migrations `AddChannelPasswordHash`, `AddChannelEncryptionEnvelope`, and `AddMessageAttachments` (applied automatically on server start); a one-time startup data migration folds legacy single-attachment messages into the new model diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index b742136..428b5fc 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -33,6 +33,7 @@ public sealed class AppOrchestrator : IDisposable private readonly Dictionary> _channelUsers = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _channelUsersLock = new(); private readonly HashSet _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase); + private readonly List _stagedAttachments = []; private ClientConfig _config; private readonly UserSession _session = new(); @@ -91,6 +92,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnImageSaveRequested += HandleImageSaveRequested; + _mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested; _mainWindow.OnUserProfileRequested += HandleViewProfile; @@ -113,6 +115,8 @@ public sealed class AppOrchestrator : IDisposable _commandHandler.OnOpenServers += HandleCmdOpenServers; _commandHandler.OnJoinChannel += HandleCmdJoinChannel; _commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword; + _commandHandler.OnClearAttachments += HandleCmdClearAttachments; + _commandHandler.OnSetDownloadPath += HandleCmdSetDownloadPath; _commandHandler.OnLeaveChannel += HandleCmdLeaveChannel; _commandHandler.OnSetTopic += HandleCmdSetTopic; _commandHandler.OnListUsers += HandleCmdListUsers; @@ -162,85 +166,107 @@ public sealed class AppOrchestrator : IDisposable return Task.CompletedTask; } - private async Task HandleCmdSendFile(string target, string? size) + private Task HandleCmdSendFile(string target, string? size) { - if (!_conn.IsAuthenticated || !_conn.IsConnected) return; + if (!_conn.IsAuthenticated || !_conn.IsConnected) return Task.CompletedTask; var channel = _mainWindow.CurrentChannel; - if (string.IsNullOrEmpty(channel)) return; + if (string.IsNullOrEmpty(channel)) return Task.CompletedTask; - try + // A URL image is sent immediately as its own message (it can't be staged/encrypted). + if (Uri.TryCreate(target, UriKind.Absolute, out var uri) + && (uri.Scheme == "http" || uri.Scheme == "https")) { - var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); + if (_conn.RoomKeys.HasKey(channel)) + { + InvokeUI(() => _mainWindow.ShowError( + "Sending by URL isn't available in encrypted channels — download the file and /send it instead.")); + return Task.CompletedTask; + } - if (Uri.TryCreate(target, UriKind.Absolute, out var uri) - && (uri.Scheme == "http" || uri.Scheme == "https")) - { - if (hasRoomKey) - { - InvokeUI(() => _mainWindow.ShowError( - "Sending by URL isn't available in encrypted channels — download the file and /send it instead.")); - return; - } - - await _conn.Api!.SendUrlAsync(channel, target, size); - } - else if (hasRoomKey) - { - await UploadEncryptedFileAsync(channel, target, size, roomKey); - } - else - { - await using var stream = File.OpenRead(target); - var fileName = Path.GetFileName(target); - await _conn.Api!.UploadFileAsync(channel, stream, fileName, size); - } + RunAsync(async () => await _conn.Api!.SendUrlAsync(channel, target, size), "Send failed"); + return Task.CompletedTask; } - catch (Exception ex) + + // Local files are staged; the next Enter sends them with the typed caption as one message. + if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage) { - Log.Error(ex, "File send failed for {Target}", target); - InvokeUI(() => _mainWindow.ShowError($"Send failed: {ex.Message}")); + InvokeUI(() => _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message.")); + return Task.CompletedTask; } + + _stagedAttachments.Add(target); + InvokeUI(() => _mainWindow.SetStagedAttachments(_stagedAttachments.Select(Path.GetFileName).OfType().ToList())); + return Task.CompletedTask; + } + + private Task HandleCmdClearAttachments() + { + _stagedAttachments.Clear(); + InvokeUI(() => _mainWindow.SetStagedAttachments([])); + return Task.CompletedTask; } /// - /// Upload into an end-to-end encrypted channel: the blob is encrypted with the room - /// key before it leaves this machine, and for images the ASCII preview is rendered - /// locally and sent room-encrypted — the server never sees image or file contents. + /// Sends one message with the given caption plus all staged files as attachments, then + /// clears the staging tray. In encrypted channels each file is room-encrypted (blob + + /// ASCII preview) client-side before upload; the caption is room-encrypted too. /// - private async Task UploadEncryptedFileAsync(string channel, string path, string? size, byte[] roomKey) + private void SendStagedMessage(string channel, string content) + { + var staged = _stagedAttachments.ToList(); + _stagedAttachments.Clear(); + InvokeUI(() => _mainWindow.SetStagedAttachments([])); + + var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); + + RunAsync(async () => + { + var outgoing = new List(); + foreach (var path in staged) + outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null)); + + var wireContent = hasRoomKey && !string.IsNullOrEmpty(content) + ? RoomCrypto.EncryptText(content, roomKey) + : content; + + await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing); + }, "Send failed"); + } + + /// + /// Reads a staged file into an . For encrypted channels the + /// blob is AES-GCM encrypted, its kind is declared, and the image ASCII preview is rendered + /// locally and room-encrypted — so the server never sees the file or image contents. + /// + private static async Task BuildOutgoingAttachmentAsync(string path, byte[]? roomKey) { var fileName = Path.GetFileName(path); - var bytes = await File.ReadAllBytesAsync(path); - string declaredType; - string plainContent; + if (roomKey is null) + return new OutgoingAttachment(File.OpenRead(path), fileName); + + var bytes = await File.ReadAllBytesAsync(path); + string declaredKind; + string? preview = null; + using (var ms = new MemoryStream(bytes)) { if (FileValidationHelper.IsValidImage(ms)) { - declaredType = "image"; - var (w, h) = ImageToAsciiService.GetDimensions(size); + declaredKind = "image"; + var (w, h) = ImageToAsciiService.GetDimensions(null); ms.Position = 0; - plainContent = new ImageToAsciiService().ConvertToAscii(ms, w, h); - } - else if (FileValidationHelper.IsAudioFile(fileName)) - { - declaredType = "audio"; - plainContent = fileName; + preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); } else { - declaredType = "file"; - plainContent = fileName; + declaredKind = FileValidationHelper.IsAudioFile(fileName) ? "audio" : "file"; } } - var encryptedContent = RoomCrypto.EncryptText(plainContent, roomKey); var encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey); - - await using var blobStream = new MemoryStream(encryptedBlob); - await _conn.Api!.UploadFileAsync(channel, blobStream, fileName, size, declaredType, encryptedContent); + return new OutgoingAttachment(new MemoryStream(encryptedBlob), fileName, declaredKind, preview); } private async Task HandleCmdSetAvatar(string target) @@ -893,11 +919,28 @@ public sealed class AppOrchestrator : IDisposable return; } + // Staged files → one message with the typed caption plus those attachments. + if (_stagedAttachments.Count > 0) + { + SendStagedMessage(channelName, content); + return; + } + RunAsync( async () => await _conn.SendMessageAsync(channelName, content), "Send failed"); } + private void HandleDeleteMessageRequested(Guid messageId) + { + if (!_conn.IsAuthenticated) return; + + // The server enforces the hierarchy rule (own message, or Mod+ over a strictly + // lower role) and broadcasts the deletion; the local list updates on that event. + RunAsync(async () => await _conn.Api!.DeleteMessageAsync(messageId), + "Failed to delete message"); + } + private void HandleChannelSelected(string channelName) { if (!_conn.IsConnected) return; @@ -1319,20 +1362,46 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); - var downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); - Directory.CreateDirectory(downloads); - - var stem = Path.GetFileNameWithoutExtension(fileName); - var ext = Path.GetExtension(fileName); - var destination = Path.Combine(downloads, fileName); - for (var i = 1; File.Exists(destination); i++) - destination = Path.Combine(downloads, $"{stem} ({i}){ext}"); - + var destination = DedupPath(GetDownloadDir(), fileName); File.Move(tempPath, destination); InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Image saved to: {destination}")); }, "Failed to save image"); } + /// + /// Resolves the folder downloads are written to: the user's configured + /// if set, otherwise the OS Downloads folder. + /// Falls back to the temp folder if neither can be created. + /// + private string GetDownloadDir() + { + var dir = _config.DownloadPath; + if (string.IsNullOrWhiteSpace(dir)) + dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); + + try + { + Directory.CreateDirectory(dir); + return dir; + } + catch (Exception ex) + { + Log.Warning(ex, "Download folder {Dir} is not usable; falling back to temp", dir); + return Path.GetTempPath(); + } + } + + /// Appends " (n)" before the extension until the path doesn't collide with an existing file. + private static string DedupPath(string dir, string fileName) + { + var stem = Path.GetFileNameWithoutExtension(fileName); + var ext = Path.GetExtension(fileName); + var dest = Path.Combine(dir, fileName); + for (var i = 1; File.Exists(dest); i++) + dest = Path.Combine(dir, $"{stem} ({i}){ext}"); + return dest; + } + /// /// File extensions considered safe to open with the system default application. /// Everything else is downloaded only — never auto-opened via UseShellExecute. @@ -1352,27 +1421,81 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); - var ext = Path.GetExtension(fileName); - if (SafeOpenExtensions.Contains(ext)) + var destination = DedupPath(GetDownloadDir(), fileName); + File.Move(tempPath, destination); + InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Saved to: {destination}")); + + if (SafeOpenExtensions.Contains(Path.GetExtension(fileName))) { try { - var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true }; + var psi = new System.Diagnostics.ProcessStartInfo(destination) { UseShellExecute = true }; System.Diagnostics.Process.Start(psi); } catch (Exception ex) { - Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath); - InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}")); + Log.Warning(ex, "Failed to open file with default app: {Path}", destination); } } - else - { - InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}")); - } }, "Failed to download file"); } + /// + /// Sets the download folder. With no argument, opens the OS-native folder picker; if that + /// isn't available (headless, missing tool), tells the user to pass a path instead. With an + /// argument, sets that path directly (the fallback for machines with no native picker). + /// + private Task HandleCmdSetDownloadPath(string args) + { + var current = _config.DownloadPath ?? GetDownloadDir(); + + if (!string.IsNullOrWhiteSpace(args)) + { + SetDownloadPath(args.Trim()); + return Task.CompletedTask; + } + + RunAsync(async () => + { + var result = await NativeFolderPicker.PickFolderAsync(current); + InvokeUI(() => + { + switch (result.Outcome) + { + case PickerOutcome.Chosen when result.Path is not null: + SetDownloadPath(result.Path); + break; + case PickerOutcome.Cancelled: + _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, "Download folder unchanged."); + break; + case PickerOutcome.Unavailable: + _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, + $"No native folder picker here. Current download folder: {current}\nSet one with: /downloadpath "); + break; + } + }); + }, "Failed to open folder picker"); + + return Task.CompletedTask; + } + + private void SetDownloadPath(string path) + { + try + { + Directory.CreateDirectory(path); + } + catch (Exception ex) + { + InvokeUI(() => _mainWindow.ShowError($"Can't use that folder: {ex.Message}")); + return; + } + + _config.DownloadPath = path; + ConfigManager.Save(_config); + InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Download folder set to: {path}")); + } + private void HandleCheckForUpdatesRequested() { RunAsync(_updateService.CheckNowAsync, "Failed to check for updates"); diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 52d8caf..59c7c06 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -15,6 +15,8 @@ public class CommandHandler public event Func? OnOpenServers; public event Func? OnJoinChannel; public event Func? OnChangeRoomPassword; + public event Func? OnClearAttachments; + public event Func? OnSetDownloadPath; public event Func? OnLeaveChannel; public event Func? OnSetTopic; public event Func? OnListUsers; @@ -48,6 +50,8 @@ public class CommandHandler "color" => await HandleColor(args), "theme" => await HandleTheme(args), "send" => await HandleSend(args), + "clear" => await HandleClear(), + "downloadpath" or "downloads" => await HandleDownloadPath(args), "profile" => await HandleProfile(args), "avatar" => await HandleAvatar(args), "servers" => await HandleServers(), @@ -165,6 +169,21 @@ public class CommandHandler return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}..."); } + private async Task HandleClear() + { + if (OnClearAttachments is not null) + await OnClearAttachments(); + return new CommandResult(true, "Cleared staged attachments."); + } + + private async Task HandleDownloadPath(string args) + { + // No argument → open the native folder picker; an argument sets the path directly. + if (OnSetDownloadPath is not null) + await OnSetDownloadPath(args.Trim()); + return new CommandResult(true); + } + private async Task HandleProfile(string args) { var username = string.IsNullOrWhiteSpace(args) ? null : args.Trim(); @@ -358,7 +377,11 @@ public class CommandHandler /nick - Set display name /color <#hex> - Set nickname color /theme - Switch theme - /send [-s|-m|-l] - Send file/image/audio (size flag for images) + /send [-s|-m|-l] - Stage a file to attach (Enter sends with your text) + /send [-s|-m|-l] - Send an image URL immediately + /clear - Drop all staged attachments + (Tip: copy a file and press Ctrl+V, or drag a file onto the window, to attach it.) + /downloadpath [path] - Set download folder (no path = native folder picker) /avatar - Set your avatar /profile [username] - View a profile /servers - Open saved servers diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 15b6b60..ee87a69 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -6,6 +6,12 @@ public class ClientConfig public AccountPreset DefaultPreset { get; set; } = new(); public string ActiveTheme { get; set; } = "Default"; public NotificationConfig Notifications { get; set; } = new(); + + /// + /// Folder where downloaded attachments and saved images are written. When null, the + /// OS Downloads folder is used. Set via the native folder picker or /downloadpath. + /// + public string? DownloadPath { get; set; } } public class NotificationConfig diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index cf64417..df99d26 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -185,25 +185,35 @@ public sealed class ApiClient : IDisposable return result?.AvatarAscii; } - public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null, - string? declaredType = null, string? encryptedContent = null) + /// + /// Sends one message with optional text and one or more file attachments. + /// For end-to-end encrypted channels each attachment carries a declared kind and a + /// room-encrypted preview (empty when none); the caption is likewise room-encrypted. + /// + public async Task SendMessageWithAttachmentsAsync( + string channelName, string content, IReadOnlyList attachments, string? size = null) { EnsureAuthenticated(); - using var content = new MultipartFormDataContent(); - using var streamContent = new StreamContent(fileStream); - streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); - content.Add(streamContent, "file", fileName); + using var form = new MultipartFormDataContent { { new StringContent(content), "content" } }; - // E2E channels: the blob is ciphertext, so the client declares the type and - // supplies the room-encrypted message content the server can't produce. - if (declaredType is not null) - content.Add(new StringContent(declaredType), "type"); - if (encryptedContent is not null) - content.Add(new StringContent(encryptedContent), "content"); + foreach (var att in attachments) + { + var streamContent = new StreamContent(att.Stream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(att.FileName)); + form.Add(streamContent, "file", att.FileName); + + // Encrypted channels: one kind + preview per file, in the same order, to keep + // the server's index alignment (empty preview string for non-images). + if (att.DeclaredKind is not null) + { + form.Add(new StringContent(att.DeclaredKind), "kind"); + form.Add(new StringContent(att.EncryptedPreview ?? string.Empty), "preview"); + } + } var sizeQuery = size is not null ? $"?size={size}" : ""; using var response = await AuthenticatedRequestAsync(() => - _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content)); + _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/messages{sizeQuery}", form)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); } diff --git a/src/EchoHub.Client/Services/ClipboardFiles.cs b/src/EchoHub.Client/Services/ClipboardFiles.cs new file mode 100644 index 0000000..30651c2 --- /dev/null +++ b/src/EchoHub.Client/Services/ClipboardFiles.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; +using Serilog; + +namespace EchoHub.Client.Services; + +/// +/// Reads file paths that live on the OS clipboard as a *file list* (e.g. after copying a file in +/// Explorer/Finder/Nautilus), which terminals do not paste as text. Lets Ctrl+V attach a copied +/// file directly instead of requiring the user to paste a raw path. +/// +public static class ClipboardFiles +{ + public static bool TryGetFiles(out List files) + { + files = []; + try + { + if (OperatingSystem.IsWindows()) + return TryGetWindows(out files); + if (OperatingSystem.IsLinux()) + return TryGetLinux(out files); + } + catch (Exception ex) + { + Log.Warning(ex, "Reading files from the clipboard failed"); + } + + // macOS and everything else: no file-list clipboard support (text paste still works). + return false; + } + + // ── Windows: CF_HDROP via the Win32 clipboard ──────────────────────────── + + private const uint CfHdrop = 15; + + [SupportedOSPlatform("windows")] + private static bool TryGetWindows(out List files) + { + files = []; + if (!IsClipboardFormatAvailable(CfHdrop)) + return false; + + // The clipboard may briefly be held by another process; a few quick retries cover that. + var opened = false; + for (var attempt = 0; attempt < 5 && !opened; attempt++) + opened = OpenClipboard(IntPtr.Zero); + if (!opened) + return false; + + try + { + var hDrop = GetClipboardData(CfHdrop); + if (hDrop == IntPtr.Zero) + return false; + + var count = DragQueryFileW(hDrop, 0xFFFFFFFF, null, 0); + for (uint i = 0; i < count; i++) + { + var len = DragQueryFileW(hDrop, i, null, 0); + if (len == 0) + continue; + + var sb = new StringBuilder((int)len + 1); + DragQueryFileW(hDrop, i, sb, (uint)sb.Capacity); + var path = sb.ToString(); + if (File.Exists(path)) + files.Add(path); + } + + return files.Count > 0; + } + finally + { + CloseClipboard(); + } + } + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenClipboard(IntPtr hWndNewOwner); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseClipboard(); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsClipboardFormatAvailable(uint format); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr GetClipboardData(uint uFormat); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern uint DragQueryFileW(IntPtr hDrop, uint iFile, StringBuilder? lpszFile, uint cch); + + // ── Linux: text/uri-list from the clipboard via xclip or wl-paste ───────── + + [SupportedOSPlatform("linux")] + private static bool TryGetLinux(out List files) + { + files = []; + + var output = RunForOutput("wl-paste", ["--type", "text/uri-list", "--no-newline"]) + ?? RunForOutput("xclip", ["-selection", "clipboard", "-t", "text/uri-list", "-o"]); + if (string.IsNullOrWhiteSpace(output)) + return false; + + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!line.StartsWith("file://", StringComparison.Ordinal)) + continue; + try + { + var path = new Uri(line).LocalPath; + if (File.Exists(path)) + files.Add(path); + } + catch (UriFormatException) { /* skip malformed entry */ } + } + + return files.Count > 0; + } + + private static string? RunForOutput(string fileName, IEnumerable args) + { + var psi = new ProcessStartInfo(fileName) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + try + { + using var process = Process.Start(psi); + if (process is null) + return null; + + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(2000); + return process.ExitCode == 0 ? output : null; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException) + { + return null; // tool not installed + } + } +} diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 0724fc3..0fa0034 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -207,28 +207,41 @@ public sealed class EchoHubConnection : IAsyncDisposable } /// - /// Strips the transport encryption, then the room layer for E2E channels. - /// Without the room key the content is replaced by a locked placeholder — - /// re-fetch history after unlocking to render it. + /// Strips the transport encryption, then the room layer for E2E channels, from the + /// message content and every attachment preview. Without the room key the content is + /// replaced by a locked placeholder — re-fetch history after unlocking to render it. /// private MessageDto DecryptMessage(MessageDto message) { - var content = _encryption.Decrypt(message.Content); + _roomKeys.TryGetKey(message.ChannelName, out var roomKey); - if (RoomCrypto.IsRoomCiphertext(content)) + var content = DecryptField(message.Content, roomKey) ?? LockedMessagePlaceholder; + + List? attachments = null; + if (message.Attachments is { Count: > 0 }) { - if (_roomKeys.TryGetKey(message.ChannelName, out var roomKey) - && RoomCrypto.TryDecryptText(content, roomKey, out var plaintext)) - { - content = plaintext; - } - else - { - content = LockedMessagePlaceholder; - } + attachments = message.Attachments + .Select(a => a with { AsciiPreview = a.AsciiPreview is null ? null : DecryptField(a.AsciiPreview, roomKey) }) + .ToList(); } - return message with { Content = content }; + return message with { Content = content, Attachments = attachments }; + } + + /// + /// Decrypts one field: strips transport encryption, then the room layer if it is room + /// ciphertext. Returns null when it is room ciphertext but the room key is missing/wrong. + /// + private string? DecryptField(string value, byte[]? roomKey) + { + var plain = _encryption.Decrypt(value); + if (!RoomCrypto.IsRoomCiphertext(plain)) + return plain; + + if (roomKey is not null && RoomCrypto.TryDecryptText(plain, roomKey, out var decrypted)) + return decrypted; + + return null; } public async ValueTask DisposeAsync() diff --git a/src/EchoHub.Client/Services/NativeFolderPicker.cs b/src/EchoHub.Client/Services/NativeFolderPicker.cs new file mode 100644 index 0000000..df58b52 --- /dev/null +++ b/src/EchoHub.Client/Services/NativeFolderPicker.cs @@ -0,0 +1,141 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Serilog; + +namespace EchoHub.Client.Services; + +public enum PickerOutcome +{ + /// The user picked a folder ( is set). + Chosen, + + /// The native dialog ran but the user cancelled it. + Cancelled, + + /// No native picker is available on this machine (headless, missing tool, etc.). + Unavailable, +} + +public sealed record FolderPickResult(PickerOutcome Outcome, string? Path); + +/// +/// Opens the OS-native folder chooser (Windows Explorer, macOS Finder, Linux GTK/KDE) by shelling +/// out, so the TUI doesn't need a GUI toolkit reference. Returns +/// when no native dialog can run, so callers can fall back to a configured path. +/// +public static class NativeFolderPicker +{ + private const string Title = "Choose your EchoHub download folder"; + + public static async Task PickFolderAsync(string? initialDir) + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return await PickWindowsAsync(initialDir); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return await PickMacAsync(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return await PickLinuxAsync(initialDir); + } + catch (Exception ex) + { + Log.Warning(ex, "Native folder picker failed"); + } + + return new FolderPickResult(PickerOutcome.Unavailable, null); + } + + private static async Task PickWindowsAsync(string? initialDir) + { + var safeInit = (initialDir ?? string.Empty).Replace("'", "''"); + var script = $$""" + Add-Type -AssemblyName System.Windows.Forms + $d = New-Object System.Windows.Forms.FolderBrowserDialog + $d.Description = '{{Title}}' + $d.ShowNewFolderButton = $true + $d.SelectedPath = '{{safeInit}}' + if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) } + """; + + // -EncodedCommand avoids all quoting issues; FolderBrowserDialog needs an STA thread. + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + var (started, _, stdout) = await RunAsync("powershell.exe", + ["-STA", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]); + + if (!started) + return new FolderPickResult(PickerOutcome.Unavailable, null); + return string.IsNullOrWhiteSpace(stdout) + ? new FolderPickResult(PickerOutcome.Cancelled, null) + : new FolderPickResult(PickerOutcome.Chosen, stdout); + } + + private static async Task PickMacAsync() + { + var (started, exit, stdout) = await RunAsync("osascript", + ["-e", $"POSIX path of (choose folder with prompt \"{Title}\")"]); + + if (!started) + return new FolderPickResult(PickerOutcome.Unavailable, null); + return exit == 0 && !string.IsNullOrWhiteSpace(stdout) + ? new FolderPickResult(PickerOutcome.Chosen, stdout) + : new FolderPickResult(PickerOutcome.Cancelled, null); + } + + private static async Task PickLinuxAsync(string? initialDir) + { + // No graphical session → no native picker. + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")) + && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))) + return new FolderPickResult(PickerOutcome.Unavailable, null); + + var zenityArgs = new List { "--file-selection", "--directory", $"--title={Title}" }; + if (!string.IsNullOrWhiteSpace(initialDir)) + zenityArgs.Add($"--filename={initialDir!.TrimEnd('/')}/"); + + var (zStarted, zExit, zOut) = await RunAsync("zenity", zenityArgs); + if (zStarted) + return zExit == 0 && !string.IsNullOrWhiteSpace(zOut) + ? new FolderPickResult(PickerOutcome.Chosen, zOut) + : new FolderPickResult(PickerOutcome.Cancelled, null); + + var (kStarted, kExit, kOut) = await RunAsync("kdialog", + ["--getexistingdirectory", string.IsNullOrWhiteSpace(initialDir) ? "." : initialDir!]); + if (kStarted) + return kExit == 0 && !string.IsNullOrWhiteSpace(kOut) + ? new FolderPickResult(PickerOutcome.Chosen, kOut) + : new FolderPickResult(PickerOutcome.Cancelled, null); + + return new FolderPickResult(PickerOutcome.Unavailable, null); + } + + private static async Task<(bool Started, int ExitCode, string StdOut)> RunAsync(string fileName, IEnumerable args) + { + var psi = new ProcessStartInfo(fileName) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + try + { + using var process = Process.Start(psi); + if (process is null) + return (false, -1, string.Empty); + + var stdout = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + return (true, process.ExitCode, stdout.Trim()); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException) + { + // Executable not found on PATH → treat as "no native picker". + return (false, -1, string.Empty); + } + } +} diff --git a/src/EchoHub.Client/Services/OutgoingAttachment.cs b/src/EchoHub.Client/Services/OutgoingAttachment.cs new file mode 100644 index 0000000..875fdf2 --- /dev/null +++ b/src/EchoHub.Client/Services/OutgoingAttachment.cs @@ -0,0 +1,13 @@ +namespace EchoHub.Client.Services; + +/// +/// One file to upload as part of a message. For end-to-end encrypted channels the stream +/// is already ciphertext, is set (image/audio/file), and +/// holds the room-encrypted ASCII art for images. +/// For normal channels only and are set. +/// +public sealed record OutgoingAttachment( + Stream Stream, + string FileName, + string? DeclaredKind = null, + string? EncryptedPreview = null); diff --git a/src/EchoHub.Client/UI/Chat/ChatLine.cs b/src/EchoHub.Client/UI/Chat/ChatLine.cs index 9f67afc..92120a2 100644 --- a/src/EchoHub.Client/UI/Chat/ChatLine.cs +++ b/src/EchoHub.Client/UI/Chat/ChatLine.cs @@ -18,7 +18,7 @@ public partial class ChatLine public bool IsMention { get; set; } public string? AttachmentUrl { get; set; } public string? AttachmentFileName { get; set; } - public MessageType? Type { get; set; } + public AttachmentKind? AttachmentKind { get; set; } public string? SenderUsername { get; set; } /// Number of spaces to prepend on continuation lines when this line is word-wrapped. public int ContinuationIndent { get; set; } @@ -124,7 +124,7 @@ public partial class ChatLine { wrapped.AttachmentUrl = AttachmentUrl; wrapped.AttachmentFileName = AttachmentFileName; - wrapped.Type = Type; + wrapped.AttachmentKind = AttachmentKind; wrapped.MessageId = MessageId; wrapped.SenderUsername = SenderUsername; } diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index fdda23e..4e66790 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -238,86 +238,77 @@ public sealed class ChatMessageManager var senderName = message.SenderUsername + ":"; var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor); + var indent = new string(' ', $"[{time}] {senderName} ".Length); + var pad = new string(' ', 7); + var lines = new List(); + var hasContent = !string.IsNullOrWhiteSpace(message.Content); + var attachments = message.Attachments ?? []; - switch (message.Type) + // Header line: caption text, or a summary when the message is attachments-only + if (hasContent) { - case MessageType.Image: - lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]")); - if (!string.IsNullOrWhiteSpace(message.Content)) - { - foreach (var artLine in message.Content.Split('\n')) + var displayContent = EmojiHelper.ReplaceEmoji(message.Content); + var contentLines = displayContent.Split('\n'); + lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {contentLines[0].TrimEnd('\r')}")); + for (int i = 1; i < contentLines.Length; i++) + lines.Add(new ChatLine(ChatColors.SplitMentions($"{indent}{contentLines[i].TrimEnd('\r')}"))); + } + else + { + var summary = attachments.Count switch + { + 0 => " ", + 1 => $" [{attachments[0].Kind.ToString().ToLowerInvariant()}]", + _ => $" [{attachments.Count} attachments]", + }; + lines.Add(BuildChatLine(time, senderName, senderColor, summary)); + } + + foreach (var l in lines) + l.ContinuationIndent = indent.Length; + + // One block per attachment + foreach (var attachment in attachments) + { + switch (attachment.Kind) + { + case Core.Models.AttachmentKind.Image: + if (!string.IsNullOrWhiteSpace(attachment.AsciiPreview)) { - var trimmed = artLine.TrimEnd('\r'); - if (ChatLine.HasColorTags(trimmed)) - lines.Add(ChatLine.FromColoredText(" " + trimmed)); - else - lines.Add(new ChatLine($" {trimmed}")); + foreach (var artLine in attachment.AsciiPreview.Split('\n')) + { + var trimmed = artLine.TrimEnd('\r'); + lines.Add(ChatLine.HasColorTags(trimmed) + ? ChatLine.FromColoredText(pad + trimmed) + : new ChatLine($"{pad}{trimmed}")); + } } - } + lines.Add(AttachmentActionLine(pad, + $"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", + ChatColors.FileAttr, attachment)); + break; - // Clickable action to download the original image below the ASCII art - if (message.AttachmentUrl is not null) - { - var imageName = message.AttachmentFileName ?? "image"; - var imageSize = FormatFileSize(message.AttachmentFileSize); - var saveLine = new ChatLine(new List - { - new(" ", null), - new($"[↓ save original] {imageName} [{imageSize}]", ChatColors.FileAttr), - }); - saveLine.AttachmentUrl = message.AttachmentUrl; - saveLine.AttachmentFileName = imageName; - saveLine.Type = MessageType.Image; - lines.Add(saveLine); - } - break; + case Core.Models.AttachmentKind.Audio: + lines.Add(AttachmentActionLine(pad, + $"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]", + ChatColors.AudioAttr, attachment)); + break; - case MessageType.Audio: - var audioName = message.AttachmentFileName ?? "unknown"; - var audioSize = FormatFileSize(message.AttachmentFileSize); - var audioLine = BuildChatLineColored(time, senderName, senderColor, - $" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr); - audioLine.AttachmentUrl = message.AttachmentUrl; - audioLine.AttachmentFileName = audioName; - audioLine.Type = MessageType.Audio; - lines.Add(audioLine); - break; + default: + lines.Add(AttachmentActionLine(pad, + $"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]", + ChatColors.FileAttr, attachment)); + break; + } + } - case MessageType.File: - var fileName = message.AttachmentFileName ?? "unknown"; - var fileSize = FormatFileSize(message.AttachmentFileSize); - var fileLine = BuildChatLineColored(time, senderName, senderColor, - $" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr); - fileLine.AttachmentUrl = message.AttachmentUrl; - fileLine.AttachmentFileName = fileName; - fileLine.Type = MessageType.File; - lines.Add(fileLine); - break; - - case MessageType.Text: - default: - var displayContent = EmojiHelper.ReplaceEmoji(message.Content); - var contentLines = displayContent.Split('\n'); - var firstLine = contentLines[0].TrimEnd('\r'); - lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}")); - var indent = new string(' ', $"[{time}] {senderName} ".Length); - for (int i = 1; i < contentLines.Length; i++) - { - var contText = $"{indent}{contentLines[i].TrimEnd('\r')}"; - lines.Add(new ChatLine(ChatColors.SplitMentions(contText))); - } - - foreach (var l in lines) - l.ContinuationIndent = indent.Length; - - if (message.Embeds is { Count: > 0 }) - { - var chatWidth = _chatWidth > 0 ? _chatWidth : 80; - foreach (var embed in message.Embeds) - lines.AddRange(FormatEmbed(embed, indent, chatWidth)); - } - break; + // Link embeds (from caption URLs) + if (message.Embeds is { Count: > 0 }) + { + var chatWidth = _chatWidth > 0 ? _chatWidth : 80; + foreach (var embed in message.Embeds) + lines.AddRange(FormatEmbed(embed, indent, chatWidth)); } foreach (var line in lines) @@ -326,7 +317,7 @@ public sealed class ChatMessageManager line.SenderUsername = message.SenderUsername; } - if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text) + if (hasContent && !string.IsNullOrEmpty(_currentUser)) { var pattern = $@"@{Regex.Escape(_currentUser)}\b"; if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase)) @@ -339,6 +330,23 @@ public sealed class ChatMessageManager return lines; } + /// + /// Builds a clickable attachment line carrying the metadata the message list uses to + /// route activation (play audio, download file, save original image). + /// + private static ChatLine AttachmentActionLine(string pad, string text, Attribute color, AttachmentDto attachment) + { + var line = new ChatLine(new List + { + new(pad, null), + new(text, color), + }); + line.AttachmentUrl = attachment.Url; + line.AttachmentFileName = attachment.FileName; + line.AttachmentKind = attachment.Kind; + return line; + } + private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix) { var segments = new List diff --git a/src/EchoHub.Client/UI/Helpers/DroppedFileParser.cs b/src/EchoHub.Client/UI/Helpers/DroppedFileParser.cs new file mode 100644 index 0000000..cff381a --- /dev/null +++ b/src/EchoHub.Client/UI/Helpers/DroppedFileParser.cs @@ -0,0 +1,104 @@ +using System.Text; + +namespace EchoHub.Client.UI.Helpers; + +/// +/// Recognizes a dragged-and-dropped file (or files) that a terminal delivers into the input as an +/// absolute path. Terminals differ: some paste the whole path at once, others send it character by +/// character; either way this checks whether the current input text resolves to existing file(s). +/// +public static class DroppedFileParser +{ + /// + /// Cheap pre-check so callers only stat the filesystem when the input plausibly holds a path: + /// a quoted path, a Windows drive path (X:\/X:/), a UNC path (\\), or a + /// POSIX absolute path (/). Normal chat text never starts this way. + /// + public static bool LooksLikePath(string text) + { + var t = text.TrimStart(); + if (t.Length < 3) + return false; + if (t[0] is '"' or '/') + return true; + if (t.StartsWith(@"\\", StringComparison.Ordinal)) + return true; + return char.IsLetter(t[0]) && t[1] == ':' && (t[2] == '\\' || t[2] == '/'); + } + + /// + /// Returns true when resolves to one or more existing files. + /// Handles a single path (quoted or not, possibly containing spaces) and multiple + /// space-separated (optionally quoted) paths. is injectable + /// for testing; production passes . + /// + public static bool TryGetFiles(string text, out List files, Func? fileExists = null) + { + fileExists ??= File.Exists; + files = []; + + var trimmed = text.Trim(); + if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n')) + return false; + + // Single path, possibly quoted and/or containing spaces. + var unquoted = StripQuotes(trimmed); + if (Path.IsPathFullyQualified(unquoted) && fileExists(unquoted)) + { + files.Add(unquoted); + return true; + } + + // Multiple files: space-separated tokens, each optionally quoted. + foreach (var token in TokenizeQuoted(trimmed)) + { + if (!Path.IsPathFullyQualified(token) || !fileExists(token)) + { + files.Clear(); + return false; + } + files.Add(token); + } + + return files.Count > 0; + } + + private static string StripQuotes(string s) => + s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\'')) + ? s[1..^1] + : s; + + private static IEnumerable TokenizeQuoted(string input) + { + var current = new 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(); + } +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index d6cf1de..1be8d70 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -40,7 +40,9 @@ public sealed partial class MainWindow : Runnable private readonly UserListSource _usersListSource; private bool _usersPanelVisible = true; private const int UsersPanelWidth = 22; + private const string DefaultInputTitle = "Message │ Enter=send │ Ctrl+N=newline │ Tab=complete │ Ctrl+K=search"; private static readonly Key F2Key = Key.F2; + private bool _hasStagedAttachments; internal static readonly string AppVersion = typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?"; @@ -60,7 +62,7 @@ public sealed partial class MainWindow : Runnable private static readonly string[] SlashCommands = [ "/status", "/nick", "/color", "/theme", "/send", - "/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", + "/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/downloadpath", "/topic", "/users", "/kick", "/ban", "/unban", "/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help" ]; @@ -159,6 +161,11 @@ public sealed partial class MainWindow : Runnable /// public event Action? OnImageSaveRequested; + /// + /// Fired when the user presses Delete on the selected message. Parameter is the message id. + /// + public event Action? OnDeleteMessageRequested; + /// /// Fired when the user activates a username (in userlist or message). Parameter is the username. /// @@ -240,6 +247,7 @@ public sealed partial class MainWindow : Runnable }; _messageList.Source = new ChatListSource(); _messageList.Accepting += OnMessageListAccepting; + _messageList.KeyDown += OnMessageListKeyDown; _messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled; _messageList.VerticalScrollBar.Visible = true; @@ -249,7 +257,7 @@ public sealed partial class MainWindow : Runnable // Bottom input area _inputFrame = new FrameView { - Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search", + Title = DefaultInputTitle, X = 22, Y = Pos.Bottom(_chatFrame), Width = Dim.Fill(UsersPanelWidth), @@ -321,6 +329,27 @@ public sealed partial class MainWindow : Runnable KeyDown += OnWindowKeyDown; } + /// + /// Updates the attachment staging indicator shown on the input frame's title. + /// Passing an empty list restores the default hint. + /// + public void SetStagedAttachments(IReadOnlyList fileNames) + { + _hasStagedAttachments = fileNames.Count > 0; + if (fileNames.Count == 0) + { + _inputFrame.Title = DefaultInputTitle; + } + else + { + var names = string.Join(", ", fileNames); + if (names.Length > 60) + names = names[..57] + "..."; + _inputFrame.Title = $"📎 {fileNames.Count} staged: {names} │ Enter=send │ /clear to drop"; + } + _inputFrame.SetNeedsDraw(); + } + /// /// Applies the currently registered color schemes to all views. /// Call after theme changes to refresh colors. @@ -458,21 +487,21 @@ public sealed partial class MainWindow : Runnable // Audio/file attachments take priority if (line.AttachmentUrl is not null && line.AttachmentFileName is not null) { - if (line.Type == MessageType.Audio) + if (line.AttachmentKind == AttachmentKind.Audio) { OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); e.Handled = true; return; } - if (line.Type == MessageType.File) + if (line.AttachmentKind == AttachmentKind.File) { OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); e.Handled = true; return; } - if (line.Type == MessageType.Image) + if (line.AttachmentKind == AttachmentKind.Image) { OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); e.Handled = true; @@ -510,6 +539,32 @@ public sealed partial class MainWindow : Runnable } } + private void OnMessageListKeyDown(object? sender, Key e) + { + if (e.KeyCode != Key.Delete.KeyCode && e.KeyCode != Key.Backspace.KeyCode) + return; + + if (_messageList.Source is not ChatListSource source) + return; + + var index = _messageList.SelectedItem; + if (!index.HasValue || index.Value < 0 || index.Value >= source.Count) + return; + + var line = source.GetLine(index.Value); + if (line?.MessageId is not { } messageId) + return; + + // Server enforces the real permission (own message, or Mod+ over a lower role); + // the client just confirms intent and lets the server reject if disallowed. + var confirm = MessageBox.Query(_app, "Delete Message", + "Delete this message?", "Delete", "Cancel"); + if (confirm == 0) + OnDeleteMessageRequested?.Invoke(messageId); + + e.Handled = true; + } + private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs e) { if (_messageList.VerticalScrollBar.Value == 0) @@ -545,7 +600,9 @@ public sealed partial class MainWindow : Runnable else if (e.KeyCode == EnterKey.KeyCode) { var text = _inputField.Text?.Trim() ?? string.Empty; - if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_messageManager.CurrentChannel)) + // Send when there's text, or when only attachments are staged (empty caption). + if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments) + && !string.IsNullOrEmpty(_messageManager.CurrentChannel)) { OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text); _inputField.Text = string.Empty; @@ -564,9 +621,13 @@ public sealed partial class MainWindow : Runnable } 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"); + // If a file was copied in the OS file manager, the clipboard holds a file list + // (not text) — attach it. Otherwise paste text. This is the reliable path on + // Windows Terminal, which never pastes copied files as text. + if (ClipboardFiles.TryGetFiles(out var pastedFiles)) + StageFiles(pastedFiles); + else + GuardedClipboardAction(() => _inputField.Paste(), "paste"); e.Handled = true; } else if (e.KeyCode == CtrlXKey.KeyCode) @@ -599,42 +660,35 @@ public sealed partial class MainWindow : Runnable } 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)) + // A file dropped onto the terminal is delivered as its absolute path inserted into the + // input — often character by character (this Terminal.Gui build has no bracketed-paste + // coalescing). As soon as the input resolves to existing file path(s), route them + // through /send (which stages them) instead of leaving a raw path to be sent as a message. + if (DroppedFileParser.LooksLikePath(text) && DroppedFileParser.TryGetFiles(text, out var droppedFiles) + && !string.IsNullOrEmpty(_messageManager.CurrentChannel)) { - var channel = _messageManager.CurrentChannel; - if (!string.IsNullOrEmpty(channel)) + _suppressEmojiReplace = true; + try { - _suppressEmojiReplace = true; - try - { - _inputField.Text = string.Empty; - } - finally - { - _suppressEmojiReplace = false; - } - - foreach (var file in droppedFiles) - OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\""); - return; + _inputField.Text = string.Empty; } + finally + { + _suppressEmojiReplace = false; + } + + StageFiles(droppedFiles); + return; } var replaced = EmojiHelper.ReplaceEmoji(text); @@ -695,77 +749,17 @@ public sealed partial class MainWindow : Runnable } /// - /// 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. + /// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which + /// stages them; the next Enter sends them with any typed caption. /// - private static bool TryGetDroppedFiles(string text, out List files) + private void StageFiles(IEnumerable files) { - files = []; + var channel = _messageManager.CurrentChannel; + if (string.IsNullOrEmpty(channel)) + return; - var trimmed = text.Trim(); - if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n')) - return false; - - // Single unquoted path, possibly with spaces (e.g. WSL or plain conhost drops) - var unquoted = StripQuotes(trimmed); - if (Path.IsPathFullyQualified(unquoted) && File.Exists(unquoted)) - { - files.Add(unquoted); - return true; - } - - // Multiple files: space-separated tokens, each optionally quoted - foreach (var token in TokenizeQuoted(trimmed)) - { - if (!Path.IsPathFullyQualified(token) || !File.Exists(token)) - { - files.Clear(); - return false; - } - files.Add(token); - } - - return files.Count > 0; - } - - private static string StripQuotes(string s) => - s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\'')) - ? s[1..^1] - : s; - - private static IEnumerable TokenizeQuoted(string input) - { - var current = new System.Text.StringBuilder(); - var quote = '\0'; - - foreach (var c in input) - { - if (quote != '\0') - { - if (c == quote) quote = '\0'; - else current.Append(c); - } - else if (c is '"' or '\'') - { - quote = c; - } - else if (c == ' ') - { - if (current.Length > 0) - { - yield return current.ToString(); - current.Clear(); - } - } - else - { - current.Append(c); - } - } - - if (current.Length > 0) - yield return current.ToString(); + foreach (var file in files) + OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\""); } private void OnChatViewportChanged() diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index de149bf..a7ead47 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -11,6 +11,7 @@ public static class HubConstants public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB public const int MaxMessageNewlines = 30; + public const int MaxAttachmentsPerMessage = 10; public const int MaxConsecutiveNewlines = 1; public const int AsciiArtWidth = 80; public const int AsciiArtHeight = 40; diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 02a1a4b..cd64cea 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -8,13 +8,22 @@ public record MessageDto( string SenderUsername, string? SenderNicknameColor, string ChannelName, - MessageType Type, - string? AttachmentUrl, - string? AttachmentFileName, DateTimeOffset SentAt, - long? AttachmentFileSize = null, + List? Attachments = null, List? Embeds = null); +/// +/// A file attached to a message. holds the color-tag art for +/// images (null otherwise). For end-to-end encrypted channels the content behind +/// and the preview are ciphertext the server cannot read. +/// +public record AttachmentDto( + AttachmentKind Kind, + string Url, + string FileName, + long FileSize, + string? AsciiPreview = null); + public record ChannelDto( Guid Id, string Name, diff --git a/src/EchoHub.Core/Models/Attachment.cs b/src/EchoHub.Core/Models/Attachment.cs new file mode 100644 index 0000000..a0f8bf4 --- /dev/null +++ b/src/EchoHub.Core/Models/Attachment.cs @@ -0,0 +1,28 @@ +namespace EchoHub.Core.Models; + +/// +/// A file attached to a message (image, audio, or any file). A message may carry +/// zero or more attachments alongside its text content (Discord-style). +/// +public class Attachment +{ + public Guid Id { get; set; } + public Guid MessageId { get; set; } + public Message? Message { get; set; } + + public AttachmentKind Kind { get; set; } + + /// Relative download URL, e.g. /api/files/{fileId}. + public required string Url { get; set; } + public required string FileName { get; set; } + + /// Stored blob size in bytes (ciphertext size for encrypted channels). + public long FileSize { get; set; } + + /// + /// Rendered ASCII-art preview for images (color-tag format). Null for audio/files. + /// Stored encrypted-at-rest when database encryption is enabled, and room-encrypted + /// for end-to-end encrypted channels. + /// + public string? AsciiPreview { get; set; } +} diff --git a/src/EchoHub.Core/Models/AttachmentKind.cs b/src/EchoHub.Core/Models/AttachmentKind.cs new file mode 100644 index 0000000..a618a45 --- /dev/null +++ b/src/EchoHub.Core/Models/AttachmentKind.cs @@ -0,0 +1,12 @@ +namespace EchoHub.Core.Models; + +/// +/// The kind of a message attachment. Determines how the client renders it +/// (ASCII preview for images, a play affordance for audio, a download line for files). +/// +public enum AttachmentKind +{ + Image, + Audio, + File +} diff --git a/src/EchoHub.Core/Models/Message.cs b/src/EchoHub.Core/Models/Message.cs index a574b16..efadc7e 100644 --- a/src/EchoHub.Core/Models/Message.cs +++ b/src/EchoHub.Core/Models/Message.cs @@ -3,11 +3,10 @@ namespace EchoHub.Core.Models; public class Message { public Guid Id { get; set; } + + /// The message text/caption. May be empty when the message only carries attachments. public required string Content { get; set; } - public MessageType Type { get; set; } = MessageType.Text; - public string? AttachmentUrl { get; set; } - public string? AttachmentFileName { get; set; } - public long? AttachmentFileSize { get; set; } + public string? EmbedJson { get; set; } public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; @@ -16,4 +15,16 @@ public class Message public Guid SenderUserId { get; set; } public required string SenderUsername { get; set; } + + /// Files attached to this message. Empty for a plain text message. + public List Attachments { get; set; } = []; + + // ── Legacy columns (pre-attachments model) ────────────────────────────── + // Retained so the one-time startup data migration can fold old single-attachment + // messages into Attachments. New code never writes these; they are nulled out + // once migrated. Not exposed in DTOs. See DataMigrationService.MigrateLegacyAttachmentsAsync. + public MessageType Type { get; set; } = MessageType.Text; + public string? AttachmentUrl { get; set; } + public string? AttachmentFileName { get; set; } + public long? AttachmentFileSize { get; set; } } diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index c1a91ab..62e39cc 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -18,40 +18,49 @@ public static partial class IrcMessageFormatter var ircChannel = $"#{message.ChannelName}"; var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub"; - switch (message.Type) + // Caption text first (may be empty when the message is attachments-only) + if (!string.IsNullOrEmpty(message.Content)) { - case MessageType.Text: - foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes)) - lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); + foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes)) + lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); + } - // Append embed previews if present - if (message.Embeds is { Count: > 0 }) + // One block per attachment + if (message.Attachments is { Count: > 0 }) + { + foreach (var attachment in message.Attachments) + { + switch (attachment.Kind) { - foreach (var embed in message.Embeds) - lines.AddRange(FormatEmbed(prefix, ircChannel, embed)); + case AttachmentKind.Image: + lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}"); + if (attachment.AsciiPreview is not null) + { + foreach (var line in attachment.AsciiPreview.Split('\n')) + { + var trimmed = line.TrimEnd('\r'); + if (trimmed.Length > 0) + lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}"); + } + } + break; + + case AttachmentKind.Audio: + lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {attachment.FileName}] {attachment.Url}"); + break; + + default: + lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {attachment.FileName}] {attachment.Url}"); + break; } - break; + } + } - case MessageType.Image: - lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {message.AttachmentFileName}]"); - if (message.AttachmentUrl is not null) - lines.Add($"{prefix} PRIVMSG {ircChannel} :Download: {message.AttachmentUrl}"); - - foreach (var line in message.Content.Split('\n')) - { - var trimmed = line.TrimEnd('\r'); - if (trimmed.Length > 0) - lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}"); - } - break; - - case MessageType.File: - lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}"); - break; - - case MessageType.Audio: - lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}"); - break; + // Append embed previews if present + if (message.Embeds is { Count: > 0 }) + { + foreach (var embed in message.Embeds) + lines.AddRange(FormatEmbed(prefix, ircChannel, embed)); } return lines; diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index c9b4088..b83188e 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; +using EchoHub.Core.Security; using EchoHub.Core.Services; using EchoHub.Core.DTOs; using EchoHub.Core.Models; @@ -144,11 +145,18 @@ public class ChannelsController : ControllerBase return NoContent(); } - [HttpPost("{channel}/upload")] + /// + /// Sends one message carrying optional text (content form field) plus zero or more + /// file attachments (Discord-style). For non-encrypted channels the server sniffs each file's + /// kind and renders ASCII previews for images. For end-to-end encrypted channels the client + /// uploads ciphertext blobs and declares each file's kind (kind) and pre-rendered, + /// room-encrypted preview (preview), aligned by file order — the server never inspects them. + /// + [HttpPost("{channel}/messages")] [EnableRateLimiting("upload")] - [RequestSizeLimit(HubConstants.MaxFileSizeBytes)] - [RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)] - public async Task Upload(string channel, [FromQuery] string? size = null) + [RequestSizeLimit((long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)] + [RequestFormLimits(MultipartBodyLengthLimit = (long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)] + public async Task SendMessageWithAttachments(string channel, [FromQuery] string? size = null) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var usernameClaim = User.FindFirstValue("username"); @@ -165,114 +173,136 @@ public class ChannelsController : ControllerBase if (channelDto is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); - if (!Request.HasFormContentType || Request.Form.Files.Count == 0) - return BadRequest(new ErrorResponse("No file uploaded.")); + if (!Request.HasFormContentType) + return BadRequest(new ErrorResponse("Expected multipart form data.")); - var file = Request.Form.Files[0]; + var files = Request.Form.Files; + if (files.Count == 0) + return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection.")); + if (files.Count > HubConstants.MaxAttachmentsPerMessage) + return BadRequest(new ErrorResponse($"A message may carry at most {HubConstants.MaxAttachmentsPerMessage} attachments.")); - MessageType messageType; - string content; - string fileId; + var sender = await _db.Users.FindAsync(userId); + if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow)) + return StatusCode(403, new ErrorResponse("You are muted and cannot send messages.")); - if (channelDto.IsEncrypted) + // Caption: plaintext for normal channels, $RC1$ room-ciphertext for encrypted ones. + // Decrypt() is a pass-through when there is no transport prefix. + var content = _encryption.Decrypt(Request.Form["content"].ToString()); + var isRoomCiphertext = RoomCrypto.IsRoomCiphertext(content); + if (!isRoomCiphertext && content.Length > HubConstants.MaxMessageLength) + return BadRequest(new ErrorResponse($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.")); + + var declaredKinds = Request.Form["kind"]; + var declaredPreviews = Request.Form["preview"]; + + var attachmentEntities = new List(); + var attachmentDtos = new List(); + + for (var i = 0; i < files.Count; i++) { - // E2E-encrypted channel: the blob is ciphertext the server cannot inspect. - // The client declares the type and supplies pre-rendered, room-encrypted - // content (ASCII art for images, encrypted filename otherwise). - messageType = Request.Form["type"].ToString().ToLowerInvariant() switch + var file = files[i]; + AttachmentKind kind; + string? previewPlain; + string fileId; + + if (channelDto.IsEncrypted) { - "image" => MessageType.Image, - "audio" => MessageType.Audio, - _ => MessageType.File, - }; + // Ciphertext blob — trust the client's declared kind + room-encrypted preview. + // Client sends one kind + preview per file in order; empty preview means none. + kind = ParseKind(i < declaredKinds.Count ? declaredKinds[i] : null); + previewPlain = i < declaredPreviews.Count ? declaredPreviews[i] : null; + if (string.IsNullOrEmpty(previewPlain)) + previewPlain = null; - var declaredMax = messageType switch - { - MessageType.Image => HubConstants.MaxImageSizeBytes, - MessageType.Audio => HubConstants.MaxAudioFileSizeBytes, - _ => HubConstants.MaxFileSizeBytes, - }; - if (file.Length > declaredMax) - return BadRequest(new ErrorResponse($"File size exceeds maximum of {declaredMax / (1024 * 1024)} MB.")); + if (file.Length > MaxForKind(kind)) + return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size.")); - var clientContent = Request.Form["content"].ToString(); - content = string.IsNullOrEmpty(clientContent) ? file.FileName : clientContent; - - using var encryptedStream = file.OpenReadStream(); - (fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName); - } - else - { - // Detect file type early so we can apply the correct size limit - using var stream = file.OpenReadStream(); - var isImage = FileValidationHelper.IsValidImage(stream); - var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName); - - var maxSize = isImage ? HubConstants.MaxImageSizeBytes - : isAudio ? HubConstants.MaxAudioFileSizeBytes - : HubConstants.MaxFileSizeBytes; - - if (file.Length > maxSize) - return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB.")); - - string filePath; - (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); - - messageType = isImage ? MessageType.Image - : isAudio ? MessageType.Audio - : MessageType.File; - - if (isImage) - { - var (w, h) = ImageToAsciiService.GetDimensions(size); - using var imageStream = System.IO.File.OpenRead(filePath); - content = _asciiService.ConvertToAscii(imageStream, w, h); + using var encryptedStream = file.OpenReadStream(); + (fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName); } else { - content = file.FileName; + using var stream = file.OpenReadStream(); + var isImage = FileValidationHelper.IsValidImage(stream); + var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName); + kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File; + + if (file.Length > MaxForKind(kind)) + return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {MaxForKind(kind) / (1024 * 1024)} MB.")); + + string filePath; + (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); + + if (isImage) + { + var (w, h) = ImageToAsciiService.GetDimensions(size); + using var imageStream = System.IO.File.OpenRead(filePath); + previewPlain = _asciiService.ConvertToAscii(imageStream, w, h); + } + else + { + previewPlain = null; + } } + + var url = $"/api/files/{fileId}"; + attachmentEntities.Add(new Attachment + { + Id = Guid.NewGuid(), + Kind = kind, + Url = url, + FileName = file.FileName, + FileSize = file.Length, + AsciiPreview = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(previewPlain) : previewPlain, + }); + attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length, + _encryption.EncryptNullable(previewPlain))); } - var attachmentUrl = $"/api/files/{fileId}"; - var sender = await _db.Users.FindAsync(userId); var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content; - var message = new Message { Id = Guid.NewGuid(), Content = dbContent, - Type = messageType, - AttachmentUrl = attachmentUrl, - AttachmentFileName = file.FileName, - AttachmentFileSize = file.Length, SentAt = DateTimeOffset.UtcNow, ChannelId = channelDto.Id, SenderUserId = userId, SenderUsername = usernameClaim, + Attachments = attachmentEntities, }; _db.Messages.Add(message); await _db.SaveChangesAsync(); - // Encrypt for transport — clients decrypt var messageDto = new MessageDto( message.Id, _encryption.Encrypt(content), message.SenderUsername, sender?.NicknameColor, channelName, - messageType, - attachmentUrl, - file.FileName, message.SentAt, - file.Length); + attachmentDtos); await _chatService.BroadcastMessageAsync(channelName, messageDto); return Ok(messageDto); } + private static AttachmentKind ParseKind(string? kind) => kind?.ToLowerInvariant() switch + { + "image" => AttachmentKind.Image, + "audio" => AttachmentKind.Audio, + _ => AttachmentKind.File, + }; + + private static long MaxForKind(AttachmentKind kind) => kind switch + { + AttachmentKind.Image => HubConstants.MaxImageSizeBytes, + AttachmentKind.Audio => HubConstants.MaxAudioFileSizeBytes, + _ => HubConstants.MaxFileSizeBytes, + }; + [HttpPost("{channel}/send-url")] [EnableRateLimiting("upload")] public async Task SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null) @@ -353,46 +383,49 @@ public class ChannelsController : ControllerBase // Save file and convert to ASCII var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName); - string content; + string preview; var (w, h) = ImageToAsciiService.GetDimensions(size); using (var imageStream = System.IO.File.OpenRead(filePath)) { - content = _asciiService.ConvertToAscii(imageStream, w, h); + preview = _asciiService.ConvertToAscii(imageStream, w, h); } var attachmentUrl = $"/api/files/{fileId}"; var sender = await _db.Users.FindAsync(userId); - var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content; + + // A URL-shared image is a message with no caption and one image attachment. + var attachment = new Attachment + { + Id = Guid.NewGuid(), + Kind = AttachmentKind.Image, + Url = attachmentUrl, + FileName = fileName, + FileSize = imageBytes.Length, + AsciiPreview = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(preview) : preview, + }; var message = new Message { Id = Guid.NewGuid(), - Content = dbContent, - Type = MessageType.Image, - AttachmentUrl = attachmentUrl, - AttachmentFileName = fileName, - AttachmentFileSize = imageBytes.Length, + Content = string.Empty, SentAt = DateTimeOffset.UtcNow, ChannelId = channelDto.Id, SenderUserId = userId, SenderUsername = usernameClaim, + Attachments = [attachment], }; _db.Messages.Add(message); await _db.SaveChangesAsync(); - // Encrypt for transport — clients decrypt var messageDto = new MessageDto( message.Id, - _encryption.Encrypt(content), + _encryption.Encrypt(string.Empty), message.SenderUsername, sender?.NicknameColor, channelName, - MessageType.Image, - attachmentUrl, - fileName, message.SentAt, - imageBytes.Length); + [new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))]); await _chatService.BroadcastMessageAsync(channelName, messageDto); diff --git a/src/EchoHub.Server/Controllers/ModerationController.cs b/src/EchoHub.Server/Controllers/ModerationController.cs index b048d12..d6de0d4 100644 --- a/src/EchoHub.Server/Controllers/ModerationController.cs +++ b/src/EchoHub.Server/Controllers/ModerationController.cs @@ -20,17 +20,20 @@ public class ModerationController : ControllerBase private readonly EchoHubDbContext _db; private readonly IChatService _chatService; private readonly PresenceTracker _presenceTracker; + private readonly FileStorageService _fileStorage; private readonly IEnumerable _broadcasters; public ModerationController( EchoHubDbContext db, IChatService chatService, PresenceTracker presenceTracker, + FileStorageService fileStorage, IEnumerable broadcasters) { _db = db; _chatService = chatService; _presenceTracker = presenceTracker; + _fileStorage = fileStorage; _broadcasters = broadcasters; } @@ -170,17 +173,47 @@ public class ModerationController : ControllerBase [HttpDelete("messages/{messageId:guid}")] public async Task DeleteMessage(Guid messageId) { - var (_, error) = await GetCallerAsync(ServerRole.Mod); - if (error is not null) return error; + // Any authenticated user may reach this; permission depends on authorship + role hierarchy. + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Unauthorized(new ErrorResponse("Authentication required.")); + + var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim)); + if (caller is null) + return Unauthorized(new ErrorResponse("User not found.")); var message = await _db.Messages .Include(m => m.Channel) + .Include(m => m.Attachments) .FirstOrDefaultAsync(m => m.Id == messageId); if (message is null) return NotFound(new ErrorResponse("Message not found.")); + var isOwnMessage = message.SenderUserId == caller.Id; + if (!isOwnMessage) + { + // Deleting someone else's message requires Mod+ AND a strictly higher role than + // the message author (so a mod can't delete an admin's/owner's message). + if (caller.Role < ServerRole.Mod) + return StatusCode(403, new ErrorResponse("You can only delete your own messages.")); + + var author = await _db.Users.FindAsync(message.SenderUserId); + var authorRole = author?.Role ?? ServerRole.Member; + if (authorRole >= caller.Role) + return StatusCode(403, new ErrorResponse("You cannot delete a message from a user with an equal or higher role.")); + } + var channelName = message.Channel!.Name; + + // Remove attachment blobs from disk before the DB rows cascade away. + foreach (var attachment in message.Attachments) + { + var fileId = attachment.Url.Split('/').LastOrDefault(); + if (!string.IsNullOrEmpty(fileId)) + _fileStorage.DeleteFile(fileId); + } + _db.Messages.Remove(message); await _db.SaveChangesAsync(); @@ -200,7 +233,19 @@ public class ModerationController : ControllerBase if (dbChannel is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); - var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync(); + var messages = await _db.Messages + .Where(m => m.ChannelId == dbChannel.Id) + .Include(m => m.Attachments) + .ToListAsync(); + + foreach (var fileId in messages + .SelectMany(m => m.Attachments) + .Select(a => a.Url.Split('/').LastOrDefault()) + .Where(id => !string.IsNullOrEmpty(id))) + { + _fileStorage.DeleteFile(fileId!); + } + _db.Messages.RemoveRange(messages); await _db.SaveChangesAsync(); diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index 3039dbd..17949cf 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -10,6 +10,7 @@ public class EchoHubDbContext : DbContext public DbSet Users => Set(); public DbSet Channels => Set(); public DbSet Messages => Set(); + public DbSet Attachments => Set(); public DbSet RefreshTokens => Set(); public DbSet ChannelMemberships => Set(); @@ -63,6 +64,21 @@ public class EchoHubDbContext : DbContext entity.Property(m => m.AttachmentUrl).HasMaxLength(500); entity.Property(m => m.AttachmentFileName).HasMaxLength(255); entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON + + entity.HasMany(m => m.Attachments) + .WithOne(a => a.Message) + .HasForeignKey(a => a.MessageId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(a => a.Id); + entity.HasIndex(a => a.MessageId); + entity.Property(a => a.Kind).HasConversion(); + entity.Property(a => a.Url).IsRequired().HasMaxLength(500); + entity.Property(a => a.FileName).IsRequired().HasMaxLength(255); + entity.Property(a => a.AsciiPreview).HasMaxLength(64000); // color-tag ASCII art, encrypted-at-rest overhead }); modelBuilder.Entity(entity => diff --git a/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.Designer.cs new file mode 100644 index 0000000..8a04227 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.Designer.cs @@ -0,0 +1,331 @@ +// +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("20260716020211_AddMessageAttachments")] + partial class AddMessageAttachments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AsciiPreview") + .HasMaxLength(64000) + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("EncryptionSalt") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WrappedRoomKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Attachment", b => + { + b.HasOne("EchoHub.Core.Models.Message", "Message") + .WithMany("Attachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + 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"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.cs b/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.cs new file mode 100644 index 0000000..ed1cdca --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddMessageAttachments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Attachments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + MessageId = table.Column(type: "TEXT", nullable: false), + Kind = table.Column(type: "INTEGER", nullable: false), + Url = table.Column(type: "TEXT", maxLength: 500, nullable: false), + FileName = table.Column(type: "TEXT", maxLength: 255, nullable: false), + FileSize = table.Column(type: "INTEGER", nullable: false), + AsciiPreview = table.Column(type: "TEXT", maxLength: 64000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Attachments", x => x.Id); + table.ForeignKey( + name: "FK_Attachments_Messages_MessageId", + column: x => x.MessageId, + principalTable: "Messages", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Attachments_MessageId", + table: "Attachments", + column: "MessageId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Attachments"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 0cba3e3..2a81502 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -17,6 +17,42 @@ namespace EchoHub.Server.Data.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + modelBuilder.Entity("EchoHub.Core.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AsciiPreview") + .HasMaxLength(64000) + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.ToTable("Attachments"); + }); + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => { b.Property("Id") @@ -229,6 +265,17 @@ namespace EchoHub.Server.Data.Migrations b.ToTable("Users"); }); + modelBuilder.Entity("EchoHub.Core.Models.Attachment", b => + { + b.HasOne("EchoHub.Core.Models.Message", "Message") + .WithMany("Attachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => { b.HasOne("EchoHub.Core.Models.Channel", null) @@ -270,6 +317,11 @@ namespace EchoHub.Server.Data.Migrations { b.Navigation("Messages"); }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Navigation("Attachments"); + }); #pragma warning restore 612, 618 } } diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 91e7999..4a5badf 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -214,7 +214,6 @@ public class ChatService : IChatService { Id = Guid.NewGuid(), Content = dbContent, - Type = MessageType.Text, SentAt = DateTimeOffset.UtcNow, ChannelId = channel.Id, SenderUserId = userId, @@ -233,9 +232,6 @@ public class ChatService : IChatService message.SenderUsername, sender?.NicknameColor, channelName, - MessageType.Text, - null, - null, message.SentAt, Embeds: embeds); @@ -386,6 +382,13 @@ public class ChatService : IChatService raw.Reverse(); + var messageIds = raw.Select(x => x.m.Id).ToList(); + var attachmentsByMessage = (await db.Attachments + .Where(a => messageIds.Contains(a.MessageId)) + .ToListAsync()) + .GroupBy(a => a.MessageId) + .ToDictionary(g => g.Key, g => g.ToList()); + return raw.Select(x => { // Decrypt DB content (handles both encrypted and plaintext via prefix detection) @@ -399,6 +402,18 @@ public class ChatService : IChatService catch { /* ignore malformed JSON */ } } + List? attachments = null; + if (attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0) + { + attachments = atts.Select(a => new AttachmentDto( + a.Kind, + a.Url, + a.FileName, + a.FileSize, + // Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E) + _encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList(); + } + // Encrypt for transport — client decrypts return new MessageDto( x.m.Id, @@ -406,11 +421,8 @@ public class ChatService : IChatService x.m.SenderUsername, x.NicknameColor, channelName, - x.m.Type, - x.m.AttachmentUrl, - x.m.AttachmentFileName, x.m.SentAt, - x.m.AttachmentFileSize, + attachments, embeds); }).ToList(); } diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs index e1e1902..9a9c1b8 100644 --- a/src/EchoHub.Server/Setup/DataMigrationService.cs +++ b/src/EchoHub.Server/Setup/DataMigrationService.cs @@ -20,6 +20,7 @@ public static partial class DataMigrationService await EnsureDefaultChannelsPublicAsync(db, logger); await MigrateAnsiMessagesAsync(db, logger); await MigrateEmbedJsonToArrayAsync(db, logger); + await MigrateLegacyAttachmentsAsync(db, logger); await EnsureConfiguredAdminsAsync(db, config, logger); } @@ -97,6 +98,59 @@ public static partial class DataMigrationService [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex AnsiColorRegex(); + /// + /// Fold legacy single-attachment messages (which stored the file on the message row and, + /// for images, the ASCII art in Content) into the new Attachments model. Idempotent: + /// only migrates messages that still have a legacy AttachmentUrl and no Attachment rows. + /// After migrating, Content becomes empty (the ASCII art moves to the attachment preview) + /// and the legacy columns are nulled out. + /// + private static async Task MigrateLegacyAttachmentsAsync(EchoHubDbContext db, ILogger logger) + { + var legacy = await db.Messages + .Where(m => m.AttachmentUrl != null && m.Attachments.Count == 0) + .ToListAsync(); + + if (legacy.Count == 0) + return; + + logger.LogInformation("Migrating {Count} legacy single-attachment messages to the attachments model...", legacy.Count); + + foreach (var message in legacy) + { + var kind = message.Type switch + { + Core.Models.MessageType.Image => AttachmentKind.Image, + Core.Models.MessageType.Audio => AttachmentKind.Audio, + _ => AttachmentKind.File, + }; + + // For images the ASCII art lived in Content; for audio/file Content was just the + // filename (now redundant with the attachment). Either way the caption becomes empty. + var preview = kind == AttachmentKind.Image ? message.Content : null; + + db.Attachments.Add(new Attachment + { + Id = Guid.NewGuid(), + MessageId = message.Id, + Kind = kind, + Url = message.AttachmentUrl!, + FileName = message.AttachmentFileName ?? "file", + FileSize = message.AttachmentFileSize ?? 0, + AsciiPreview = preview, + }); + + message.Content = string.Empty; + message.AttachmentUrl = null; + message.AttachmentFileName = null; + message.AttachmentFileSize = null; + message.Type = Core.Models.MessageType.Text; + } + + await db.SaveChangesAsync(); + logger.LogInformation("Migrated {Count} legacy attachments.", legacy.Count); + } + /// /// Ensure usernames listed in Server:Admins config are at least Admin role. /// Acts as a safety net in case the first registered user didn't get Owner role. diff --git a/src/EchoHub.Tests/DroppedFileParserTests.cs b/src/EchoHub.Tests/DroppedFileParserTests.cs new file mode 100644 index 0000000..121866d --- /dev/null +++ b/src/EchoHub.Tests/DroppedFileParserTests.cs @@ -0,0 +1,116 @@ +using EchoHub.Client.UI.Helpers; +using Xunit; + +namespace EchoHub.Tests; + +public class DroppedFileParserTests +{ + // ── LooksLikePath ───────────────────────────────────────────────── + + [Theory] + [InlineData("C:\\Users\\me\\cat.png")] + [InlineData("D:/photos/pic.jpg")] + [InlineData("\"C:\\My Files\\a b.png\"")] + [InlineData("/home/me/song.mp3")] + [InlineData("\\\\server\\share\\file.txt")] + public void LooksLikePath_PathLikeInput_ReturnsTrue(string text) + { + Assert.True(DroppedFileParser.LooksLikePath(text)); + } + + [Theory] + [InlineData("hello world")] + [InlineData("check out my cat")] + [InlineData("no")] + [InlineData("")] + [InlineData("@someone hi")] + public void LooksLikePath_NormalChat_ReturnsFalse(string text) + { + Assert.False(DroppedFileParser.LooksLikePath(text)); + } + + // ── TryGetFiles (injected existence check) ──────────────────────── + + [Fact] + public void TryGetFiles_SingleWindowsPath_Detected() + { + var exists = Exists("C:\\Users\\me\\cat.png"); + Assert.True(DroppedFileParser.TryGetFiles("C:\\Users\\me\\cat.png", out var files, exists)); + Assert.Equal(["C:\\Users\\me\\cat.png"], files); + } + + [Fact] + public void TryGetFiles_QuotedPathWithSpaces_StripsQuotes() + { + var path = "C:\\My Files\\a b.png"; + Assert.True(DroppedFileParser.TryGetFiles($"\"{path}\"", out var files, Exists(path))); + Assert.Equal([path], files); + } + + [Fact] + public void TryGetFiles_MultipleQuotedPaths_Detected() + { + var a = "C:\\a.png"; + var b = "C:\\b.mp3"; + Assert.True(DroppedFileParser.TryGetFiles($"\"{a}\" \"{b}\"", out var files, Exists(a, b))); + Assert.Equal([a, b], files); + } + + [Fact] + public void TryGetFiles_PosixAbsolutePath_Detected() + { + // Path.IsPathFullyQualified treats "/x" as fully qualified only on non-Windows; + // this asserts the parser defers that judgment to the platform. + var isPosix = !OperatingSystem.IsWindows(); + var detected = DroppedFileParser.TryGetFiles("/home/me/song.mp3", out var files, Exists("/home/me/song.mp3")); + Assert.Equal(isPosix, detected); + if (isPosix) + Assert.Equal(["/home/me/song.mp3"], files); + } + + [Fact] + public void TryGetFiles_NonExistentPath_ReturnsFalse() + { + Assert.False(DroppedFileParser.TryGetFiles("C:\\nope\\missing.png", out _, _ => false)); + } + + [Fact] + public void TryGetFiles_PartialPathDuringTyping_ReturnsFalseUntilComplete() + { + // Only the fully typed path exists; prefixes do not. + var full = "C:\\Users\\me\\cat.png"; + var exists = Exists(full); + Assert.False(DroppedFileParser.TryGetFiles("C:\\Users\\me\\ca", out _, exists)); + Assert.True(DroppedFileParser.TryGetFiles(full, out _, exists)); + } + + [Fact] + public void TryGetFiles_OneMissingAmongMultiple_ReturnsFalse() + { + var a = "C:\\a.png"; + Assert.False(DroppedFileParser.TryGetFiles($"\"{a}\" \"C:\\gone.png\"", out _, Exists(a))); + } + + [Fact] + public void TryGetFiles_RealTempFile_DetectedWithDefaultExists() + { + var temp = Path.Combine(Path.GetTempPath(), $"echohub_drop_{Guid.NewGuid():N}.txt"); + File.WriteAllText(temp, "x"); + try + { + Assert.True(DroppedFileParser.TryGetFiles(temp, out var files)); + Assert.Single(files); + Assert.Equal(temp, files[0]); + } + finally + { + File.Delete(temp); + } + } + + private static Func Exists(params string[] existing) + { + var set = new HashSet(existing, StringComparer.OrdinalIgnoreCase); + return set.Contains; + } +} diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs index 65c372a..8547285 100644 --- a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs @@ -80,8 +80,7 @@ public class IrcBroadcasterTests var encryptedContent = _encryption.Encrypt("Hello world!"); var message = new MessageDto( - Guid.NewGuid(), encryptedContent, "alice", null, "general", - MessageType.Text, null, null, DateTimeOffset.UtcNow); + Guid.NewGuid(), encryptedContent, "alice", null, "general", DateTimeOffset.UtcNow); await _broadcaster.SendMessageToChannelAsync("general", message); @@ -97,8 +96,7 @@ public class IrcBroadcasterTests var (_, bobStream) = AddConnectionWithCapture("bob", "general"); var message = new MessageDto( - Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", - MessageType.Text, null, null, DateTimeOffset.UtcNow); + Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow); await _broadcaster.SendMessageToChannelAsync("general", message); @@ -116,8 +114,7 @@ public class IrcBroadcasterTests var (_, randomStream) = AddConnectionWithCapture("charlie", "random"); var message = new MessageDto( - Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", - MessageType.Text, null, null, DateTimeOffset.UtcNow); + Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow); await _broadcaster.SendMessageToChannelAsync("general", message); diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs index f0ac952..c80a546 100644 --- a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -340,8 +340,7 @@ public class IrcCommandHandlerTests var encryptedContent = _encryption.Encrypt("Hello from history!"); _chatService.HistoryToReturn = [ - new(Guid.NewGuid(), encryptedContent, "bob", null, "general", - MessageType.Text, null, null, DateTimeOffset.UtcNow) + new(Guid.NewGuid(), encryptedContent, "bob", null, "general", DateTimeOffset.UtcNow) ]; var lines = await RunAuthenticated(["JOIN #general"]); diff --git a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs index b845b50..8f73265 100644 --- a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs @@ -11,32 +11,31 @@ public class IrcMessageFormatterTests string channel = "general", List? embeds = null) { return new MessageDto( - Guid.NewGuid(), content, sender, null, channel, - MessageType.Text, null, null, DateTimeOffset.UtcNow, Embeds: embeds); + Guid.NewGuid(), content, sender, null, channel, DateTimeOffset.UtcNow, Embeds: embeds); } private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png", string url = "https://example.com/image.png", string sender = "alice", string channel = "general") { return new MessageDto( - Guid.NewGuid(), asciiArt, sender, null, channel, - MessageType.Image, url, fileName, DateTimeOffset.UtcNow); + Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow, + [new AttachmentDto(AttachmentKind.Image, url, fileName, 0, asciiArt)]); } private static MessageDto CreateFileMessage(string fileName = "doc.pdf", string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general") { return new MessageDto( - Guid.NewGuid(), "", sender, null, channel, - MessageType.File, url, fileName, DateTimeOffset.UtcNow); + Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow, + [new AttachmentDto(AttachmentKind.File, url, fileName, 0)]); } private static MessageDto CreateAudioMessage(string fileName = "song.mp3", string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general") { return new MessageDto( - Guid.NewGuid(), "", sender, null, channel, - MessageType.Audio, url, fileName, DateTimeOffset.UtcNow); + Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow, + [new AttachmentDto(AttachmentKind.Audio, url, fileName, 0)]); } // ── FormatMessage ──────────────────────────────────────────────────── @@ -115,8 +114,7 @@ public class IrcMessageFormatterTests var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg"); var lines = IrcMessageFormatter.FormatMessage(msg); - Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]")); - Assert.Contains(lines, l => l.Contains("Download: https://example.com/photo.jpg")); + Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]") && l.Contains("https://example.com/photo.jpg")); } [Fact] diff --git a/src/EchoHub.Tests/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/IrcMessageFormatterTests.cs index 5e407b5..a2521c3 100644 --- a/src/EchoHub.Tests/IrcMessageFormatterTests.cs +++ b/src/EchoHub.Tests/IrcMessageFormatterTests.cs @@ -8,22 +8,18 @@ namespace EchoHub.Tests; public class IrcMessageFormatterTests { private static MessageDto CreateMessage( - MessageType type = MessageType.Text, string content = "hello", string sender = "alice", string channel = "general", - string? attachmentUrl = null, - string? attachmentFileName = null, + List? attachments = null, List? embeds = null) => new( Id: Guid.NewGuid(), Content: content, SenderUsername: sender, SenderNicknameColor: null, ChannelName: channel, - Type: type, - AttachmentUrl: attachmentUrl, - AttachmentFileName: attachmentFileName, SentAt: DateTimeOffset.UtcNow, + Attachments: attachments, Embeds: embeds); // ── FormatMessage ───────────────────────────────────────────────── @@ -51,34 +47,29 @@ public class IrcMessageFormatterTests Assert.True(lines.Count >= 2); Assert.Contains("PRIVMSG #general :check this out", lines[0]); - // Embed lines contain the Unicode pipe char and site/title Assert.Contains("GitHub", lines[1]); Assert.Contains("Repo Title", lines[1]); } [Fact] - public void FormatMessage_ImageMessage_IncludesImageTagAndDownloadUrl() + public void FormatMessage_ImageAttachment_IncludesImageTagAndDownloadUrl() { var msg = CreateMessage( - type: MessageType.Image, - content: "{F:FF0000}\u2588{X}", - attachmentUrl: "/api/files/abc", - attachmentFileName: "photo.png"); + content: "", + attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]); var lines = IrcMessageFormatter.FormatMessage(msg); Assert.True(lines.Count >= 2); Assert.Contains("[Image: photo.png]", lines[0]); - Assert.Contains("Download: /api/files/abc", lines[1]); + Assert.Contains("/api/files/abc", lines[0]); } [Fact] - public void FormatMessage_FileMessage_IncludesFileTag() + public void FormatMessage_FileAttachment_IncludesFileTag() { var msg = CreateMessage( - type: MessageType.File, - content: "report.pdf", - attachmentUrl: "/api/files/xyz", - attachmentFileName: "report.pdf"); + content: "", + attachments: [new AttachmentDto(AttachmentKind.File, "/api/files/xyz", "report.pdf", 0)]); var lines = IrcMessageFormatter.FormatMessage(msg); Assert.Single(lines); @@ -87,21 +78,49 @@ public class IrcMessageFormatterTests } [Fact] - public void FormatMessage_AudioMessage_IncludesMusicNoteAndAudioTag() + public void FormatMessage_AudioAttachment_IncludesMusicNoteAndAudioTag() { var msg = CreateMessage( - type: MessageType.Audio, - content: "song.mp3", - attachmentUrl: "/api/files/def", - attachmentFileName: "song.mp3"); + content: "", + attachments: [new AttachmentDto(AttachmentKind.Audio, "/api/files/def", "song.mp3", 0)]); var lines = IrcMessageFormatter.FormatMessage(msg); Assert.Single(lines); - Assert.Contains("\u266a", lines[0]); // ♪ + Assert.Contains("♪", lines[0]); Assert.Contains("[Audio: song.mp3]", lines[0]); Assert.Contains("/api/files/def", lines[0]); } + [Fact] + public void FormatMessage_CaptionWithAttachment_RendersBoth() + { + var msg = CreateMessage( + content: "check this photo", + attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/p", "pic.png", 0, null)]); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Contains(lines, l => l.Contains("check this photo")); + Assert.Contains(lines, l => l.Contains("[Image: pic.png]")); + } + + [Fact] + public void FormatMessage_MultipleAttachments_RendersEach() + { + var msg = CreateMessage( + content: "", + attachments: + [ + new AttachmentDto(AttachmentKind.Image, "/api/files/1", "a.png", 0, null), + new AttachmentDto(AttachmentKind.Audio, "/api/files/2", "b.mp3", 0), + new AttachmentDto(AttachmentKind.File, "/api/files/3", "c.pdf", 0), + ]); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Contains(lines, l => l.Contains("[Image: a.png]")); + Assert.Contains(lines, l => l.Contains("[Audio: b.mp3]")); + Assert.Contains(lines, l => l.Contains("[File: c.pdf]")); + } + // ── ColorTagsToAnsi ─────────────────────────────────────────────── [Fact] @@ -179,7 +198,6 @@ public class IrcMessageFormatterTests var longWord = new string('a', 500); var result = IrcMessageFormatter.SplitMessage(longWord, 400); - // Single word can't be split at word boundary, so it stays as one chunk Assert.Single(result); Assert.Equal(longWord, result[0]); } From 6292e82ceced92a11664f629b8c25b454b100954 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 05:35:11 +0200 Subject: [PATCH 07/10] feat: Add ASCII art size selection for image attachments and enhance message context menu --- README.md | 3 + docs/changelog/v0.2.12.md | 3 + src/EchoHub.Client/AppOrchestrator.cs | 85 +++++++++- src/EchoHub.Client/Commands/CommandHandler.cs | 13 ++ src/EchoHub.Client/Config/ClientConfig.cs | 6 + src/EchoHub.Client/UI/Chat/ChatListSource.cs | 28 +++- src/EchoHub.Client/UI/MainWindow.cs | 153 ++++++++++++++++-- src/EchoHub.Server/Services/ChatService.cs | 80 +++++++-- .../Services/FileStorageService.cs | 16 ++ 9 files changed, 344 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 0b3f8be..b36459d 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | ------- | ----------- | | `/join [password]` | Join a channel (passphrase for encrypted channels) | | `/passwd ` | Change the current encrypted channel's passphrase (creator only) | +| `/size [s\|m\|l]` | ASCII-art size for attached images (no arg = picker) | | `/downloadpath [path]` | Set the download folder (no path = native folder picker) | | `/leave` | Leave current channel | | `/topic ` | Set channel topic (creator only) | @@ -242,6 +243,8 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | `/help` | Show help | | `/quit` | Exit | +**Message actions:** **right-click a message** for a context menu — delete, save/download/play its attachment, mention the sender, view their profile, or copy the text. (Keyboard alternative: press F6 to focus the message list, select with the arrow keys, and press Delete; F6 again returns to the input.) You can always delete your own messages; moderators and above can delete others' messages, but only from users below their own role. + ## Themes `/theme ` to switch: diff --git a/docs/changelog/v0.2.12.md b/docs/changelog/v0.2.12.md index 8b5a20c..8cceccf 100644 --- a/docs/changelog/v0.2.12.md +++ b/docs/changelog/v0.2.12.md @@ -18,6 +18,7 @@ Private channels are now genuinely private: password-protected channels are end- - Each image attachment renders its own ASCII preview with its own "save original" action; audio/file attachments each get their own play/download line. - In encrypted channels every attachment is encrypted individually (blob + ASCII preview), and the caption is room-encrypted — the server still stores only ciphertext and can report count/size but not contents. - Up to 10 attachments per message. +- **Right-click message menu** — right-click any message for a context menu: save/download/play its attachment, mention the sender, view their profile, copy the text, or delete the message. (Keyboard: F6 focuses the message list for arrow-key selection + Delete.) The selected message is now highlighted while the list is focused. - **Message deletion** — press Delete on a selected message to remove it. You can always delete your own messages; moderators and above can delete others' messages, but only from users **below their own role** (a mod can't delete an admin's or owner's message). Deleting a message also removes its attachment blobs from server storage. - **Customizable download folder** — `/downloadpath` opens your OS-native folder picker (Windows Explorer / macOS Finder / Linux GTK or KDE) to choose where downloaded attachments and saved images go; `/downloadpath ` sets it directly (the fallback when no native picker is available). Downloaded files now land in that folder (with automatic `(n)` de-duplication) instead of a temp directory. - `/join [password]` — join protected channels inline, or let the client prompt: joining a protected channel without a password opens a masked prompt that re-prompts on a wrong password @@ -25,11 +26,13 @@ Private channels are now genuinely private: password-protected channels are end- - IRC `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k ` sets and `-k` clears the room password (channel creator or admin only), ban-list probes get a clean empty reply, and `CHANMODES` is advertised in ISUPPORT - IRC `TOPIC` set support — the channel creator can change the topic from IRC; the change broadcasts to connected TUI clients (previously topic changes were rejected with a stub error) - Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux +- Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size ` sets it directly, and `/send -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray - New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`) - Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages. ## Bug Fixes +- Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database. - Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing - Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement - IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]` diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 428b5fc..77b4c0d 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -116,6 +116,7 @@ public sealed class AppOrchestrator : IDisposable _commandHandler.OnJoinChannel += HandleCmdJoinChannel; _commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword; _commandHandler.OnClearAttachments += HandleCmdClearAttachments; + _commandHandler.OnSetAsciiSize += HandleCmdSetAsciiSize; _commandHandler.OnSetDownloadPath += HandleCmdSetDownloadPath; _commandHandler.OnLeaveChannel += HandleCmdLeaveChannel; _commandHandler.OnSetTopic += HandleCmdSetTopic; @@ -195,18 +196,84 @@ public sealed class AppOrchestrator : IDisposable return Task.CompletedTask; } + // An explicit "-s/-m/-l" on /send also sets the message's ASCII size. + if (NormalizeAsciiSize(size) is { } flag) + _config.DefaultAsciiSize = flag; + _stagedAttachments.Add(target); - InvokeUI(() => _mainWindow.SetStagedAttachments(_stagedAttachments.Select(Path.GetFileName).OfType().ToList())); + InvokeUI(RefreshStagingTray); return Task.CompletedTask; } private Task HandleCmdClearAttachments() { _stagedAttachments.Clear(); - InvokeUI(() => _mainWindow.SetStagedAttachments([])); + InvokeUI(RefreshStagingTray); return Task.CompletedTask; } + /// + /// Opens the ASCII-art size picker (no argument) or sets it directly from "s"/"m"/"l" (or + /// small/medium/large). The choice is a persistent preference applied to attached images. + /// + private Task HandleCmdSetAsciiSize(string args) + { + var flag = NormalizeAsciiSize(args); + if (flag is not null) + { + InvokeUI(() => ApplyAsciiSize(flag)); + return Task.CompletedTask; + } + + InvokeUI(() => + { + var choice = MessageBox.Query(_app, "ASCII Art Size", + "Size of the ASCII rendering for images you attach:\n\n" + + " Small 40 x 40 (compact)\n" + + " Medium 80 x 80 (default)\n" + + " Large 120 x 120 (detailed)", + "Small", "Medium", "Large", "Cancel"); + + var picked = choice switch { 0 => "s", 1 => "m", 2 => "l", _ => null }; + if (picked is not null) + ApplyAsciiSize(picked); + }); + return Task.CompletedTask; + } + + private void ApplyAsciiSize(string flag) + { + _config.DefaultAsciiSize = flag; + ConfigManager.Save(_config); + RefreshStagingTray(); + + var channel = _mainWindow.CurrentChannel; + if (!string.IsNullOrEmpty(channel)) + _messageManager.AddSystemMessage(channel, $"Image ASCII size set to {AsciiSizeLabel(flag)}."); + } + + /// Refreshes the staging tray with the current staged files and ASCII size. + private void RefreshStagingTray() + { + var names = _stagedAttachments.Select(Path.GetFileName).OfType().ToList(); + _mainWindow.SetStagedAttachments(names, AsciiSizeLabel(_config.DefaultAsciiSize)); + } + + private static string? NormalizeAsciiSize(string? size) => size?.Trim().ToLowerInvariant() switch + { + "s" or "small" => "s", + "m" or "medium" => "m", + "l" or "large" => "l", + _ => null, + }; + + private static string AsciiSizeLabel(string flag) => flag switch + { + "s" => "Small (40x40)", + "l" => "Large (120x120)", + _ => "Medium (80x80)", + }; + /// /// Sends one message with the given caption plus all staged files as attachments, then /// clears the staging tray. In encrypted channels each file is room-encrypted (blob + @@ -216,30 +283,32 @@ public sealed class AppOrchestrator : IDisposable { var staged = _stagedAttachments.ToList(); _stagedAttachments.Clear(); - InvokeUI(() => _mainWindow.SetStagedAttachments([])); + InvokeUI(RefreshStagingTray); var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); + var size = _config.DefaultAsciiSize; RunAsync(async () => { var outgoing = new List(); foreach (var path in staged) - outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null)); + outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size)); var wireContent = hasRoomKey && !string.IsNullOrEmpty(content) ? RoomCrypto.EncryptText(content, roomKey) : content; - await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing); + await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size); }, "Send failed"); } /// /// Reads a staged file into an . For encrypted channels the /// blob is AES-GCM encrypted, its kind is declared, and the image ASCII preview is rendered - /// locally and room-encrypted — so the server never sees the file or image contents. + /// locally (at ) and room-encrypted — so the server never sees the + /// file or image contents. /// - private static async Task BuildOutgoingAttachmentAsync(string path, byte[]? roomKey) + private static async Task BuildOutgoingAttachmentAsync(string path, byte[]? roomKey, string size) { var fileName = Path.GetFileName(path); @@ -255,7 +324,7 @@ public sealed class AppOrchestrator : IDisposable if (FileValidationHelper.IsValidImage(ms)) { declaredKind = "image"; - var (w, h) = ImageToAsciiService.GetDimensions(null); + var (w, h) = ImageToAsciiService.GetDimensions(size); ms.Position = 0; preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); } diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 59c7c06..3f7fda8 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -17,6 +17,7 @@ public class CommandHandler public event Func? OnChangeRoomPassword; public event Func? OnClearAttachments; public event Func? OnSetDownloadPath; + public event Func? OnSetAsciiSize; public event Func? OnLeaveChannel; public event Func? OnSetTopic; public event Func? OnListUsers; @@ -51,6 +52,7 @@ public class CommandHandler "theme" => await HandleTheme(args), "send" => await HandleSend(args), "clear" => await HandleClear(), + "size" or "asciisize" => await HandleAsciiSize(args), "downloadpath" or "downloads" => await HandleDownloadPath(args), "profile" => await HandleProfile(args), "avatar" => await HandleAvatar(args), @@ -176,6 +178,14 @@ public class CommandHandler return new CommandResult(true, "Cleared staged attachments."); } + private async Task HandleAsciiSize(string args) + { + // No argument → open the size picker; an argument (s/m/l or small/medium/large) sets it. + if (OnSetAsciiSize is not null) + await OnSetAsciiSize(args.Trim()); + return new CommandResult(true); + } + private async Task HandleDownloadPath(string args) { // No argument → open the native folder picker; an argument sets the path directly. @@ -380,7 +390,10 @@ public class CommandHandler /send [-s|-m|-l] - Stage a file to attach (Enter sends with your text) /send [-s|-m|-l] - Send an image URL immediately /clear - Drop all staged attachments + /size [s|m|l] - ASCII art size for attached images (no arg = picker) (Tip: copy a file and press Ctrl+V, or drag a file onto the window, to attach it.) + (Tip: right-click a message for actions — delete, save/download/play attachment, + mention, view profile, copy. Or press F6 to pick a message, then Delete.) /downloadpath [path] - Set download folder (no path = native folder picker) /avatar - Set your avatar /profile [username] - View a profile diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index ee87a69..6ebedd6 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -12,6 +12,12 @@ public class ClientConfig /// OS Downloads folder is used. Set via the native folder picker or /downloadpath. /// public string? DownloadPath { get; set; } + + /// + /// ASCII-art rendering size for images you attach: "s" (40×40), "m" (80×80), or "l" (120×120). + /// Applies to copy-paste/drag-drop attachments, which have no per-file size flag. + /// + public string DefaultAsciiSize { get; set; } = "m"; } public class NotificationConfig diff --git a/src/EchoHub.Client/UI/Chat/ChatListSource.cs b/src/EchoHub.Client/UI/Chat/ChatListSource.cs index d999d72..6e221db 100644 --- a/src/EchoHub.Client/UI/Chat/ChatListSource.cs +++ b/src/EchoHub.Client/UI/Chat/ChatListSource.cs @@ -65,6 +65,13 @@ public class ChatListSource : IListDataSource var chatLine = _lines[item]; var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); + + // Highlight the selected row (whole width) while the list has focus — used by the + // F6 selection flow and right-click. The custom source must draw this itself. + var focusAttr = selected && listView.HasFocus + ? listView.GetAttributeForRole(VisualRole.Focus) + : (Attribute?)null; + var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null; int charPos = 0; @@ -72,11 +79,19 @@ public class ChatListSource : IListDataSource foreach (var segment in chatLine.Segments) { - var attr = segment.Color ?? normalAttr; - if (attr.Background == Color.None) - attr = attr with { Background = normalAttr.Background }; - if (mentionBg.HasValue) - attr = attr with { Background = mentionBg.Value }; + Attribute attr; + if (focusAttr is { } focus) + { + attr = focus; + } + else + { + attr = segment.Color ?? normalAttr; + if (attr.Background == Color.None) + attr = attr with { Background = normalAttr.Background }; + if (mentionBg.HasValue) + attr = attr with { Background = mentionBg.Value }; + } listView.SetAttribute(attr); foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text)) @@ -91,7 +106,8 @@ public class ChatListSource : IListDataSource } } - var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr; + var fillAttr = focusAttr + ?? (mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr); listView.SetAttribute(fillAttr); for (int i = drawnChars; i < width; i++) listView.AddStr(" "); diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 1be8d70..13c63f4 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -40,7 +40,7 @@ public sealed partial class MainWindow : Runnable private readonly UserListSource _usersListSource; private bool _usersPanelVisible = true; private const int UsersPanelWidth = 22; - private const string DefaultInputTitle = "Message │ Enter=send │ Ctrl+N=newline │ Tab=complete │ Ctrl+K=search"; + private const string DefaultInputTitle = "Message │ Enter=send │ Tab=complete │ Ctrl+K=search │ F6=pick message"; private static readonly Key F2Key = Key.F2; private bool _hasStagedAttachments; @@ -57,12 +57,13 @@ public sealed partial class MainWindow : Runnable private static readonly Key CtrlXKey = Key.X.WithCtrl; private static readonly Key CtrlCKey = Key.C.WithCtrl; private static readonly Key CtrlYKey = Key.Y.WithCtrl; + private static readonly Key F6Key = Key.F6; // Available slash commands for Tab autocomplete private static readonly string[] SlashCommands = [ "/status", "/nick", "/color", "/theme", "/send", - "/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/downloadpath", + "/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath", "/topic", "/users", "/kick", "/ban", "/unban", "/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help" ]; @@ -248,6 +249,7 @@ public sealed partial class MainWindow : Runnable _messageList.Source = new ChatListSource(); _messageList.Accepting += OnMessageListAccepting; _messageList.KeyDown += OnMessageListKeyDown; + _messageList.MouseEvent += OnMessageListMouseEvent; _messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled; _messageList.VerticalScrollBar.Visible = true; @@ -330,10 +332,10 @@ public sealed partial class MainWindow : Runnable } /// - /// Updates the attachment staging indicator shown on the input frame's title. - /// Passing an empty list restores the default hint. + /// Updates the attachment staging indicator shown on the input frame's title, including the + /// current ASCII-art size for images. Passing an empty list restores the default hint. /// - public void SetStagedAttachments(IReadOnlyList fileNames) + public void SetStagedAttachments(IReadOnlyList fileNames, string asciiSizeLabel) { _hasStagedAttachments = fileNames.Count > 0; if (fileNames.Count == 0) @@ -343,9 +345,9 @@ public sealed partial class MainWindow : Runnable else { var names = string.Join(", ", fileNames); - if (names.Length > 60) - names = names[..57] + "..."; - _inputFrame.Title = $"📎 {fileNames.Count} staged: {names} │ Enter=send │ /clear to drop"; + if (names.Length > 45) + names = names[..42] + "..."; + _inputFrame.Title = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear"; } _inputFrame.SetNeedsDraw(); } @@ -541,6 +543,14 @@ public sealed partial class MainWindow : Runnable private void OnMessageListKeyDown(object? sender, Key e) { + // F6 returns focus to the input box. + if (e.KeyCode == F6Key.KeyCode) + { + _inputField.SetFocus(); + e.Handled = true; + return; + } + if (e.KeyCode != Key.Delete.KeyCode && e.KeyCode != Key.Backspace.KeyCode) return; @@ -557,12 +567,104 @@ public sealed partial class MainWindow : Runnable // Server enforces the real permission (own message, or Mod+ over a lower role); // the client just confirms intent and lets the server reject if disallowed. - var confirm = MessageBox.Query(_app, "Delete Message", - "Delete this message?", "Delete", "Cancel"); + ConfirmDeleteMessage(messageId); + e.Handled = true; + } + + private void OnMessageListMouseEvent(object? sender, Mouse e) + { + if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked)) + return; + + if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos) + return; + + var index = _messageList.TopItem + pos.Y; + if (index < 0 || index >= source.Count) + return; + + // Select the right-clicked row (so the menu acts on it and it highlights), then show the menu. + _messageList.SelectedItem = index; + _messageList.SetFocus(); + + var line = source.GetLine(index); + if (line is null) + return; + + ShowMessageContextMenu(line, e.ScreenPosition); + e.Handled = true; + } + + /// + /// Builds and shows a right-click context menu for a message line: attachment actions, + /// mention/profile for the sender, copy, and delete (permission enforced server-side). + /// + private void ShowMessageContextMenu(ChatLine line, System.Drawing.Point screenPosition) + { + var items = new List(); + var sender = line.SenderUsername; + + if (line.AttachmentKind is { } kind && line.AttachmentUrl is { } url && line.AttachmentFileName is { } name) + { + switch (kind) + { + case AttachmentKind.Image: + items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty)); + break; + case AttachmentKind.Audio: + items.Add(new MenuItem("Play audio", "", () => OnAudioPlayRequested?.Invoke(url, name), Key.Empty)); + break; + default: + items.Add(new MenuItem("Download file", "", () => OnFileDownloadRequested?.Invoke(url, name), Key.Empty)); + break; + } + } + + if (sender is not null) + { + items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty)); + items.Add(new MenuItem($"View {sender}'s profile", "", () => OnUserProfileRequested?.Invoke(sender), Key.Empty)); + } + + items.Add(new MenuItem("Copy text", "", () => CopyToClipboard(line.ToString()), Key.Empty)); + + if (line.MessageId is { } messageId) + { + items.Add(new Line()); + items.Add(new MenuItem("Delete message", "", () => ConfirmDeleteMessage(messageId), Key.Empty)); + } + + if (items.Count == 0) + return; + + var menu = new PopoverMenu(items); + _app.Popovers?.Register(menu); + menu.MakeVisible(screenPosition); + } + + private void MentionUser(string username) + { + _inputField.InsertText($"@{username} "); + _inputField.SetFocus(); + } + + private void CopyToClipboard(string text) + { + try + { + _app.Clipboard?.TrySetClipboardData(text); + } + catch (Exception ex) + { + Log.Warning(ex, "Copy to clipboard failed"); + } + } + + private void ConfirmDeleteMessage(Guid messageId) + { + var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel"); if (confirm == 0) OnDeleteMessageRequested?.Invoke(messageId); - - e.Handled = true; } private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs e) @@ -619,6 +721,13 @@ public sealed partial class MainWindow : Runnable ShowSearchDialog(); e.Handled = true; } + else if (e.KeyCode == F6Key.KeyCode) + { + // Move focus into the message list so you can select a message (arrows) and + // delete it (Delete). F6 again returns focus here. (Esc is the app quit key.) + FocusMessageList(); + e.Handled = true; + } else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode) { // If a file was copied in the OS file manager, the clipboard holds a file list @@ -1049,6 +1158,26 @@ public sealed partial class MainWindow : Runnable _inputField.SetFocus(); } + /// + /// Moves focus into the message list for selection (arrows) and deletion (Delete). Selects the + /// most recent message when nothing is selected. No-op when the channel has no messages. + /// + private void FocusMessageList() + { + if (_messageList.Source is not ChatListSource source || source.Count == 0) + return; + + if (!_messageList.SelectedItem.HasValue + || _messageList.SelectedItem < 0 + || _messageList.SelectedItem >= source.Count) + { + _messageList.SelectedItem = source.Count - 1; + } + + _messageList.SetFocus(); + _messageList.SetNeedsDraw(); + } + private void RefreshMessages() { var messages = _messageManager.GetMessages(_messageManager.CurrentChannel); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 4a5badf..f5509ad 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -18,6 +18,7 @@ public class ChatService : IChatService private readonly LinkEmbedService _embedService; private readonly IMessageEncryptionService _encryption; private readonly IChannelService _channelService; + private readonly FileStorageService _fileStorage; private readonly ILogger _logger; public ChatService( @@ -27,6 +28,7 @@ public class ChatService : IChatService LinkEmbedService embedService, IMessageEncryptionService encryption, IChannelService channelService, + FileStorageService fileStorage, ILogger logger) { _scopeFactory = scopeFactory; @@ -35,6 +37,7 @@ public class ChatService : IChatService _embedService = embedService; _encryption = encryption; _channelService = channelService; + _fileStorage = fileStorage; _logger = logger; } @@ -389,12 +392,46 @@ public class ChatService : IChatService .GroupBy(a => a.MessageId) .ToDictionary(g => g.Key, g => g.ToList()); - return raw.Select(x => + // Attachment blobs can be pruned by retention while the message rows remain. Check what's + // actually on disk (one scan) so we never render a dead download, and so we can drop + // attachment-only messages whose files are all gone. + var storedFileIds = attachmentsByMessage.Count > 0 ? _fileStorage.GetStoredFileIds() : []; + + var result = new List(raw.Count); + var deadMessageIds = new List(); + + foreach (var x in raw) { // Decrypt DB content (handles both encrypted and plaintext via prefix detection) var plaintext = _encryption.Decrypt(x.m.Content); - var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson); + List? attachments = null; + var hadAttachments = attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0; + if (hadAttachments) + { + // Keep only attachments whose underlying file still exists on disk. + var live = atts!.Where(a => storedFileIds.Contains(FileIdFromUrl(a.Url))).ToList(); + + // Attachment-only message whose files are all gone → prune it entirely. + if (live.Count == 0 && string.IsNullOrEmpty(plaintext)) + { + deadMessageIds.Add(x.m.Id); + continue; + } + + if (live.Count > 0) + { + attachments = live.Select(a => new AttachmentDto( + a.Kind, + a.Url, + a.FileName, + a.FileSize, + // Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E) + _encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList(); + } + } + + var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson); List? embeds = null; if (embedJsonPlain is not null) { @@ -402,20 +439,8 @@ public class ChatService : IChatService catch { /* ignore malformed JSON */ } } - List? attachments = null; - if (attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0) - { - attachments = atts.Select(a => new AttachmentDto( - a.Kind, - a.Url, - a.FileName, - a.FileSize, - // Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E) - _encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList(); - } - // Encrypt for transport — client decrypts - return new MessageDto( + result.Add(new MessageDto( x.m.Id, _encryption.Encrypt(plaintext), x.m.SenderUsername, @@ -423,7 +448,28 @@ public class ChatService : IChatService channelName, x.m.SentAt, attachments, - embeds); - }).ToList(); + embeds)); + } + + // Lazily delete the pruned messages (+ their attachment rows) as they're encountered. + if (deadMessageIds.Count > 0) + { + try + { + await db.Attachments.Where(a => deadMessageIds.Contains(a.MessageId)).ExecuteDeleteAsync(); + await db.Messages.Where(m => deadMessageIds.Contains(m.Id)).ExecuteDeleteAsync(); + _logger.LogInformation("Pruned {Count} attachment-only messages with missing files in '{Channel}'", + deadMessageIds.Count, channelName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to prune messages with missing attachments in '{Channel}'", channelName); + } + } + + return result; } + + /// Extracts the storage file id from an attachment URL (e.g. "/api/files/{id}"). + private static string FileIdFromUrl(string url) => url.Split('/')[^1]; } diff --git a/src/EchoHub.Server/Services/FileStorageService.cs b/src/EchoHub.Server/Services/FileStorageService.cs index a34d3cd..a013e06 100644 --- a/src/EchoHub.Server/Services/FileStorageService.cs +++ b/src/EchoHub.Server/Services/FileStorageService.cs @@ -35,6 +35,22 @@ public class FileStorageService return files.Length > 0 ? files[0] : null; } + /// + /// Returns the set of stored file ids (filenames without extension) currently on disk. + /// One directory scan, so callers can bulk-check many attachments without a glob per file. + /// + public HashSet GetStoredFileIds() + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(_storagePath)) + return ids; + + foreach (var file in Directory.EnumerateFiles(_storagePath)) + ids.Add(Path.GetFileNameWithoutExtension(file)); + + return ids; + } + public void DeleteFile(string fileId) { var filePath = GetFilePath(fileId); From a3a413f0b11373ad70bd37c3d8b8a2249155c773 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 05:38:41 +0200 Subject: [PATCH 08/10] feat: Add "Copy message ID" option to message context menu --- src/EchoHub.Client/UI/MainWindow.cs | 1 + .../Data/Migrations/20260715232856_AddChannelPasswordHash.cs | 2 +- .../Migrations/20260716012917_AddChannelEncryptionEnvelope.cs | 2 +- .../Data/Migrations/20260716020211_AddMessageAttachments.cs | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 13c63f4..8f18f68 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -630,6 +630,7 @@ public sealed partial class MainWindow : Runnable if (line.MessageId is { } messageId) { + items.Add(new MenuItem("Copy message ID", "", () => CopyToClipboard(messageId.ToString()), Key.Empty)); items.Add(new Line()); items.Add(new MenuItem("Delete message", "", () => ConfirmDeleteMessage(messageId), Key.Empty)); } diff --git a/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs index c01f2c0..47acd69 100644 --- a/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs +++ b/src/EchoHub.Server/Data/Migrations/20260715232856_AddChannelPasswordHash.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs index a0a6664..4fd5ef2 100644 --- a/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs +++ b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.cs b/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.cs index ed1cdca..75c1767 100644 --- a/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.cs +++ b/src/EchoHub.Server/Data/Migrations/20260716020211_AddMessageAttachments.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable From 5040c5c20115cd57b8d95920d433e6c61c315224 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 05:46:02 +0200 Subject: [PATCH 09/10] feat: Enhance ThemeManager to ensure transparent themes render correctly for editable fields --- docs/changelog/v0.2.12.md | 3 ++- src/EchoHub.Client/Themes/ThemeManager.cs | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/changelog/v0.2.12.md b/docs/changelog/v0.2.12.md index 8cceccf..516c1d8 100644 --- a/docs/changelog/v0.2.12.md +++ b/docs/changelog/v0.2.12.md @@ -18,7 +18,7 @@ Private channels are now genuinely private: password-protected channels are end- - Each image attachment renders its own ASCII preview with its own "save original" action; audio/file attachments each get their own play/download line. - In encrypted channels every attachment is encrypted individually (blob + ASCII preview), and the caption is room-encrypted — the server still stores only ciphertext and can report count/size but not contents. - Up to 10 attachments per message. -- **Right-click message menu** — right-click any message for a context menu: save/download/play its attachment, mention the sender, view their profile, copy the text, or delete the message. (Keyboard: F6 focuses the message list for arrow-key selection + Delete.) The selected message is now highlighted while the list is focused. +- **Right-click message menu** — right-click any message for a context menu: save/download/play its attachment, mention the sender, view their profile, copy the text, copy the message ID (for linking or command arguments), or delete the message. (Keyboard: F6 focuses the message list for arrow-key selection + Delete.) The selected message is now highlighted while the list is focused. - **Message deletion** — press Delete on a selected message to remove it. You can always delete your own messages; moderators and above can delete others' messages, but only from users **below their own role** (a mod can't delete an admin's or owner's message). Deleting a message also removes its attachment blobs from server storage. - **Customizable download folder** — `/downloadpath` opens your OS-native folder picker (Windows Explorer / macOS Finder / Linux GTK or KDE) to choose where downloaded attachments and saved images go; `/downloadpath ` sets it directly (the fallback when no native picker is available). Downloaded files now land in that folder (with automatic `(n)` de-duplication) instead of a temp directory. - `/join [password]` — join protected channels inline, or let the client prompt: joining a protected channel without a password opens a masked prompt that re-prompts on a wrong password @@ -32,6 +32,7 @@ Private channels are now genuinely private: password-protected channels are end- ## Bug Fixes +- Transparent themes no longer draw an opaque box behind the message input. The input `TextView` renders with the `Editable` visual role, which Terminal.Gui derives as an opaque color when a theme leaves it unset; the themes now pin `Editable`/`ReadOnly` to their base colors so the input matches its (transparent) background. - Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database. - Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing - Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement diff --git a/src/EchoHub.Client/Themes/ThemeManager.cs b/src/EchoHub.Client/Themes/ThemeManager.cs index ebc7003..130dad4 100644 --- a/src/EchoHub.Client/Themes/ThemeManager.cs +++ b/src/EchoHub.Client/Themes/ThemeManager.cs @@ -575,7 +575,14 @@ public static class ThemeManager Focus = focus, HotNormal = normal, HotFocus = focus, - Disabled = normal + Disabled = normal, + + // TextView/TextField draw their editable area with the Editable/ReadOnly roles. If + // left unset, Terminal.Gui derives an opaque background from Normal — which renders + // as a solid box behind the input under transparent themes. Pin them to the theme's + // own colors so the input matches its background (transparent stays transparent). + Editable = normal, + ReadOnly = normal }; } From 9aea6ecfc343ac4306bbd2d1c0a3a679c0b9d4a4 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 05:52:06 +0200 Subject: [PATCH 10/10] refactor: Update DroppedFileParserTests to use absolute path helper for improved readability and maintainability --- src/EchoHub.Tests/DroppedFileParserTests.cs | 35 ++++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/EchoHub.Tests/DroppedFileParserTests.cs b/src/EchoHub.Tests/DroppedFileParserTests.cs index 121866d..62b1043 100644 --- a/src/EchoHub.Tests/DroppedFileParserTests.cs +++ b/src/EchoHub.Tests/DroppedFileParserTests.cs @@ -32,17 +32,17 @@ public class DroppedFileParserTests // ── TryGetFiles (injected existence check) ──────────────────────── [Fact] - public void TryGetFiles_SingleWindowsPath_Detected() + public void TryGetFiles_SingleAbsolutePath_Detected() { - var exists = Exists("C:\\Users\\me\\cat.png"); - Assert.True(DroppedFileParser.TryGetFiles("C:\\Users\\me\\cat.png", out var files, exists)); - Assert.Equal(["C:\\Users\\me\\cat.png"], files); + var path = Abs("Users", "me", "cat.png"); + Assert.True(DroppedFileParser.TryGetFiles(path, out var files, Exists(path))); + Assert.Equal([path], files); } [Fact] public void TryGetFiles_QuotedPathWithSpaces_StripsQuotes() { - var path = "C:\\My Files\\a b.png"; + var path = Abs("My Files", "a b.png"); Assert.True(DroppedFileParser.TryGetFiles($"\"{path}\"", out var files, Exists(path))); Assert.Equal([path], files); } @@ -50,8 +50,8 @@ public class DroppedFileParserTests [Fact] public void TryGetFiles_MultipleQuotedPaths_Detected() { - var a = "C:\\a.png"; - var b = "C:\\b.mp3"; + var a = Abs("a.png"); + var b = Abs("b.mp3"); Assert.True(DroppedFileParser.TryGetFiles($"\"{a}\" \"{b}\"", out var files, Exists(a, b))); Assert.Equal([a, b], files); } @@ -71,24 +71,26 @@ public class DroppedFileParserTests [Fact] public void TryGetFiles_NonExistentPath_ReturnsFalse() { - Assert.False(DroppedFileParser.TryGetFiles("C:\\nope\\missing.png", out _, _ => false)); + Assert.False(DroppedFileParser.TryGetFiles(Abs("nope", "missing.png"), out _, _ => false)); } [Fact] public void TryGetFiles_PartialPathDuringTyping_ReturnsFalseUntilComplete() { // Only the fully typed path exists; prefixes do not. - var full = "C:\\Users\\me\\cat.png"; + var full = Abs("Users", "me", "cat.png"); + var partial = Abs("Users", "me", "ca"); var exists = Exists(full); - Assert.False(DroppedFileParser.TryGetFiles("C:\\Users\\me\\ca", out _, exists)); + Assert.False(DroppedFileParser.TryGetFiles(partial, out _, exists)); Assert.True(DroppedFileParser.TryGetFiles(full, out _, exists)); } [Fact] public void TryGetFiles_OneMissingAmongMultiple_ReturnsFalse() { - var a = "C:\\a.png"; - Assert.False(DroppedFileParser.TryGetFiles($"\"{a}\" \"C:\\gone.png\"", out _, Exists(a))); + var a = Abs("a.png"); + var gone = Abs("gone.png"); + Assert.False(DroppedFileParser.TryGetFiles($"\"{a}\" \"{gone}\"", out _, Exists(a))); } [Fact] @@ -113,4 +115,13 @@ public class DroppedFileParserTests var set = new HashSet(existing, StringComparer.OrdinalIgnoreCase); return set.Contains; } + + /// + /// Builds an absolute path that accepts on the current + /// OS — C:\a\b on Windows, /a/b elsewhere — so these tests run on any platform (CI is Linux). + /// + private static string Abs(params string[] segments) => + OperatingSystem.IsWindows() + ? "C:\\" + string.Join('\\', segments) + : "/" + string.Join('/', segments); }