mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
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.
This commit is contained in:
@@ -227,6 +227,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
| ------- | ----------- |
|
| ------- | ----------- |
|
||||||
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
|
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
|
||||||
| `/passwd <old> <new>` | Change the current encrypted channel's passphrase (creator only) |
|
| `/passwd <old> <new>` | Change the current encrypted channel's passphrase (creator only) |
|
||||||
|
| `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
|
||||||
| `/leave` | Leave current channel |
|
| `/leave` | Leave current channel |
|
||||||
| `/topic <text>` | Set channel topic (creator only) |
|
| `/topic <text>` | Set channel topic (creator only) |
|
||||||
| `/send <file or URL>` | Upload a file or image |
|
| `/send <file or URL>` | Upload a file or image |
|
||||||
|
|||||||
@@ -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.
|
- 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
|
- 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)
|
- 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 <file>` 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 <URL>` 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 <path>` 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 <channel> [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
|
- `/join <channel> [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 <key>` works against room passwords (RFC 1459 comma-paired key lists supported); keyless or wrong-key joins get `475 ERR_BADCHANNELKEY`
|
- IRC channel keys — `JOIN #room <key>` 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 <key>` 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 `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k <key>` 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)
|
- 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
|
- 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
|
||||||
- Ctrl+V pastes into the message input (previously paste was only available via the right-click menu); Ctrl+Y works as an alias
|
|
||||||
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
|
- 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.
|
- 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)
|
- 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
|
- 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
|
- `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
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Lock _channelUsersLock = new();
|
private readonly Lock _channelUsersLock = new();
|
||||||
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly List<string> _stagedAttachments = [];
|
||||||
|
|
||||||
private ClientConfig _config;
|
private ClientConfig _config;
|
||||||
private readonly UserSession _session = new();
|
private readonly UserSession _session = new();
|
||||||
@@ -91,6 +92,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||||
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
||||||
|
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
||||||
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||||
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
||||||
@@ -113,6 +115,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_commandHandler.OnOpenServers += HandleCmdOpenServers;
|
_commandHandler.OnOpenServers += HandleCmdOpenServers;
|
||||||
_commandHandler.OnJoinChannel += HandleCmdJoinChannel;
|
_commandHandler.OnJoinChannel += HandleCmdJoinChannel;
|
||||||
_commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword;
|
_commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword;
|
||||||
|
_commandHandler.OnClearAttachments += HandleCmdClearAttachments;
|
||||||
|
_commandHandler.OnSetDownloadPath += HandleCmdSetDownloadPath;
|
||||||
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
|
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
|
||||||
_commandHandler.OnSetTopic += HandleCmdSetTopic;
|
_commandHandler.OnSetTopic += HandleCmdSetTopic;
|
||||||
_commandHandler.OnListUsers += HandleCmdListUsers;
|
_commandHandler.OnListUsers += HandleCmdListUsers;
|
||||||
@@ -162,85 +166,107 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return Task.CompletedTask;
|
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;
|
var channel = _mainWindow.CurrentChannel;
|
||||||
if (string.IsNullOrEmpty(channel)) return;
|
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
|
|
||||||
|
|
||||||
|
// 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)
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
{
|
{
|
||||||
if (hasRoomKey)
|
if (_conn.RoomKeys.HasKey(channel))
|
||||||
{
|
{
|
||||||
InvokeUI(() => _mainWindow.ShowError(
|
InvokeUI(() => _mainWindow.ShowError(
|
||||||
"Sending by URL isn't available in encrypted channels — download the file and /send it instead."));
|
"Sending by URL isn't available in encrypted channels — download the file and /send it instead."));
|
||||||
return;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _conn.Api!.SendUrlAsync(channel, target, size);
|
RunAsync(async () => await _conn.Api!.SendUrlAsync(channel, target, size), "Send failed");
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
else if (hasRoomKey)
|
|
||||||
|
// Local files are staged; the next Enter sends them with the typed caption as one message.
|
||||||
|
if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage)
|
||||||
{
|
{
|
||||||
await UploadEncryptedFileAsync(channel, target, size, roomKey);
|
InvokeUI(() => _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message."));
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
_stagedAttachments.Add(target);
|
||||||
|
InvokeUI(() => _mainWindow.SetStagedAttachments(_stagedAttachments.Select(Path.GetFileName).OfType<string>().ToList()));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleCmdClearAttachments()
|
||||||
{
|
{
|
||||||
await using var stream = File.OpenRead(target);
|
_stagedAttachments.Clear();
|
||||||
var fileName = Path.GetFileName(target);
|
InvokeUI(() => _mainWindow.SetStagedAttachments([]));
|
||||||
await _conn.Api!.UploadFileAsync(channel, stream, fileName, size);
|
return Task.CompletedTask;
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "File send failed for {Target}", target);
|
|
||||||
InvokeUI(() => _mainWindow.ShowError($"Send failed: {ex.Message}"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Upload into an end-to-end encrypted channel: the blob is encrypted with the room
|
/// Sends one message with the given caption plus all staged files as attachments, then
|
||||||
/// key before it leaves this machine, and for images the ASCII preview is rendered
|
/// clears the staging tray. In encrypted channels each file is room-encrypted (blob +
|
||||||
/// locally and sent room-encrypted — the server never sees image or file contents.
|
/// ASCII preview) client-side before upload; the caption is room-encrypted too.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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<OutgoingAttachment>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a staged file into an <see cref="OutgoingAttachment"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<OutgoingAttachment> BuildOutgoingAttachmentAsync(string path, byte[]? roomKey)
|
||||||
{
|
{
|
||||||
var fileName = Path.GetFileName(path);
|
var fileName = Path.GetFileName(path);
|
||||||
var bytes = await File.ReadAllBytesAsync(path);
|
|
||||||
|
|
||||||
string declaredType;
|
if (roomKey is null)
|
||||||
string plainContent;
|
return new OutgoingAttachment(File.OpenRead(path), fileName);
|
||||||
|
|
||||||
|
var bytes = await File.ReadAllBytesAsync(path);
|
||||||
|
string declaredKind;
|
||||||
|
string? preview = null;
|
||||||
|
|
||||||
using (var ms = new MemoryStream(bytes))
|
using (var ms = new MemoryStream(bytes))
|
||||||
{
|
{
|
||||||
if (FileValidationHelper.IsValidImage(ms))
|
if (FileValidationHelper.IsValidImage(ms))
|
||||||
{
|
{
|
||||||
declaredType = "image";
|
declaredKind = "image";
|
||||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
var (w, h) = ImageToAsciiService.GetDimensions(null);
|
||||||
ms.Position = 0;
|
ms.Position = 0;
|
||||||
plainContent = new ImageToAsciiService().ConvertToAscii(ms, w, h);
|
preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey);
|
||||||
}
|
|
||||||
else if (FileValidationHelper.IsAudioFile(fileName))
|
|
||||||
{
|
|
||||||
declaredType = "audio";
|
|
||||||
plainContent = fileName;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
declaredType = "file";
|
declaredKind = FileValidationHelper.IsAudioFile(fileName) ? "audio" : "file";
|
||||||
plainContent = fileName;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var encryptedContent = RoomCrypto.EncryptText(plainContent, roomKey);
|
|
||||||
var encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey);
|
var encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey);
|
||||||
|
return new OutgoingAttachment(new MemoryStream(encryptedBlob), fileName, declaredKind, preview);
|
||||||
await using var blobStream = new MemoryStream(encryptedBlob);
|
|
||||||
await _conn.Api!.UploadFileAsync(channel, blobStream, fileName, size, declaredType, encryptedContent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleCmdSetAvatar(string target)
|
private async Task HandleCmdSetAvatar(string target)
|
||||||
@@ -893,11 +919,28 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Staged files → one message with the typed caption plus those attachments.
|
||||||
|
if (_stagedAttachments.Count > 0)
|
||||||
|
{
|
||||||
|
SendStagedMessage(channelName, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
RunAsync(
|
RunAsync(
|
||||||
async () => await _conn.SendMessageAsync(channelName, content),
|
async () => await _conn.SendMessageAsync(channelName, content),
|
||||||
"Send failed");
|
"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)
|
private void HandleChannelSelected(string channelName)
|
||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
@@ -1319,20 +1362,46 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||||
|
|
||||||
var downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
var destination = DedupPath(GetDownloadDir(), fileName);
|
||||||
Directory.CreateDirectory(downloads);
|
|
||||||
|
|
||||||
var stem = Path.GetFileNameWithoutExtension(fileName);
|
|
||||||
var ext = Path.GetExtension(fileName);
|
|
||||||
var destination = Path.Combine(downloads, fileName);
|
|
||||||
for (var i = 1; File.Exists(destination); i++)
|
|
||||||
destination = Path.Combine(downloads, $"{stem} ({i}){ext}");
|
|
||||||
|
|
||||||
File.Move(tempPath, destination);
|
File.Move(tempPath, destination);
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Image saved to: {destination}"));
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Image saved to: {destination}"));
|
||||||
}, "Failed to save image");
|
}, "Failed to save image");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the folder downloads are written to: the user's configured
|
||||||
|
/// <see cref="ClientConfig.DownloadPath"/> if set, otherwise the OS Downloads folder.
|
||||||
|
/// Falls back to the temp folder if neither can be created.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends " (n)" before the extension until the path doesn't collide with an existing file.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// File extensions considered safe to open with the system default application.
|
/// File extensions considered safe to open with the system default application.
|
||||||
/// Everything else is downloaded only — never auto-opened via UseShellExecute.
|
/// 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}..."));
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||||
|
|
||||||
var ext = Path.GetExtension(fileName);
|
var destination = DedupPath(GetDownloadDir(), fileName);
|
||||||
if (SafeOpenExtensions.Contains(ext))
|
File.Move(tempPath, destination);
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Saved to: {destination}"));
|
||||||
|
|
||||||
|
if (SafeOpenExtensions.Contains(Path.GetExtension(fileName)))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
var psi = new System.Diagnostics.ProcessStartInfo(destination) { UseShellExecute = true };
|
||||||
System.Diagnostics.Process.Start(psi);
|
System.Diagnostics.Process.Start(psi);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
|
Log.Warning(ex, "Failed to open file with default app: {Path}", destination);
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
|
||||||
}
|
|
||||||
}, "Failed to download file");
|
}, "Failed to download file");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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 <path>");
|
||||||
|
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()
|
private void HandleCheckForUpdatesRequested()
|
||||||
{
|
{
|
||||||
RunAsync(_updateService.CheckNowAsync, "Failed to check for updates");
|
RunAsync(_updateService.CheckNowAsync, "Failed to check for updates");
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public class CommandHandler
|
|||||||
public event Func<Task>? OnOpenServers;
|
public event Func<Task>? OnOpenServers;
|
||||||
public event Func<string, string?, Task>? OnJoinChannel;
|
public event Func<string, string?, Task>? OnJoinChannel;
|
||||||
public event Func<string, string, Task>? OnChangeRoomPassword;
|
public event Func<string, string, Task>? OnChangeRoomPassword;
|
||||||
|
public event Func<Task>? OnClearAttachments;
|
||||||
|
public event Func<string, Task>? OnSetDownloadPath;
|
||||||
public event Func<Task>? OnLeaveChannel;
|
public event Func<Task>? OnLeaveChannel;
|
||||||
public event Func<string, Task>? OnSetTopic;
|
public event Func<string, Task>? OnSetTopic;
|
||||||
public event Func<Task>? OnListUsers;
|
public event Func<Task>? OnListUsers;
|
||||||
@@ -48,6 +50,8 @@ public class CommandHandler
|
|||||||
"color" => await HandleColor(args),
|
"color" => await HandleColor(args),
|
||||||
"theme" => await HandleTheme(args),
|
"theme" => await HandleTheme(args),
|
||||||
"send" => await HandleSend(args),
|
"send" => await HandleSend(args),
|
||||||
|
"clear" => await HandleClear(),
|
||||||
|
"downloadpath" or "downloads" => await HandleDownloadPath(args),
|
||||||
"profile" => await HandleProfile(args),
|
"profile" => await HandleProfile(args),
|
||||||
"avatar" => await HandleAvatar(args),
|
"avatar" => await HandleAvatar(args),
|
||||||
"servers" => await HandleServers(),
|
"servers" => await HandleServers(),
|
||||||
@@ -165,6 +169,21 @@ public class CommandHandler
|
|||||||
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleClear()
|
||||||
|
{
|
||||||
|
if (OnClearAttachments is not null)
|
||||||
|
await OnClearAttachments();
|
||||||
|
return new CommandResult(true, "Cleared staged attachments.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> 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<CommandResult> HandleProfile(string args)
|
private async Task<CommandResult> HandleProfile(string args)
|
||||||
{
|
{
|
||||||
var username = string.IsNullOrWhiteSpace(args) ? null : args.Trim();
|
var username = string.IsNullOrWhiteSpace(args) ? null : args.Trim();
|
||||||
@@ -358,7 +377,11 @@ public class CommandHandler
|
|||||||
/nick <name> - Set display name
|
/nick <name> - Set display name
|
||||||
/color <#hex> - Set nickname color
|
/color <#hex> - Set nickname color
|
||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
/send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
|
/send <filepath> [-s|-m|-l] - Stage a file to attach (Enter sends with your text)
|
||||||
|
/send <URL> [-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 <URL or filepath> - Set your avatar
|
/avatar <URL or filepath> - Set your avatar
|
||||||
/profile [username] - View a profile
|
/profile [username] - View a profile
|
||||||
/servers - Open saved servers
|
/servers - Open saved servers
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ public class ClientConfig
|
|||||||
public AccountPreset DefaultPreset { get; set; } = new();
|
public AccountPreset DefaultPreset { get; set; } = new();
|
||||||
public string ActiveTheme { get; set; } = "Default";
|
public string ActiveTheme { get; set; } = "Default";
|
||||||
public NotificationConfig Notifications { get; set; } = new();
|
public NotificationConfig Notifications { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folder where downloaded attachments and saved images are written. When null, the
|
||||||
|
/// OS Downloads folder is used. Set via the native folder picker or <c>/downloadpath</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? DownloadPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class NotificationConfig
|
public class NotificationConfig
|
||||||
|
|||||||
@@ -185,25 +185,35 @@ public sealed class ApiClient : IDisposable
|
|||||||
return result?.AvatarAscii;
|
return result?.AvatarAscii;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null,
|
/// <summary>
|
||||||
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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<MessageDto?> SendMessageWithAttachmentsAsync(
|
||||||
|
string channelName, string content, IReadOnlyList<OutgoingAttachment> attachments, string? size = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
using var content = new MultipartFormDataContent();
|
using var form = new MultipartFormDataContent { { new StringContent(content), "content" } };
|
||||||
using var streamContent = new StreamContent(fileStream);
|
|
||||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
|
||||||
content.Add(streamContent, "file", fileName);
|
|
||||||
|
|
||||||
// E2E channels: the blob is ciphertext, so the client declares the type and
|
foreach (var att in attachments)
|
||||||
// supplies the room-encrypted message content the server can't produce.
|
{
|
||||||
if (declaredType is not null)
|
var streamContent = new StreamContent(att.Stream);
|
||||||
content.Add(new StringContent(declaredType), "type");
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(att.FileName));
|
||||||
if (encryptedContent is not null)
|
form.Add(streamContent, "file", att.FileName);
|
||||||
content.Add(new StringContent(encryptedContent), "content");
|
|
||||||
|
// 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}" : "";
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
using var response = await AuthenticatedRequestAsync(() =>
|
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);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class ClipboardFiles
|
||||||
|
{
|
||||||
|
public static bool TryGetFiles(out List<string> 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<string> 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<string> 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<string> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -207,28 +207,41 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Strips the transport encryption, then the room layer for E2E channels.
|
/// Strips the transport encryption, then the room layer for E2E channels, from the
|
||||||
/// Without the room key the content is replaced by a locked placeholder —
|
/// message content and every attachment preview. Without the room key the content is
|
||||||
/// re-fetch history after unlocking to render it.
|
/// replaced by a locked placeholder — re-fetch history after unlocking to render it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private MessageDto DecryptMessage(MessageDto message)
|
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<AttachmentDto>? attachments = null;
|
||||||
|
if (message.Attachments is { Count: > 0 })
|
||||||
{
|
{
|
||||||
if (_roomKeys.TryGetKey(message.ChannelName, out var roomKey)
|
attachments = message.Attachments
|
||||||
&& RoomCrypto.TryDecryptText(content, roomKey, out var plaintext))
|
.Select(a => a with { AsciiPreview = a.AsciiPreview is null ? null : DecryptField(a.AsciiPreview, roomKey) })
|
||||||
{
|
.ToList();
|
||||||
content = plaintext;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
content = LockedMessagePlaceholder;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return message with { Content = content };
|
return message with { Content = content, Attachments = attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
public async ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public enum PickerOutcome
|
||||||
|
{
|
||||||
|
/// <summary>The user picked a folder (<see cref="FolderPickResult.Path"/> is set).</summary>
|
||||||
|
Chosen,
|
||||||
|
|
||||||
|
/// <summary>The native dialog ran but the user cancelled it.</summary>
|
||||||
|
Cancelled,
|
||||||
|
|
||||||
|
/// <summary>No native picker is available on this machine (headless, missing tool, etc.).</summary>
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FolderPickResult(PickerOutcome Outcome, string? Path);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="PickerOutcome.Unavailable"/>
|
||||||
|
/// when no native dialog can run, so callers can fall back to a configured path.
|
||||||
|
/// </summary>
|
||||||
|
public static class NativeFolderPicker
|
||||||
|
{
|
||||||
|
private const string Title = "Choose your EchoHub download folder";
|
||||||
|
|
||||||
|
public static async Task<FolderPickResult> 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<FolderPickResult> 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<FolderPickResult> 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<FolderPickResult> 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<string> { "--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<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One file to upload as part of a message. For end-to-end encrypted channels the stream
|
||||||
|
/// is already ciphertext, <see cref="DeclaredKind"/> is set (image/audio/file), and
|
||||||
|
/// <see cref="EncryptedPreview"/> holds the room-encrypted ASCII art for images.
|
||||||
|
/// For normal channels only <see cref="Stream"/> and <see cref="FileName"/> are set.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OutgoingAttachment(
|
||||||
|
Stream Stream,
|
||||||
|
string FileName,
|
||||||
|
string? DeclaredKind = null,
|
||||||
|
string? EncryptedPreview = null);
|
||||||
@@ -18,7 +18,7 @@ public partial class ChatLine
|
|||||||
public bool IsMention { get; set; }
|
public bool IsMention { get; set; }
|
||||||
public string? AttachmentUrl { get; set; }
|
public string? AttachmentUrl { get; set; }
|
||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
public MessageType? Type { get; set; }
|
public AttachmentKind? AttachmentKind { get; set; }
|
||||||
public string? SenderUsername { get; set; }
|
public string? SenderUsername { get; set; }
|
||||||
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||||
public int ContinuationIndent { get; set; }
|
public int ContinuationIndent { get; set; }
|
||||||
@@ -124,7 +124,7 @@ public partial class ChatLine
|
|||||||
{
|
{
|
||||||
wrapped.AttachmentUrl = AttachmentUrl;
|
wrapped.AttachmentUrl = AttachmentUrl;
|
||||||
wrapped.AttachmentFileName = AttachmentFileName;
|
wrapped.AttachmentFileName = AttachmentFileName;
|
||||||
wrapped.Type = Type;
|
wrapped.AttachmentKind = AttachmentKind;
|
||||||
wrapped.MessageId = MessageId;
|
wrapped.MessageId = MessageId;
|
||||||
wrapped.SenderUsername = SenderUsername;
|
wrapped.SenderUsername = SenderUsername;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,87 +238,78 @@ public sealed class ChatMessageManager
|
|||||||
var senderName = message.SenderUsername + ":";
|
var senderName = message.SenderUsername + ":";
|
||||||
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
||||||
|
|
||||||
|
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||||
|
var pad = new string(' ', 7);
|
||||||
|
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
|
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 trimmed = artLine.TrimEnd('\r');
|
|
||||||
if (ChatLine.HasColorTags(trimmed))
|
|
||||||
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
|
||||||
else
|
|
||||||
lines.Add(new ChatLine($" {trimmed}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clickable action to download the original image below the ASCII art
|
|
||||||
if (message.AttachmentUrl is not null)
|
|
||||||
{
|
|
||||||
var imageName = message.AttachmentFileName ?? "image";
|
|
||||||
var imageSize = FormatFileSize(message.AttachmentFileSize);
|
|
||||||
var saveLine = new ChatLine(new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new(" ", null),
|
|
||||||
new($"[↓ save original] {imageName} [{imageSize}]", ChatColors.FileAttr),
|
|
||||||
});
|
|
||||||
saveLine.AttachmentUrl = message.AttachmentUrl;
|
|
||||||
saveLine.AttachmentFileName = imageName;
|
|
||||||
saveLine.Type = MessageType.Image;
|
|
||||||
lines.Add(saveLine);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.Audio:
|
|
||||||
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;
|
|
||||||
|
|
||||||
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 displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
var contentLines = displayContent.Split('\n');
|
var contentLines = displayContent.Split('\n');
|
||||||
var firstLine = contentLines[0].TrimEnd('\r');
|
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {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++)
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
|
lines.Add(new ChatLine(ChatColors.SplitMentions($"{indent}{contentLines[i].TrimEnd('\r')}")));
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
var summary = attachments.Count switch
|
||||||
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
{
|
||||||
|
0 => " ",
|
||||||
|
1 => $" [{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
||||||
|
_ => $" [{attachments.Count} attachments]",
|
||||||
|
};
|
||||||
|
lines.Add(BuildChatLine(time, senderName, senderColor, summary));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var l in lines)
|
foreach (var l in lines)
|
||||||
l.ContinuationIndent = indent.Length;
|
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))
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
case Core.Models.AttachmentKind.Audio:
|
||||||
|
lines.Add(AttachmentActionLine(pad,
|
||||||
|
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
|
ChatColors.AudioAttr, attachment));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
lines.Add(AttachmentActionLine(pad,
|
||||||
|
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
|
ChatColors.FileAttr, attachment));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link embeds (from caption URLs)
|
||||||
if (message.Embeds is { Count: > 0 })
|
if (message.Embeds is { Count: > 0 })
|
||||||
{
|
{
|
||||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||||
foreach (var embed in message.Embeds)
|
foreach (var embed in message.Embeds)
|
||||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
{
|
{
|
||||||
@@ -326,7 +317,7 @@ public sealed class ChatMessageManager
|
|||||||
line.SenderUsername = message.SenderUsername;
|
line.SenderUsername = message.SenderUsername;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
if (hasContent && !string.IsNullOrEmpty(_currentUser))
|
||||||
{
|
{
|
||||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||||
@@ -339,6 +330,23 @@ public sealed class ChatMessageManager
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||||
|
/// route activation (play audio, download file, save original image).
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine AttachmentActionLine(string pad, string text, Attribute color, AttachmentDto attachment)
|
||||||
|
{
|
||||||
|
var line = new ChatLine(new List<ChatSegment>
|
||||||
|
{
|
||||||
|
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)
|
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
var segments = new List<ChatSegment>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public static class DroppedFileParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cheap pre-check so callers only stat the filesystem when the input plausibly holds a path:
|
||||||
|
/// a quoted path, a Windows drive path (<c>X:\</c>/<c>X:/</c>), a UNC path (<c>\\</c>), or a
|
||||||
|
/// POSIX absolute path (<c>/</c>). Normal chat text never starts this way.
|
||||||
|
/// </summary>
|
||||||
|
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] == '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when <paramref name="text"/> resolves to one or more existing files.
|
||||||
|
/// Handles a single path (quoted or not, possibly containing spaces) and multiple
|
||||||
|
/// space-separated (optionally quoted) paths. <paramref name="fileExists"/> is injectable
|
||||||
|
/// for testing; production passes <see cref="File.Exists"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGetFiles(string text, out List<string> files, Func<string, bool>? 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<string> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
private readonly UserListSource _usersListSource;
|
private readonly UserListSource _usersListSource;
|
||||||
private bool _usersPanelVisible = true;
|
private bool _usersPanelVisible = true;
|
||||||
private const int UsersPanelWidth = 22;
|
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 static readonly Key F2Key = Key.F2;
|
||||||
|
private bool _hasStagedAttachments;
|
||||||
|
|
||||||
internal static readonly string AppVersion =
|
internal static readonly string AppVersion =
|
||||||
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
||||||
@@ -60,7 +62,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
private static readonly string[] SlashCommands =
|
private static readonly string[] SlashCommands =
|
||||||
[
|
[
|
||||||
"/status", "/nick", "/color", "/theme", "/send",
|
"/status", "/nick", "/color", "/theme", "/send",
|
||||||
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave",
|
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/downloadpath",
|
||||||
"/topic", "/users", "/kick", "/ban", "/unban",
|
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||||
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||||
];
|
];
|
||||||
@@ -159,6 +161,11 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnImageSaveRequested;
|
public event Action<string, string>? OnImageSaveRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<Guid>? OnDeleteMessageRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -240,6 +247,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
};
|
};
|
||||||
_messageList.Source = new ChatListSource();
|
_messageList.Source = new ChatListSource();
|
||||||
_messageList.Accepting += OnMessageListAccepting;
|
_messageList.Accepting += OnMessageListAccepting;
|
||||||
|
_messageList.KeyDown += OnMessageListKeyDown;
|
||||||
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
|
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
|
||||||
_messageList.VerticalScrollBar.Visible = true;
|
_messageList.VerticalScrollBar.Visible = true;
|
||||||
|
|
||||||
@@ -249,7 +257,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
// Bottom input area
|
// Bottom input area
|
||||||
_inputFrame = new FrameView
|
_inputFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search",
|
Title = DefaultInputTitle,
|
||||||
X = 22,
|
X = 22,
|
||||||
Y = Pos.Bottom(_chatFrame),
|
Y = Pos.Bottom(_chatFrame),
|
||||||
Width = Dim.Fill(UsersPanelWidth),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
@@ -321,6 +329,27 @@ public sealed partial class MainWindow : Runnable
|
|||||||
KeyDown += OnWindowKeyDown;
|
KeyDown += OnWindowKeyDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the attachment staging indicator shown on the input frame's title.
|
||||||
|
/// Passing an empty list restores the default hint.
|
||||||
|
/// </summary>
|
||||||
|
public void SetStagedAttachments(IReadOnlyList<string> 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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies the currently registered color schemes to all views.
|
/// Applies the currently registered color schemes to all views.
|
||||||
/// Call after theme changes to refresh colors.
|
/// Call after theme changes to refresh colors.
|
||||||
@@ -458,21 +487,21 @@ public sealed partial class MainWindow : Runnable
|
|||||||
// Audio/file attachments take priority
|
// Audio/file attachments take priority
|
||||||
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
|
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);
|
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.Type == MessageType.File)
|
if (line.AttachmentKind == AttachmentKind.File)
|
||||||
{
|
{
|
||||||
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.Type == MessageType.Image)
|
if (line.AttachmentKind == AttachmentKind.Image)
|
||||||
{
|
{
|
||||||
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
e.Handled = true;
|
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<int> e)
|
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
|
||||||
{
|
{
|
||||||
if (_messageList.VerticalScrollBar.Value == 0)
|
if (_messageList.VerticalScrollBar.Value == 0)
|
||||||
@@ -545,7 +600,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
else if (e.KeyCode == EnterKey.KeyCode)
|
else if (e.KeyCode == EnterKey.KeyCode)
|
||||||
{
|
{
|
||||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
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);
|
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
||||||
_inputField.Text = string.Empty;
|
_inputField.Text = string.Empty;
|
||||||
@@ -564,8 +621,12 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode)
|
else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode)
|
||||||
{
|
{
|
||||||
// Explicit paste support: terminals that don't intercept Ctrl+V themselves
|
// If a file was copied in the OS file manager, the clipboard holds a file list
|
||||||
// otherwise leave users with only the right-click context menu.
|
// (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");
|
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
@@ -599,27 +660,22 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bool _suppressEmojiReplace;
|
private bool _suppressEmojiReplace;
|
||||||
private int _lastInputLength;
|
|
||||||
|
|
||||||
private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e)
|
private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e)
|
||||||
{
|
{
|
||||||
var text = _inputField.Text;
|
|
||||||
var previousLength = _lastInputLength;
|
|
||||||
_lastInputLength = text?.Length ?? 0;
|
|
||||||
|
|
||||||
if (_suppressEmojiReplace)
|
if (_suppressEmojiReplace)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var text = _inputField.Text;
|
||||||
if (string.IsNullOrEmpty(text))
|
if (string.IsNullOrEmpty(text))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// A file dropped onto the terminal arrives as a pasted absolute path.
|
// A file dropped onto the terminal is delivered as its absolute path inserted into the
|
||||||
// Detect multi-char bursts that resolve to existing files and route them
|
// input — often character by character (this Terminal.Gui build has no bracketed-paste
|
||||||
// through /send instead of leaving a raw path in the input.
|
// coalescing). As soon as the input resolves to existing file path(s), route them
|
||||||
if (text.Length - previousLength > 3 && TryGetDroppedFiles(text, out var droppedFiles))
|
// 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)
|
||||||
var channel = _messageManager.CurrentChannel;
|
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||||
if (!string.IsNullOrEmpty(channel))
|
|
||||||
{
|
{
|
||||||
_suppressEmojiReplace = true;
|
_suppressEmojiReplace = true;
|
||||||
try
|
try
|
||||||
@@ -631,11 +687,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_suppressEmojiReplace = false;
|
_suppressEmojiReplace = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var file in droppedFiles)
|
StageFiles(droppedFiles);
|
||||||
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var replaced = EmojiHelper.ReplaceEmoji(text);
|
var replaced = EmojiHelper.ReplaceEmoji(text);
|
||||||
if (replaced == text)
|
if (replaced == text)
|
||||||
@@ -695,77 +749,17 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interprets pasted text as one or more dropped files. Terminals deliver a file drop
|
/// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which
|
||||||
/// as the absolute path (quoted when it contains spaces; multiple files space-separated).
|
/// stages them; the next Enter sends them with any typed caption.
|
||||||
/// Returns true only when the entire input resolves to existing files.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool TryGetDroppedFiles(string text, out List<string> files)
|
private void StageFiles(IEnumerable<string> files)
|
||||||
{
|
{
|
||||||
files = [];
|
var channel = _messageManager.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel))
|
||||||
|
return;
|
||||||
|
|
||||||
var trimmed = text.Trim();
|
foreach (var file in files)
|
||||||
if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n'))
|
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
||||||
return false;
|
|
||||||
|
|
||||||
// Single unquoted path, possibly with spaces (e.g. WSL or plain conhost drops)
|
|
||||||
var unquoted = StripQuotes(trimmed);
|
|
||||||
if (Path.IsPathFullyQualified(unquoted) && File.Exists(unquoted))
|
|
||||||
{
|
|
||||||
files.Add(unquoted);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple files: space-separated tokens, each optionally quoted
|
|
||||||
foreach (var token in TokenizeQuoted(trimmed))
|
|
||||||
{
|
|
||||||
if (!Path.IsPathFullyQualified(token) || !File.Exists(token))
|
|
||||||
{
|
|
||||||
files.Clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
files.Add(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
return files.Count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string StripQuotes(string s) =>
|
|
||||||
s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\''))
|
|
||||||
? s[1..^1]
|
|
||||||
: s;
|
|
||||||
|
|
||||||
private static IEnumerable<string> TokenizeQuoted(string input)
|
|
||||||
{
|
|
||||||
var current = new System.Text.StringBuilder();
|
|
||||||
var quote = '\0';
|
|
||||||
|
|
||||||
foreach (var c in input)
|
|
||||||
{
|
|
||||||
if (quote != '\0')
|
|
||||||
{
|
|
||||||
if (c == quote) quote = '\0';
|
|
||||||
else current.Append(c);
|
|
||||||
}
|
|
||||||
else if (c is '"' or '\'')
|
|
||||||
{
|
|
||||||
quote = c;
|
|
||||||
}
|
|
||||||
else if (c == ' ')
|
|
||||||
{
|
|
||||||
if (current.Length > 0)
|
|
||||||
{
|
|
||||||
yield return current.ToString();
|
|
||||||
current.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
current.Append(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.Length > 0)
|
|
||||||
yield return current.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnChatViewportChanged()
|
private void OnChatViewportChanged()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public static class HubConstants
|
|||||||
public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
|
public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
|
||||||
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
||||||
public const int MaxMessageNewlines = 30;
|
public const int MaxMessageNewlines = 30;
|
||||||
|
public const int MaxAttachmentsPerMessage = 10;
|
||||||
public const int MaxConsecutiveNewlines = 1;
|
public const int MaxConsecutiveNewlines = 1;
|
||||||
public const int AsciiArtWidth = 80;
|
public const int AsciiArtWidth = 80;
|
||||||
public const int AsciiArtHeight = 40;
|
public const int AsciiArtHeight = 40;
|
||||||
|
|||||||
@@ -8,13 +8,22 @@ public record MessageDto(
|
|||||||
string SenderUsername,
|
string SenderUsername,
|
||||||
string? SenderNicknameColor,
|
string? SenderNicknameColor,
|
||||||
string ChannelName,
|
string ChannelName,
|
||||||
MessageType Type,
|
|
||||||
string? AttachmentUrl,
|
|
||||||
string? AttachmentFileName,
|
|
||||||
DateTimeOffset SentAt,
|
DateTimeOffset SentAt,
|
||||||
long? AttachmentFileSize = null,
|
List<AttachmentDto>? Attachments = null,
|
||||||
List<EmbedDto>? Embeds = null);
|
List<EmbedDto>? Embeds = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
|
||||||
|
/// images (null otherwise). For end-to-end encrypted channels the content behind
|
||||||
|
/// <see cref="Url"/> and the preview are ciphertext the server cannot read.
|
||||||
|
/// </summary>
|
||||||
|
public record AttachmentDto(
|
||||||
|
AttachmentKind Kind,
|
||||||
|
string Url,
|
||||||
|
string FileName,
|
||||||
|
long FileSize,
|
||||||
|
string? AsciiPreview = null);
|
||||||
|
|
||||||
public record ChannelDto(
|
public record ChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public class Attachment
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid MessageId { get; set; }
|
||||||
|
public Message? Message { get; set; }
|
||||||
|
|
||||||
|
public AttachmentKind Kind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Relative download URL, e.g. <c>/api/files/{fileId}</c>.</summary>
|
||||||
|
public required string Url { get; set; }
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Stored blob size in bytes (ciphertext size for encrypted channels).</summary>
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public string? AsciiPreview { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public enum AttachmentKind
|
||||||
|
{
|
||||||
|
Image,
|
||||||
|
Audio,
|
||||||
|
File
|
||||||
|
}
|
||||||
@@ -3,11 +3,10 @@ namespace EchoHub.Core.Models;
|
|||||||
public class Message
|
public class Message
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The message text/caption. May be empty when the message only carries attachments.</summary>
|
||||||
public required string Content { get; set; }
|
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 string? EmbedJson { get; set; }
|
||||||
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
@@ -16,4 +15,16 @@ public class Message
|
|||||||
|
|
||||||
public Guid SenderUserId { get; set; }
|
public Guid SenderUserId { get; set; }
|
||||||
public required string SenderUsername { get; set; }
|
public required string SenderUsername { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Files attached to this message. Empty for a plain text message.</summary>
|
||||||
|
public List<Attachment> 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,43 @@ public static partial class IrcMessageFormatter
|
|||||||
var ircChannel = $"#{message.ChannelName}";
|
var ircChannel = $"#{message.ChannelName}";
|
||||||
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
|
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))
|
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// One block per attachment
|
||||||
|
if (message.Attachments is { Count: > 0 })
|
||||||
|
{
|
||||||
|
foreach (var attachment in message.Attachments)
|
||||||
|
{
|
||||||
|
switch (attachment.Kind)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Append embed previews if present
|
// Append embed previews if present
|
||||||
if (message.Embeds is { Count: > 0 })
|
if (message.Embeds is { Count: > 0 })
|
||||||
@@ -30,29 +62,6 @@ public static partial class IrcMessageFormatter
|
|||||||
foreach (var embed in message.Embeds)
|
foreach (var embed in message.Embeds)
|
||||||
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
|
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
|
||||||
}
|
}
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
using EchoHub.Core.Services;
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
@@ -144,11 +145,18 @@ public class ChannelsController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{channel}/upload")]
|
/// <summary>
|
||||||
|
/// Sends one message carrying optional text (<c>content</c> 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 (<c>kind</c>) and pre-rendered,
|
||||||
|
/// room-encrypted preview (<c>preview</c>), aligned by file order — the server never inspects them.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{channel}/messages")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
|
[RequestSizeLimit((long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
||||||
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
|
[RequestFormLimits(MultipartBodyLengthLimit = (long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
||||||
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
|
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var usernameClaim = User.FindFirstValue("username");
|
var usernameClaim = User.FindFirstValue("username");
|
||||||
@@ -165,114 +173,136 @@ public class ChannelsController : ControllerBase
|
|||||||
if (channelDto is null)
|
if (channelDto is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
if (!Request.HasFormContentType)
|
||||||
return BadRequest(new ErrorResponse("No file uploaded."));
|
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;
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
string content;
|
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."));
|
||||||
|
|
||||||
|
// 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<Attachment>();
|
||||||
|
var attachmentDtos = new List<AttachmentDto>();
|
||||||
|
|
||||||
|
for (var i = 0; i < files.Count; i++)
|
||||||
|
{
|
||||||
|
var file = files[i];
|
||||||
|
AttachmentKind kind;
|
||||||
|
string? previewPlain;
|
||||||
string fileId;
|
string fileId;
|
||||||
|
|
||||||
if (channelDto.IsEncrypted)
|
if (channelDto.IsEncrypted)
|
||||||
{
|
{
|
||||||
// E2E-encrypted channel: the blob is ciphertext the server cannot inspect.
|
// Ciphertext blob — trust the client's declared kind + room-encrypted preview.
|
||||||
// The client declares the type and supplies pre-rendered, room-encrypted
|
// Client sends one kind + preview per file in order; empty preview means none.
|
||||||
// content (ASCII art for images, encrypted filename otherwise).
|
kind = ParseKind(i < declaredKinds.Count ? declaredKinds[i] : null);
|
||||||
messageType = Request.Form["type"].ToString().ToLowerInvariant() switch
|
previewPlain = i < declaredPreviews.Count ? declaredPreviews[i] : null;
|
||||||
{
|
if (string.IsNullOrEmpty(previewPlain))
|
||||||
"image" => MessageType.Image,
|
previewPlain = null;
|
||||||
"audio" => MessageType.Audio,
|
|
||||||
_ => MessageType.File,
|
|
||||||
};
|
|
||||||
|
|
||||||
var declaredMax = messageType switch
|
if (file.Length > MaxForKind(kind))
|
||||||
{
|
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
|
||||||
MessageType.Image => HubConstants.MaxImageSizeBytes,
|
|
||||||
MessageType.Audio => HubConstants.MaxAudioFileSizeBytes,
|
|
||||||
_ => HubConstants.MaxFileSizeBytes,
|
|
||||||
};
|
|
||||||
if (file.Length > declaredMax)
|
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {declaredMax / (1024 * 1024)} MB."));
|
|
||||||
|
|
||||||
var clientContent = Request.Form["content"].ToString();
|
|
||||||
content = string.IsNullOrEmpty(clientContent) ? file.FileName : clientContent;
|
|
||||||
|
|
||||||
using var encryptedStream = file.OpenReadStream();
|
using var encryptedStream = file.OpenReadStream();
|
||||||
(fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName);
|
(fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Detect file type early so we can apply the correct size limit
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||||
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||||
|
kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File;
|
||||||
|
|
||||||
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
|
if (file.Length > MaxForKind(kind))
|
||||||
: isAudio ? HubConstants.MaxAudioFileSizeBytes
|
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {MaxForKind(kind) / (1024 * 1024)} MB."));
|
||||||
: HubConstants.MaxFileSizeBytes;
|
|
||||||
|
|
||||||
if (file.Length > maxSize)
|
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
|
|
||||||
|
|
||||||
string filePath;
|
string filePath;
|
||||||
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||||
|
|
||||||
messageType = isImage ? MessageType.Image
|
|
||||||
: isAudio ? MessageType.Audio
|
|
||||||
: MessageType.File;
|
|
||||||
|
|
||||||
if (isImage)
|
if (isImage)
|
||||||
{
|
{
|
||||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||||
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
previewPlain = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
content = file.FileName;
|
previewPlain = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
var url = $"/api/files/{fileId}";
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
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 dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = dbContent,
|
Content = dbContent,
|
||||||
Type = messageType,
|
|
||||||
AttachmentUrl = attachmentUrl,
|
|
||||||
AttachmentFileName = file.FileName,
|
|
||||||
AttachmentFileSize = file.Length,
|
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = channelDto.Id,
|
ChannelId = channelDto.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = usernameClaim,
|
SenderUsername = usernameClaim,
|
||||||
|
Attachments = attachmentEntities,
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Encrypt for transport — clients decrypt
|
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
_encryption.Encrypt(content),
|
_encryption.Encrypt(content),
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
messageType,
|
|
||||||
attachmentUrl,
|
|
||||||
file.FileName,
|
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
file.Length);
|
attachmentDtos);
|
||||||
|
|
||||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
return Ok(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")]
|
[HttpPost("{channel}/send-url")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
||||||
@@ -353,46 +383,49 @@ public class ChannelsController : ControllerBase
|
|||||||
// Save file and convert to ASCII
|
// Save file and convert to ASCII
|
||||||
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
|
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
|
||||||
|
|
||||||
string content;
|
string preview;
|
||||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||||
using (var imageStream = System.IO.File.OpenRead(filePath))
|
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 attachmentUrl = $"/api/files/{fileId}";
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
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
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = dbContent,
|
Content = string.Empty,
|
||||||
Type = MessageType.Image,
|
|
||||||
AttachmentUrl = attachmentUrl,
|
|
||||||
AttachmentFileName = fileName,
|
|
||||||
AttachmentFileSize = imageBytes.Length,
|
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = channelDto.Id,
|
ChannelId = channelDto.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = usernameClaim,
|
SenderUsername = usernameClaim,
|
||||||
|
Attachments = [attachment],
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
// Encrypt for transport — clients decrypt
|
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
_encryption.Encrypt(content),
|
_encryption.Encrypt(string.Empty),
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
MessageType.Image,
|
|
||||||
attachmentUrl,
|
|
||||||
fileName,
|
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
imageBytes.Length);
|
[new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))]);
|
||||||
|
|
||||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
|
|||||||
@@ -20,17 +20,20 @@ public class ModerationController : ControllerBase
|
|||||||
private readonly EchoHubDbContext _db;
|
private readonly EchoHubDbContext _db;
|
||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly FileStorageService _fileStorage;
|
||||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
|
||||||
public ModerationController(
|
public ModerationController(
|
||||||
EchoHubDbContext db,
|
EchoHubDbContext db,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
|
FileStorageService fileStorage,
|
||||||
IEnumerable<IChatBroadcaster> broadcasters)
|
IEnumerable<IChatBroadcaster> broadcasters)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
_broadcasters = broadcasters;
|
_broadcasters = broadcasters;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,17 +173,47 @@ public class ModerationController : ControllerBase
|
|||||||
[HttpDelete("messages/{messageId:guid}")]
|
[HttpDelete("messages/{messageId:guid}")]
|
||||||
public async Task<IActionResult> DeleteMessage(Guid messageId)
|
public async Task<IActionResult> DeleteMessage(Guid messageId)
|
||||||
{
|
{
|
||||||
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
// Any authenticated user may reach this; permission depends on authorship + role hierarchy.
|
||||||
if (error is not null) return error;
|
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
|
var message = await _db.Messages
|
||||||
.Include(m => m.Channel)
|
.Include(m => m.Channel)
|
||||||
|
.Include(m => m.Attachments)
|
||||||
.FirstOrDefaultAsync(m => m.Id == messageId);
|
.FirstOrDefaultAsync(m => m.Id == messageId);
|
||||||
|
|
||||||
if (message is null)
|
if (message is null)
|
||||||
return NotFound(new ErrorResponse("Message not found."));
|
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;
|
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);
|
_db.Messages.Remove(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -200,7 +233,19 @@ public class ModerationController : ControllerBase
|
|||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
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);
|
_db.Messages.RemoveRange(messages);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class EchoHubDbContext : DbContext
|
|||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<Channel> Channels => Set<Channel>();
|
public DbSet<Channel> Channels => Set<Channel>();
|
||||||
public DbSet<Message> Messages => Set<Message>();
|
public DbSet<Message> Messages => Set<Message>();
|
||||||
|
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
||||||
|
|
||||||
@@ -63,6 +64,21 @@ public class EchoHubDbContext : DbContext
|
|||||||
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
||||||
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
||||||
entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON
|
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<Attachment>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(a => a.Id);
|
||||||
|
entity.HasIndex(a => a.MessageId);
|
||||||
|
entity.Property(a => a.Kind).HasConversion<int>();
|
||||||
|
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<ChannelMembership>(entity =>
|
modelBuilder.Entity<ChannelMembership>(entity =>
|
||||||
|
|||||||
+331
@@ -0,0 +1,331 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260716020211_AddMessageAttachments")]
|
||||||
|
partial class AddMessageAttachments
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AsciiPreview")
|
||||||
|
.HasMaxLength(64000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId");
|
||||||
|
|
||||||
|
b.ToTable("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptionSalt")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("WrappedRoomKey")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("AttachmentFileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMessageAttachments : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Attachments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
MessageId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Kind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||||
|
FileName = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||||
|
FileSize = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
AsciiPreview = table.Column<string>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Attachments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,42 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AsciiPreview")
|
||||||
|
.HasMaxLength(64000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId");
|
||||||
|
|
||||||
|
b.ToTable("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -229,6 +265,17 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("Users");
|
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 =>
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("EchoHub.Core.Models.Channel", null)
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
@@ -270,6 +317,11 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("Messages");
|
b.Navigation("Messages");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Attachments");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,7 +214,6 @@ public class ChatService : IChatService
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = dbContent,
|
Content = dbContent,
|
||||||
Type = MessageType.Text,
|
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = channel.Id,
|
ChannelId = channel.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
@@ -233,9 +232,6 @@ public class ChatService : IChatService
|
|||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
MessageType.Text,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
Embeds: embeds);
|
Embeds: embeds);
|
||||||
|
|
||||||
@@ -386,6 +382,13 @@ public class ChatService : IChatService
|
|||||||
|
|
||||||
raw.Reverse();
|
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 =>
|
return raw.Select(x =>
|
||||||
{
|
{
|
||||||
// Decrypt DB content (handles both encrypted and plaintext via prefix detection)
|
// Decrypt DB content (handles both encrypted and plaintext via prefix detection)
|
||||||
@@ -399,6 +402,18 @@ public class ChatService : IChatService
|
|||||||
catch { /* ignore malformed JSON */ }
|
catch { /* ignore malformed JSON */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<AttachmentDto>? 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
|
// Encrypt for transport — client decrypts
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
x.m.Id,
|
x.m.Id,
|
||||||
@@ -406,11 +421,8 @@ public class ChatService : IChatService
|
|||||||
x.m.SenderUsername,
|
x.m.SenderUsername,
|
||||||
x.NicknameColor,
|
x.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
x.m.Type,
|
|
||||||
x.m.AttachmentUrl,
|
|
||||||
x.m.AttachmentFileName,
|
|
||||||
x.m.SentAt,
|
x.m.SentAt,
|
||||||
x.m.AttachmentFileSize,
|
attachments,
|
||||||
embeds);
|
embeds);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public static partial class DataMigrationService
|
|||||||
await EnsureDefaultChannelsPublicAsync(db, logger);
|
await EnsureDefaultChannelsPublicAsync(db, logger);
|
||||||
await MigrateAnsiMessagesAsync(db, logger);
|
await MigrateAnsiMessagesAsync(db, logger);
|
||||||
await MigrateEmbedJsonToArrayAsync(db, logger);
|
await MigrateEmbedJsonToArrayAsync(db, logger);
|
||||||
|
await MigrateLegacyAttachmentsAsync(db, logger);
|
||||||
await EnsureConfiguredAdminsAsync(db, config, 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")]
|
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||||
private static partial Regex AnsiColorRegex();
|
private static partial Regex AnsiColorRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure usernames listed in Server:Admins config are at least Admin role.
|
/// 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.
|
/// Acts as a safety net in case the first registered user didn't get Owner role.
|
||||||
|
|||||||
@@ -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<string, bool> Exists(params string[] existing)
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>(existing, StringComparer.OrdinalIgnoreCase);
|
||||||
|
return set.Contains;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,8 +80,7 @@ public class IrcBroadcasterTests
|
|||||||
|
|
||||||
var encryptedContent = _encryption.Encrypt("Hello world!");
|
var encryptedContent = _encryption.Encrypt("Hello world!");
|
||||||
var message = new MessageDto(
|
var message = new MessageDto(
|
||||||
Guid.NewGuid(), encryptedContent, "alice", null, "general",
|
Guid.NewGuid(), encryptedContent, "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
|
||||||
|
|
||||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
@@ -97,8 +96,7 @@ public class IrcBroadcasterTests
|
|||||||
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
var message = new MessageDto(
|
var message = new MessageDto(
|
||||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
|
||||||
|
|
||||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
@@ -116,8 +114,7 @@ public class IrcBroadcasterTests
|
|||||||
var (_, randomStream) = AddConnectionWithCapture("charlie", "random");
|
var (_, randomStream) = AddConnectionWithCapture("charlie", "random");
|
||||||
|
|
||||||
var message = new MessageDto(
|
var message = new MessageDto(
|
||||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
|
||||||
|
|
||||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
|||||||
@@ -340,8 +340,7 @@ public class IrcCommandHandlerTests
|
|||||||
var encryptedContent = _encryption.Encrypt("Hello from history!");
|
var encryptedContent = _encryption.Encrypt("Hello from history!");
|
||||||
_chatService.HistoryToReturn =
|
_chatService.HistoryToReturn =
|
||||||
[
|
[
|
||||||
new(Guid.NewGuid(), encryptedContent, "bob", null, "general",
|
new(Guid.NewGuid(), encryptedContent, "bob", null, "general", DateTimeOffset.UtcNow)
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
var lines = await RunAuthenticated(["JOIN #general"]);
|
var lines = await RunAuthenticated(["JOIN #general"]);
|
||||||
|
|||||||
@@ -11,32 +11,31 @@ public class IrcMessageFormatterTests
|
|||||||
string channel = "general", List<EmbedDto>? embeds = null)
|
string channel = "general", List<EmbedDto>? embeds = null)
|
||||||
{
|
{
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
Guid.NewGuid(), content, sender, null, channel,
|
Guid.NewGuid(), content, sender, null, channel, DateTimeOffset.UtcNow, Embeds: embeds);
|
||||||
MessageType.Text, null, null, DateTimeOffset.UtcNow, Embeds: embeds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png",
|
private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png",
|
||||||
string url = "https://example.com/image.png", string sender = "alice", string channel = "general")
|
string url = "https://example.com/image.png", string sender = "alice", string channel = "general")
|
||||||
{
|
{
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
Guid.NewGuid(), asciiArt, sender, null, channel,
|
Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow,
|
||||||
MessageType.Image, url, fileName, DateTimeOffset.UtcNow);
|
[new AttachmentDto(AttachmentKind.Image, url, fileName, 0, asciiArt)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MessageDto CreateFileMessage(string fileName = "doc.pdf",
|
private static MessageDto CreateFileMessage(string fileName = "doc.pdf",
|
||||||
string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general")
|
string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general")
|
||||||
{
|
{
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
Guid.NewGuid(), "", sender, null, channel,
|
Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow,
|
||||||
MessageType.File, url, fileName, DateTimeOffset.UtcNow);
|
[new AttachmentDto(AttachmentKind.File, url, fileName, 0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MessageDto CreateAudioMessage(string fileName = "song.mp3",
|
private static MessageDto CreateAudioMessage(string fileName = "song.mp3",
|
||||||
string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general")
|
string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general")
|
||||||
{
|
{
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
Guid.NewGuid(), "", sender, null, channel,
|
Guid.NewGuid(), "", sender, null, channel, DateTimeOffset.UtcNow,
|
||||||
MessageType.Audio, url, fileName, DateTimeOffset.UtcNow);
|
[new AttachmentDto(AttachmentKind.Audio, url, fileName, 0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FormatMessage ────────────────────────────────────────────────────
|
// ── FormatMessage ────────────────────────────────────────────────────
|
||||||
@@ -115,8 +114,7 @@ public class IrcMessageFormatterTests
|
|||||||
var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg");
|
var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg");
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]"));
|
Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]") && l.Contains("https://example.com/photo.jpg"));
|
||||||
Assert.Contains(lines, l => l.Contains("Download: https://example.com/photo.jpg"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -8,22 +8,18 @@ namespace EchoHub.Tests;
|
|||||||
public class IrcMessageFormatterTests
|
public class IrcMessageFormatterTests
|
||||||
{
|
{
|
||||||
private static MessageDto CreateMessage(
|
private static MessageDto CreateMessage(
|
||||||
MessageType type = MessageType.Text,
|
|
||||||
string content = "hello",
|
string content = "hello",
|
||||||
string sender = "alice",
|
string sender = "alice",
|
||||||
string channel = "general",
|
string channel = "general",
|
||||||
string? attachmentUrl = null,
|
List<AttachmentDto>? attachments = null,
|
||||||
string? attachmentFileName = null,
|
|
||||||
List<EmbedDto>? embeds = null) => new(
|
List<EmbedDto>? embeds = null) => new(
|
||||||
Id: Guid.NewGuid(),
|
Id: Guid.NewGuid(),
|
||||||
Content: content,
|
Content: content,
|
||||||
SenderUsername: sender,
|
SenderUsername: sender,
|
||||||
SenderNicknameColor: null,
|
SenderNicknameColor: null,
|
||||||
ChannelName: channel,
|
ChannelName: channel,
|
||||||
Type: type,
|
|
||||||
AttachmentUrl: attachmentUrl,
|
|
||||||
AttachmentFileName: attachmentFileName,
|
|
||||||
SentAt: DateTimeOffset.UtcNow,
|
SentAt: DateTimeOffset.UtcNow,
|
||||||
|
Attachments: attachments,
|
||||||
Embeds: embeds);
|
Embeds: embeds);
|
||||||
|
|
||||||
// ── FormatMessage ─────────────────────────────────────────────────
|
// ── FormatMessage ─────────────────────────────────────────────────
|
||||||
@@ -51,34 +47,29 @@ public class IrcMessageFormatterTests
|
|||||||
|
|
||||||
Assert.True(lines.Count >= 2);
|
Assert.True(lines.Count >= 2);
|
||||||
Assert.Contains("PRIVMSG #general :check this out", lines[0]);
|
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("GitHub", lines[1]);
|
||||||
Assert.Contains("Repo Title", lines[1]);
|
Assert.Contains("Repo Title", lines[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_ImageMessage_IncludesImageTagAndDownloadUrl()
|
public void FormatMessage_ImageAttachment_IncludesImageTagAndDownloadUrl()
|
||||||
{
|
{
|
||||||
var msg = CreateMessage(
|
var msg = CreateMessage(
|
||||||
type: MessageType.Image,
|
content: "",
|
||||||
content: "{F:FF0000}\u2588{X}",
|
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]);
|
||||||
attachmentUrl: "/api/files/abc",
|
|
||||||
attachmentFileName: "photo.png");
|
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.True(lines.Count >= 2);
|
Assert.True(lines.Count >= 2);
|
||||||
Assert.Contains("[Image: photo.png]", lines[0]);
|
Assert.Contains("[Image: photo.png]", lines[0]);
|
||||||
Assert.Contains("Download: /api/files/abc", lines[1]);
|
Assert.Contains("/api/files/abc", lines[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_FileMessage_IncludesFileTag()
|
public void FormatMessage_FileAttachment_IncludesFileTag()
|
||||||
{
|
{
|
||||||
var msg = CreateMessage(
|
var msg = CreateMessage(
|
||||||
type: MessageType.File,
|
content: "",
|
||||||
content: "report.pdf",
|
attachments: [new AttachmentDto(AttachmentKind.File, "/api/files/xyz", "report.pdf", 0)]);
|
||||||
attachmentUrl: "/api/files/xyz",
|
|
||||||
attachmentFileName: "report.pdf");
|
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.Single(lines);
|
Assert.Single(lines);
|
||||||
@@ -87,21 +78,49 @@ public class IrcMessageFormatterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_AudioMessage_IncludesMusicNoteAndAudioTag()
|
public void FormatMessage_AudioAttachment_IncludesMusicNoteAndAudioTag()
|
||||||
{
|
{
|
||||||
var msg = CreateMessage(
|
var msg = CreateMessage(
|
||||||
type: MessageType.Audio,
|
content: "",
|
||||||
content: "song.mp3",
|
attachments: [new AttachmentDto(AttachmentKind.Audio, "/api/files/def", "song.mp3", 0)]);
|
||||||
attachmentUrl: "/api/files/def",
|
|
||||||
attachmentFileName: "song.mp3");
|
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.Single(lines);
|
Assert.Single(lines);
|
||||||
Assert.Contains("\u266a", lines[0]); // ♪
|
Assert.Contains("♪", lines[0]);
|
||||||
Assert.Contains("[Audio: song.mp3]", lines[0]);
|
Assert.Contains("[Audio: song.mp3]", lines[0]);
|
||||||
Assert.Contains("/api/files/def", 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 ───────────────────────────────────────────────
|
// ── ColorTagsToAnsi ───────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -179,7 +198,6 @@ public class IrcMessageFormatterTests
|
|||||||
var longWord = new string('a', 500);
|
var longWord = new string('a', 500);
|
||||||
var result = IrcMessageFormatter.SplitMessage(longWord, 400);
|
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.Single(result);
|
||||||
Assert.Equal(longWord, result[0]);
|
Assert.Equal(longWord, result[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user