diff --git a/docs/changelog/index.md b/docs/changelog/index.md index d00bfba..9a6f01d 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,6 +4,7 @@ Release history for EchoHub. ## Releases +- [v0.2.15](v0.2.15.md) - Open Images In Browser, IRC Image Links & Dual-Session Echo Fix - [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish - [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions - [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index ce61a48..c8e2256 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.15 + href: v0.2.15.md - name: v0.2.14 href: v0.2.14.md - name: v0.2.13 diff --git a/docs/changelog/v0.2.15.md b/docs/changelog/v0.2.15.md index bc7691f..794c9d7 100644 --- a/docs/changelog/v0.2.15.md +++ b/docs/changelog/v0.2.15.md @@ -1,5 +1,14 @@ # v0.2.15 +Images become properly shareable: every image gets an **[open]** action that views it in your browser (or, in end-to-end encrypted rooms, decrypts and opens it locally) — no more saving to disk just to look at a picture. The IRC gateway stops painting ANSI art nobody asked for and posts plain image links any IRC client can open or auto-preview. And a long-standing dual-session annoyance is fixed: messages you send from the TUI now reach your own connected IRC client instantly. + ## New Features +- **`[open]` images without saving them** — every image attachment now shows `[open] [↓ save original]` beneath its preview. Open views the image in your default browser straight from the server; in end-to-end encrypted rooms (where a browser would only see ciphertext) the client downloads, decrypts with the room key, and opens the image in your OS viewer from a temp file instead. Both actions are individually clickable, Enter on the line opens, and the right-click menu carries both. +- **Attachment links work in a browser** — `GET /api/files/{id}` is now a capability URL: the unguessable GUID in the link is the access token (Discord-CDN style), so attachment links can be opened directly in a browser or shared to IRC without a login token. Images and audio are served inline so the browser displays them instead of forcing a download. Blobs from encrypted rooms remain ciphertext, so their links reveal nothing. +- **IRC gets image links instead of terminal art** — the gateway no longer floods IRC clients with truecolor-ANSI ASCII art for images. Each attachment is now a single line — `[Image: photo.png] https://your-server/api/files/…` — the convention every IRC client understands, and ones like TheLounge or IRCCloud auto-preview. Set the new `Irc:PublicBaseUrl` option (e.g. `"https://chat.example.com"`) so those links come out absolute; unset, they fall back to relative paths as before. + +## Bug Fixes + +- **Messages sent from the TUI now reach your own IRC session.** With the same account online via both the TUI and an IRC client, messages sent from the TUI never appeared in the IRC client until it reconnected (which replayed history). The gateway suppressed the sender's echo by *nickname*, which swallowed the message for every IRC connection on that account — it now excludes only the exact connection a message originated from, so all your other sessions (a second IRC client included) receive it immediately. - **Channel deleted event** — when a channel is deleted, all connected clients are now immediately notified via the new `ChannelDeleted` SignalR event. The channel is removed from the channel list. Previously only the deleting client was able to delete the channel. Other clients would see the channel linger until the next manual refresh. diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 460a975..d00d1f7 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -110,6 +110,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnImageSaveRequested += HandleImageSaveRequested; + _mainWindow.OnImageOpenRequested += HandleImageOpenRequested; _mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested; @@ -1704,6 +1705,62 @@ public sealed class AppOrchestrator : IDisposable return tempPath; } + /// File extensions the "[open]" action will hand to the OS image viewer for E2E rooms. + private static readonly HashSet ImageOpenExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", + }; + + /// + /// Views an image without saving it to the user's downloads. Plain channels open the + /// file's web URL in the default browser (the server serves files by capability URL, so + /// no auth token is needed). E2E-encrypted channels would render as ciphertext in a + /// browser, so the blob is downloaded, decrypted locally, and opened from a temp file. + /// + private void HandleImageOpenRequested(string attachmentUrl, string fileName) + { + if (!_conn.IsAuthenticated) return; + + var channel = _mainWindow.CurrentChannel; + var isEncryptedRoom = !string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out _); + + if (!isEncryptedRoom) + { + var webUrl = attachmentUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || attachmentUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ? attachmentUrl + : $"{_conn.Api!.BaseUrl}/{attachmentUrl.TrimStart('/')}"; + + try + { + System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo(webUrl) { UseShellExecute = true }); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to open image URL in browser: {Url}", webUrl); + InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Couldn't open a browser — image URL: {webUrl}")); + } + return; + } + + // In E2E rooms the attachment kind is sender-declared, so only hand real image + // extensions to the OS viewer; anything else goes through the save path instead. + if (!ImageOpenExtensions.Contains(Path.GetExtension(fileName))) + { + HandleImageSaveRequested(attachmentUrl, fileName); + return; + } + + RunAsync(async () => + { + InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Decrypting {fileName}...")); + var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); + var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true }; + System.Diagnostics.Process.Start(psi); + }, "Failed to open image"); + } + private void HandleImageSaveRequested(string attachmentUrl, string fileName) { if (!_conn.IsAuthenticated) return; diff --git a/src/EchoHub.Client/UI/Chat/ChatLine.cs b/src/EchoHub.Client/UI/Chat/ChatLine.cs index f7eef86..6dfb7fa 100644 --- a/src/EchoHub.Client/UI/Chat/ChatLine.cs +++ b/src/EchoHub.Client/UI/Chat/ChatLine.cs @@ -7,6 +7,16 @@ using Attribute = Terminal.Gui.Drawing.Attribute; namespace EchoHub.Client.UI.Chat; +/// An action a click on an attachment line can trigger. +public enum AttachmentAction +{ + OpenImage, + SaveImage, +} + +/// Inclusive column range on a chat line that triggers an attachment action when clicked. +public readonly record struct AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action); + /// /// A single line in the chat, composed of colored segments. /// @@ -20,6 +30,14 @@ public partial class ChatLine public string? AttachmentFileName { get; set; } public AttachmentKind? AttachmentKind { get; set; } public string? SenderUsername { get; set; } + + /// + /// Clickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an + /// image). Columns are relative to the unwrapped line, so only the first wrapped line + /// keeps them. Null means the whole line uses the kind's default action. + /// + public List? ActionSpans { get; set; } + /// Number of spaces to prepend on continuation lines when this line is word-wrapped. public int ContinuationIndent { get; set; } @@ -160,6 +178,10 @@ public partial class ChatLine wrapped.IsMention = IsMention; } + // Span columns only line up with the first wrapped line; later lines fall + // back to the kind's default action. + results[0].ActionSpans = ActionSpans; + return results; } diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index 44ac51e..5e316a3 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -443,9 +443,7 @@ public sealed class ChatMessageManager lines.Add(new ChatLine(segments)); } } - lines.Add(AttachmentActionLine( - $"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", - ChatColors.FileAttr, attachment)); + lines.Add(ImageActionLine(attachment)); break; case Core.Models.AttachmentKind.Audio: @@ -489,6 +487,41 @@ public sealed class ChatMessageManager return lines; } + /// + /// Builds the action line below an image preview: "[open] [↓ save original] name [size]". + /// Each bracket is an so a mouse click can target it; + /// keyboard activation (Enter) uses the default action, open. + /// + private static ChatLine ImageActionLine(AttachmentDto attachment) + { + var segments = RailPrefix(); + var col = segments.Sum(s => s.Text.GetColumns()); + var spans = new List(); + + void AddAction(string text, AttachmentAction action) + { + var width = text.GetColumns(); + spans.Add(new AttachmentActionSpan(col, col + width - 1, action)); + segments.Add(new(text, ChatColors.FileAttr)); + col += width; + } + + AddAction("[open]", AttachmentAction.OpenImage); + segments.Add(new(" ", null)); + col += 1; + AddAction("[↓ save original]", AttachmentAction.SaveImage); + segments.Add(new($" {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr)); + + return new ChatLine(segments) + { + AttachmentUrl = attachment.Url, + AttachmentFileName = attachment.FileName, + AttachmentKind = attachment.Kind, + ActionSpans = spans, + ContinuationPrefixSegments = RailPrefix(), + }; + } + /// /// Builds a clickable attachment line carrying the metadata the message list uses to /// route activation (play audio, download file, save original image). diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index e518cc7..e9439e8 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -176,6 +176,12 @@ public sealed partial class MainWindow : Runnable /// public event Action? OnImageSaveRequested; + /// + /// Fired when the user activates an image's "[open]" action to view it without saving. + /// Parameters: attachmentUrl, fileName. + /// + public event Action? OnImageOpenRequested; + /// /// Fired when the user presses Delete on the selected message. Parameter is the message id. /// @@ -532,7 +538,9 @@ public sealed partial class MainWindow : Runnable if (line.AttachmentKind == AttachmentKind.Image) { - OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + // Keyboard/default activation opens the image for viewing; + // saving is the mouse span or the context menu. + OnImageOpenRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); e.Handled = true; return; } @@ -600,7 +608,8 @@ public sealed partial class MainWindow : Runnable private void OnMessageListMouseEvent(object? sender, Mouse e) { - if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked)) + var leftClick = e.Flags.HasFlag(MouseFlags.LeftButtonClicked); + if (!leftClick && !e.Flags.HasFlag(MouseFlags.RightButtonClicked)) return; if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos) @@ -610,6 +619,30 @@ public sealed partial class MainWindow : Runnable if (index < 0 || index >= source.Count) return; + // Left-click only activates the "[open]" / "[save original]" brackets on an + // attachment action line; anywhere else it falls through to normal selection. + if (leftClick) + { + var clicked = source.GetLine(index); + if (clicked?.ActionSpans is { } spans + && clicked.AttachmentUrl is { } url && clicked.AttachmentFileName is { } name) + { + foreach (var span in spans) + { + if (pos.X < span.StartCol || pos.X > span.EndCol) + continue; + + if (span.Action == AttachmentAction.OpenImage) + OnImageOpenRequested?.Invoke(url, name); + else + OnImageSaveRequested?.Invoke(url, name); + e.Handled = true; + return; + } + } + return; + } + // Select the right-clicked row (so the menu acts on it and it highlights), then show the menu. _messageList.SelectedItem = index; _messageList.SetFocus(); @@ -636,6 +669,7 @@ public sealed partial class MainWindow : Runnable switch (kind) { case AttachmentKind.Image: + items.Add(new MenuItem("Open image", "", () => OnImageOpenRequested?.Invoke(url, name), Key.Empty)); items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty)); break; case AttachmentKind.Audio: diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs index 884b587..70a6cf5 100644 --- a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs +++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs @@ -4,7 +4,14 @@ namespace EchoHub.Core.Contracts; public interface IChatBroadcaster { - Task SendMessageToChannelAsync(string channelName, MessageDto message); + /// + /// Broadcast a chat message to a channel. is the + /// connection the message originated from (IRC convention: never echo a message back to + /// the connection that sent it — its client already displayed it locally). Other + /// connections of the same user (e.g. an IRC session alongside a TUI session) still + /// receive the message. + /// + Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null); Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null); Task SendUserLeftAsync(string channelName, string username); Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null); diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index e3735af..072e4d1 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -13,8 +13,10 @@ public interface IChatService 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 - Task SendMessageAsync(Guid userId, string username, string channelName, string content); + // Messaging. originConnectionId identifies the connection the message came from so + // broadcasters can avoid echoing it back to that one connection (IRC convention); + // the sender's other sessions still receive it. + Task SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null); Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0); // Presence diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 074e8ba..b1a5479 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -14,24 +14,20 @@ public class IrcBroadcaster : IChatBroadcaster _encryption = encryption; } - public async Task SendMessageToChannelAsync(string channelName, MessageDto message) + public async Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null) { - // Decrypt content and attachment previews for IRC clients (they can't handle - // app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched; - // the formatter drops previews that are still ciphertext. - var decryptedMessage = message with - { - Content = _encryption.Decrypt(message.Content), - Attachments = message.Attachments? - .Select(a => a with { AsciiPreview = _encryption.DecryptNullable(a.AsciiPreview) }) - .ToList(), - }; - var lines = IrcMessageFormatter.FormatMessage(decryptedMessage); + // Decrypt transport-encrypted content for IRC clients (they can't handle + // app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched. + var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) }; + var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl); foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) { - // IRC convention: don't echo sender's own message - if (conn.Nickname == message.SenderUsername) + // IRC convention: don't echo a message back to the connection that sent it + // (its client already displayed it locally). Match by connection id, not + // nickname — the same account may also be online via the TUI or a second + // IRC client, and those sessions must still receive the message. + if (conn.ConnectionId == excludeConnectionId) continue; foreach (var line in lines) diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 0e27201..54f1ec9 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -433,7 +433,7 @@ public sealed class IrcCommandHandler foreach (var m in history) { var decrypted = m with { Content = _encryption.Decrypt(m.Content) }; - var lines = IrcMessageFormatter.FormatMessage(decrypted); + var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl); foreach (var line in lines) await _conn.SendAsync(line); } @@ -486,7 +486,7 @@ public sealed class IrcCommandHandler if (channelName is null) return; var error = await _chatService.SendMessageAsync( - _conn.UserId!.Value, _conn.Nickname!, channelName, content); + _conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId); if (error is not null) { diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index 01b4ab5..269ba64 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -1,20 +1,20 @@ using System.Text; -using System.Text.RegularExpressions; -using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; using EchoHub.Core.Models; -using EchoHub.Core.Security; namespace EchoHub.Server.Irc; -public static partial class IrcMessageFormatter +public static class IrcMessageFormatter { private const int MaxIrcLineContentBytes = 400; /// /// Format a MessageDto as one or more IRC PRIVMSG lines. + /// Attachments are rendered as single link lines \u2014 the widely-supported IRC convention + /// (clients auto-preview or open plain http(s) URLs) \u2014 never as terminal color art. + /// makes the links absolute so any IRC client can open them. /// - public static List FormatMessage(MessageDto message) + public static List FormatMessage(MessageDto message, string? publicBaseUrl = null) { var lines = new List(); var ircChannel = $"#{message.ChannelName}"; @@ -27,34 +27,19 @@ public static partial class IrcMessageFormatter lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); } - // One block per attachment + // One link line per attachment if (message.Attachments is { Count: > 0 }) { foreach (var attachment in message.Attachments) { - switch (attachment.Kind) + var url = ToAbsoluteUrl(attachment.Url, publicBaseUrl); + var tag = attachment.Kind switch { - case AttachmentKind.Image: - lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}"); - if (attachment.AsciiPreview is not null && !IsCiphertext(attachment.AsciiPreview)) - { - 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; - } + AttachmentKind.Image => $"[Image: {attachment.FileName}]", + AttachmentKind.Audio => $"\u266a [Audio: {attachment.FileName}]", + _ => $"[File: {attachment.FileName}]", + }; + lines.Add($"{prefix} PRIVMSG {ircChannel} :{tag} {url}"); } } @@ -69,13 +54,15 @@ public static partial class IrcMessageFormatter } /// - /// True when a preview is still encrypted — transport ($ENC$v1$) if a broadcast path - /// forgot to decrypt it, or E2E room ciphertext ($RC1$) the server cannot decrypt. - /// Emitting it would flood IRC clients with a multi-KB base64 blob. + /// Joins a relative attachment path onto the configured public base URL. + /// Already-absolute URLs and unset base URLs pass through unchanged. /// - private static bool IsCiphertext(string text) => - text.StartsWith(IMessageEncryptionService.CiphertextPrefix, StringComparison.Ordinal) - || text.StartsWith(RoomCrypto.CiphertextPrefix, StringComparison.Ordinal); + public static string ToAbsoluteUrl(string url, string? publicBaseUrl) + { + if (string.IsNullOrWhiteSpace(publicBaseUrl) || Uri.IsWellFormedUriString(url, UriKind.Absolute)) + return url; + return $"{publicBaseUrl.TrimEnd('/')}/{url.TrimStart('/')}"; + } /// /// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail). @@ -104,35 +91,6 @@ public static partial class IrcMessageFormatter return lines; } - /// - /// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients. - /// Also passes through content that already uses ANSI codes unchanged. - /// - public static string ColorTagsToAnsi(string text) - { - if (!text.Contains('{')) - return text; - - return ColorTagRegex().Replace(text, match => - { - if (match.Groups[1].Success) // {X} reset - return "\x1b[0m"; - if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB} - { - var hex = match.Groups[3].Value; - var r = Convert.ToInt32(hex[..2], 16); - var g = Convert.ToInt32(hex[2..4], 16); - var b = Convert.ToInt32(hex[4..6], 16); - var code = match.Groups[2].Value == "F" ? "38" : "48"; - return $"\x1b[{code};2;{r};{g};{b}m"; - } - return match.Value; - }); - } - - [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] - private static partial Regex ColorTagRegex(); - /// /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. /// diff --git a/src/EchoHub.Server.Irc/IrcOptions.cs b/src/EchoHub.Server.Irc/IrcOptions.cs index 2bfa9bd..29b112a 100644 --- a/src/EchoHub.Server.Irc/IrcOptions.cs +++ b/src/EchoHub.Server.Irc/IrcOptions.cs @@ -12,4 +12,11 @@ public sealed class IrcOptions public string? TlsCertPassword { get; set; } public string ServerName { get; set; } = "echohub"; public string? Motd { get; set; } + + /// + /// Public HTTP(S) base of this EchoHub server (e.g. "https://chat.example.com"), + /// used to turn relative attachment URLs into absolute links IRC clients can open. + /// When unset, attachment lines fall back to the relative path. + /// + public string? PublicBaseUrl { get; set; } } diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs index 42db03f..914631d 100644 --- a/src/EchoHub.Server/Controllers/FilesController.cs +++ b/src/EchoHub.Server/Controllers/FilesController.cs @@ -19,7 +19,14 @@ public class FilesController : ControllerBase _fileStorage = fileStorage; } + /// + /// Serves an uploaded file. Anonymous by design: the unguessable GUID in the URL is the + /// access token (Discord-CDN-style capability URL), so attachment links can be opened + /// directly in a browser and shared to IRC clients. E2E-encrypted room blobs are + /// ciphertext at rest, so anonymous access reveals nothing for those channels. + /// [HttpGet("{fileId}")] + [AllowAnonymous] public IActionResult GetFile(string fileId) { if (!Guid.TryParse(fileId, out _)) @@ -48,6 +55,11 @@ public class FilesController : ControllerBase _ => "application/octet-stream" }; + // Images and audio render inline so a browser displays them instead of + // downloading; everything else keeps the attachment disposition. + if (contentType.StartsWith("image/") || contentType.StartsWith("audio/")) + return PhysicalFile(filePath, contentType); + var fileName = Path.GetFileName(filePath); return PhysicalFile(filePath, contentType, fileName); } diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index 8f0cc68..5af637c 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -103,7 +103,7 @@ public class ChatHub : Hub { try { - var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content); + var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content, Context.ConnectionId); if (error is not null) await Clients.Caller.Error(error); } diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 11004ef..eb471c1 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -151,7 +151,7 @@ public class ChatService : IChatService _logger.LogInformation("{User} left channel '{Channel}'", username, channelName); } - public async Task SendMessageAsync(Guid userId, string username, string channelName, string content) + public async Task SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null) { channelName = channelName.ToLowerInvariant().Trim(); @@ -240,7 +240,7 @@ public class ChatService : IChatService Embeds: embeds, SenderDisplayName: sender?.DisplayName); - await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); + await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto, originConnectionId)); _logger.LogDebug("{User} sent message in '{Channel}'", username, channelName); return null; diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs index 7e4165d..f630f23 100644 --- a/src/EchoHub.Server/Services/SignalRBroadcaster.cs +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -20,7 +20,9 @@ public class SignalRBroadcaster : IChatBroadcaster _presenceTracker = presenceTracker; } - public Task SendMessageToChannelAsync(string channelName, MessageDto message) + // The exclusion only applies to the IRC gateway (SignalR clients render their own + // message from the broadcast echo), so the id is ignored here. + public Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null) => HubContext.Clients.Group(channelName).ReceiveMessage(message); public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null) diff --git a/src/EchoHub.Tests/DataMigrationServiceTests.cs b/src/EchoHub.Tests/DataMigrationServiceTests.cs index 31c2198..6f40725 100644 --- a/src/EchoHub.Tests/DataMigrationServiceTests.cs +++ b/src/EchoHub.Tests/DataMigrationServiceTests.cs @@ -62,13 +62,11 @@ public class DataMigrationServiceTests } [Fact] - public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi() + public void AnsiToColorTags_ForegroundBackgroundAndReset_AllConverted() { - // AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses - var original = "{F:FF0000}red{B:00FF00}green{X}"; - var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original); + var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;255;0mgreen\x1b[0m"; var backToTags = DataMigrationService.AnsiToColorTags(ansi); - Assert.Equal(original, backToTags); + Assert.Equal("{F:FF0000}red{B:00FF00}green{X}", backToTags); } } diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs index 0e08d82..a7c25b4 100644 --- a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs @@ -90,7 +90,7 @@ public class IrcBroadcasterTests } [Fact] - public async Task SendMessage_DecryptsAttachmentAsciiPreview() + public async Task SendMessage_ImageAttachment_SendsLinkLineOnly() { var (_, stream) = AddConnectionWithCapture("bob", "general"); @@ -104,30 +104,47 @@ public class IrcBroadcasterTests await _broadcaster.SendMessageToChannelAsync("general", message); var output = stream.GetOutputLines(); - Assert.Contains(output, l => l.Contains("[Image: photo.png]")); - Assert.Contains(output, l => l.Contains("line1")); - Assert.Contains(output, l => l.Contains("line2")); + Assert.Contains(output, l => l.Contains("[Image: photo.png]") && l.Contains("/api/files/abc")); + // ASCII preview art is never sent to IRC clients — images are links only + Assert.DoesNotContain(output, l => l.Contains("line1")); Assert.DoesNotContain(output, l => l.Contains("$ENC$")); } [Fact] - public async Task SendMessage_SkipsSender() + public async Task SendMessage_SkipsOnlyOriginConnection() { - var (_, aliceStream) = AddConnectionWithCapture("alice", "general"); + var (aliceConn, aliceStream) = AddConnectionWithCapture("alice", "general"); var (_, bobStream) = AddConnectionWithCapture("bob", "general"); var message = new MessageDto( Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow); - await _broadcaster.SendMessageToChannelAsync("general", message); + await _broadcaster.SendMessageToChannelAsync("general", message, aliceConn.ConnectionId); - // Alice (sender) should NOT receive the message + // The connection that sent it should NOT get an echo Assert.Empty(aliceStream.GetOutputLines()); // Bob should receive it Assert.NotEmpty(bobStream.GetOutputLines()); } + [Fact] + public async Task SendMessage_SendersOtherSessionsStillReceive() + { + // Same account online twice (e.g. TUI + IRC, or two IRC clients): a message sent + // from one session must still reach the other — skipping by nickname used to + // swallow these until the IRC client reconnected. + var (_, ircStream) = AddConnectionWithCapture("alice", "general"); + + var message = new MessageDto( + Guid.NewGuid(), _encryption.Encrypt("sent from the TUI"), "alice", null, "general", DateTimeOffset.UtcNow); + + // Origin is a SignalR connection, not this IRC one + await _broadcaster.SendMessageToChannelAsync("general", message, "signalr-conn-123"); + + Assert.Contains(ircStream.GetOutputLines(), l => l.Contains("sent from the TUI")); + } + [Fact] public async Task SendMessage_OnlySendsToChannelMembers() { diff --git a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs index 29736bc..c393397 100644 --- a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs @@ -118,37 +118,50 @@ public class IrcMessageFormatterTests } [Fact] - public void FormatMessage_ImageMessage_IncludesAsciiArt() + public void FormatMessage_ImageMessage_NeverEmitsAsciiArt() { - var msg = CreateImageMessage("line1\nline2"); + // Images are shared as plain links (the common IRC practice) — never color art, + // regardless of what the preview contains. + var msg = CreateImageMessage("{F:FF0000}█{X}\nline2"); var lines = IrcMessageFormatter.FormatMessage(msg); - Assert.Contains(lines, l => l.Contains("line1")); - Assert.Contains(lines, l => l.Contains("line2")); + Assert.Single(lines); + Assert.Contains("[Image: image.png]", lines[0]); + Assert.DoesNotContain(lines, l => l.Contains("line2")); } [Fact] - public void FormatMessage_ImageMessage_SkipsEmptyAsciiLines() + public void FormatMessage_RelativeUrl_JoinedWithPublicBaseUrl() { - var msg = CreateImageMessage("line1\n\nline2"); - var lines = IrcMessageFormatter.FormatMessage(msg); + var msg = CreateImageMessage("art", "photo.jpg", "/api/files/abc"); + var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com"); - // Empty lines should be skipped - var asciiLines = lines.Where(l => !l.Contains("[Image:") && !l.Contains("Download:")).ToList(); - Assert.Equal(2, asciiLines.Count); + Assert.Single(lines); + Assert.Contains("https://chat.example.com/api/files/abc", lines[0]); } - [Theory] - [InlineData("$ENC$v1$abc123$def456")] // transport ciphertext a broadcast path forgot to decrypt - [InlineData("$RC1$abc123def456")] // E2E room ciphertext the server cannot decrypt - public void FormatMessage_ImageMessage_SkipsCiphertextPreview(string ciphertextPreview) + [Fact] + public void FormatMessage_AbsoluteUrl_NotRewrittenByPublicBaseUrl() { - var msg = CreateImageMessage(ciphertextPreview, "photo.jpg", "https://example.com/photo.jpg"); - var lines = IrcMessageFormatter.FormatMessage(msg); + var msg = CreateImageMessage("art", "photo.jpg", "https://cdn.example.com/photo.jpg"); + var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com"); - // Only the [Image: ...] header line — never the ciphertext blob - Assert.Single(lines); - Assert.Contains("[Image: photo.jpg]", lines[0]); + Assert.Contains("https://cdn.example.com/photo.jpg", lines[0]); + Assert.DoesNotContain("https://chat.example.com", lines[0]); + } + + [Fact] + public void ToAbsoluteUrl_NoBaseUrl_ReturnsRelativeUnchanged() + { + Assert.Equal("/api/files/abc", IrcMessageFormatter.ToAbsoluteUrl("/api/files/abc", null)); + Assert.Equal("/api/files/abc", IrcMessageFormatter.ToAbsoluteUrl("/api/files/abc", " ")); + } + + [Fact] + public void ToAbsoluteUrl_TrailingSlashBase_JoinsWithoutDoubleSlash() + { + Assert.Equal("https://x.example/api/files/1", + IrcMessageFormatter.ToAbsoluteUrl("/api/files/1", "https://x.example/")); } [Fact] @@ -244,62 +257,4 @@ public class IrcMessageFormatterTests } } - // ── ColorTagsToAnsi ────────────────────────────────────────────────── - - [Fact] - public void ColorTagsToAnsi_NoTags_ReturnsUnchanged() - { - Assert.Equal("Hello world", IrcMessageFormatter.ColorTagsToAnsi("Hello world")); - } - - [Fact] - public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsi() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red text"); - Assert.Equal("\x1b[38;2;255;0;0mRed text", result); - } - - [Fact] - public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsi() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}Green bg"); - Assert.Equal("\x1b[48;2;0;255;0mGreen bg", result); - } - - [Fact] - public void ColorTagsToAnsi_ResetTag_ConvertsToReset() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red{X} Normal"); - Assert.Equal("\x1b[38;2;255;0;0mRed\x1b[0m Normal", result); - } - - [Fact] - public void ColorTagsToAnsi_MultipleTags_ConvertsAll() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red {F:0000FF}Blue{X}"); - Assert.Contains("\x1b[38;2;255;0;0m", result); - Assert.Contains("\x1b[38;2;0;0;255m", result); - Assert.Contains("\x1b[0m", result); - } - - [Fact] - public void ColorTagsToAnsi_LowercaseHex_ConvertsCorrectly() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:ff8800}text"); - Assert.Equal("\x1b[38;2;255;136;0mtext", result); - } - - [Fact] - public void ColorTagsToAnsi_NoBraces_SkipsProcessing() - { - var text = "plain text without braces"; - Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text)); - } - - [Fact] - public void ColorTagsToAnsi_ExistingAnsiCodes_PreservesUnchanged() - { - var text = "\x1b[31mAlready colored\x1b[0m"; - Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text)); - } } diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 760c47e..fea18b6 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -188,7 +188,7 @@ internal sealed class FakeChatService : IChatService return Task.CompletedTask; } - public Task SendMessageAsync(Guid userId, string username, string channelName, string content) + public Task SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null) { SentMessages.Add((channelName, content)); return Task.FromResult(SendMessageError); diff --git a/src/EchoHub.Tests/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/IrcMessageFormatterTests.cs index a2521c3..38007c1 100644 --- a/src/EchoHub.Tests/IrcMessageFormatterTests.cs +++ b/src/EchoHub.Tests/IrcMessageFormatterTests.cs @@ -59,11 +59,24 @@ public class IrcMessageFormatterTests attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]); var lines = IrcMessageFormatter.FormatMessage(msg); - Assert.True(lines.Count >= 2); + // Images are a single link line — the ASCII preview is never sent to IRC clients + Assert.Single(lines); Assert.Contains("[Image: photo.png]", lines[0]); Assert.Contains("/api/files/abc", lines[0]); } + [Fact] + public void FormatMessage_WithPublicBaseUrl_EmitsAbsoluteAttachmentLinks() + { + var msg = CreateMessage( + content: "", + attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, null)]); + var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com/"); + + Assert.Single(lines); + Assert.Contains("https://chat.example.com/api/files/abc", lines[0]); + } + [Fact] public void FormatMessage_FileAttachment_IncludesFileTag() { @@ -121,48 +134,6 @@ public class IrcMessageFormatterTests Assert.Contains(lines, l => l.Contains("[File: c.pdf]")); } - // ── ColorTagsToAnsi ─────────────────────────────────────────────── - - [Fact] - public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsiEscape() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}text"); - Assert.Contains("\x1b[38;2;255;0;0m", result); - Assert.Contains("text", result); - } - - [Fact] - public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsiEscape() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}text"); - Assert.Contains("\x1b[48;2;0;255;0m", result); - } - - [Fact] - public void ColorTagsToAnsi_ResetTag_ConvertsToAnsiReset() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{X}"); - Assert.Equal("\x1b[0m", result); - } - - [Fact] - public void ColorTagsToAnsi_NoTags_ReturnsUnchanged() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("plain text"); - Assert.Equal("plain text", result); - } - - [Fact] - public void ColorTagsToAnsi_MultipleTags_ConvertsAll() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}red{F:0000FF}blue{X}"); - Assert.Contains("\x1b[38;2;255;0;0m", result); - Assert.Contains("\x1b[38;2;0;0;255m", result); - Assert.Contains("\x1b[0m", result); - Assert.Contains("red", result); - Assert.Contains("blue", result); - } - // ── SplitMessage ────────────────────────────────────────────────── [Fact]