From 2d33773c244f00d6befb1fdba01d1c115cf9afbf Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 05:02:12 +0200 Subject: [PATCH] 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]); }