diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc58994..0aabca1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged + - name: Remove submodule NuGet config + run: rm -f src/Terminal.Gui/nuget.config - name: Setup .NET 10 uses: actions/setup-dotnet@v4 @@ -29,7 +35,7 @@ jobs: dotnet-version: '10.0.x' - name: Check formatting - run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic + run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic --exclude src/Terminal.Gui/ build-and-test: name: Build & Test @@ -38,6 +44,11 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive + + # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged + - name: Remove submodule NuGet config + run: rm -f src/Terminal.Gui/nuget.config - name: Check for src/ changes id: changes diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5156116..cbc2dde 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,6 +16,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged + - name: Remove submodule NuGet config + run: rm -f src/Terminal.Gui/nuget.config - name: Setup .NET 10 uses: actions/setup-dotnet@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bee4c9..1a200d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,11 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive + + # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged + - name: Remove submodule NuGet config + run: rm -f src/Terminal.Gui/nuget.config - name: Check for src/ changes id: changes diff --git a/.gitignore b/.gitignore index 886d4b6..db8d52d 100644 --- a/.gitignore +++ b/.gitignore @@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/* # DocFx generated output docs/_site/ docs/_api_meta/ + +# Diff files +*.diff diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4803285 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/Terminal.Gui"] + path = src/Terminal.Gui + url = https://github.com/HueByte/Terminal.Gui.git diff --git a/assets/hue_icon.ico b/assets/hue_icon.ico new file mode 100644 index 0000000..dd2668c Binary files /dev/null and b/assets/hue_icon.ico differ diff --git a/assets/hue_icon.png b/assets/hue_icon.png new file mode 100644 index 0000000..92c03cd Binary files /dev/null and b/assets/hue_icon.png differ diff --git a/assets/hue_icon.svg b/assets/hue_icon.svg new file mode 100644 index 0000000..fb3a92e --- /dev/null +++ b/assets/hue_icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/api/client-articles/index.md b/docs/api/client-articles/index.md index 171da8f..504bade 100644 --- a/docs/api/client-articles/index.md +++ b/docs/api/client-articles/index.md @@ -5,7 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2. ## Topics - Terminal.Gui v2 patterns and conventions -- Theme system and customization +- Theme system and customization (including transparent theme) - Command system reference -- Configuration management +- Configuration and session persistence +- Audio playback and file downloads +- Automatic update checking - [Notification sounds](../../articles/notification-sounds.md) diff --git a/docs/api/core-articles/index.md b/docs/api/core-articles/index.md index d8545b1..9eecf4c 100644 --- a/docs/api/core-articles/index.md +++ b/docs/api/core-articles/index.md @@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library. ## Topics - Data models and DTOs -- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient) +- Contract interfaces (IChatService, IChannelService, IChatBroadcaster, IEchoHubClient) - Validation constants and shared rules diff --git a/docs/api/index.md b/docs/api/index.md index b62904c..dc06867 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -10,11 +10,11 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur ### Core -Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`). +Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChannelService`, `IChatBroadcaster`, `IEchoHubClient`). ### Server -ASP.NET Core server -- controllers, hubs, ChatService, SignalRBroadcaster, authentication, and data access. +ASP.NET Core server -- controllers, hubs, ChatService, ChannelService, SignalRBroadcaster, authentication, file cleanup, and data access. ### Server.Irc diff --git a/docs/api/server-articles/index.md b/docs/api/server-articles/index.md index 6cafdf8..9ceecae 100644 --- a/docs/api/server-articles/index.md +++ b/docs/api/server-articles/index.md @@ -8,6 +8,7 @@ Articles related to the EchoHub server built with ASP.NET Core. - SignalR hub and real-time messaging - IRC gateway and protocol bridging - ChatService and broadcaster pattern -- File upload and validation +- ChannelService and channel CRUD +- File upload, validation, and cleanup - Rate limiting configuration -- Database schema and migrations +- Database schema, migrations, and DataMigrationService diff --git a/docs/articles/architecture.md b/docs/articles/architecture.md index bc59652..7ab7075 100644 --- a/docs/articles/architecture.md +++ b/docs/articles/architecture.md @@ -22,7 +22,7 @@ Shared library containing: - **Models**: `User`, `Channel`, `Message`, `RefreshToken` - **DTOs**: Record types for API requests/responses -- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface) +- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChannelService` (channel CRUD and membership), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface) - **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants` ### EchoHub.Server @@ -33,7 +33,7 @@ ASP.NET Core web application: - **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService` - **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens) - **Data**: EF Core with SQLite -- **Services**: `ChatService` (core business logic), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion +- **Services**: `ChatService` (core business logic), `ChannelService` (channel CRUD and membership), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion, `FileCleanupService` (periodic removal of expired uploads), `DataMigrationService` (startup schema/data evolution) ### EchoHub.Server.Irc @@ -51,9 +51,9 @@ IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` o Terminal.Gui v2 TUI application: - **UI**: Main window, dialogs, chat renderer with ANSI color support -- **Services**: API client with automatic token refresh, SignalR connection wrapper -- **Themes**: 13 built-in color themes -- **Config**: Client configuration management +- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate) +- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency) +- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens) ## Communication diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 647908c..f35822f 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,6 +4,8 @@ Release history for EchoHub. ## Releases +- [v0.2.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme +- [v0.2.4](v0.2.4.md) - E2E Message Encryption - [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul - [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 60d383d..1079748 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.5 + href: v0.2.5.md - name: v0.2.4 href: v0.2.4.md - name: v0.2.3 diff --git a/docs/changelog/v0.2.4.md b/docs/changelog/v0.2.4.md index 6663103..2bae94e 100644 --- a/docs/changelog/v0.2.4.md +++ b/docs/changelog/v0.2.4.md @@ -28,5 +28,5 @@ - `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest - DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000 - EF Core migration: `AddEncryptionSupport` -- Encryption test suite: server-side, client-side, and cross-compatibility tests (87 total) +- Encryption test suite: server-side, client-side, and cross-compatibility tests - Documentation article: `docs/articles/encryption.md` diff --git a/docs/changelog/v0.2.5.md b/docs/changelog/v0.2.5.md new file mode 100644 index 0000000..45e6b67 --- /dev/null +++ b/docs/changelog/v0.2.5.md @@ -0,0 +1,119 @@ +# v0.2.5 - Session Persistence, Auto-Updates, Audio & Transparent Theme + +## Features + +### Audio Message Support + +- New `Audio` message type — uploaded audio files (`.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.wma`) are automatically detected and categorized +- TUI client renders audio messages with `♪ [Audio: filename] (Enter to play)` indicator +- Press Enter on an audio message to open the **Audio Player dialog** with animated wave visualization, play/pause/stop controls, and volume slider +- Per-type upload limits: **10 MB** images, **10 MB** audio, **100 MB** generic files (with Kestrel request size configured to match) +- `AudioPlaybackService` enhanced with pause/resume, volume control, and playback-finished events +- Fixed: wrapped audio/file messages now remain clickable on all lines (attachment metadata propagated through word-wrap) +- IRC gateway formats audio messages as `♪ [Audio: filename] url` +- Server detects audio files by extension via `FileValidationHelper.IsAudioFile()` + +### File Downloads + +- Press Enter on a file message in the TUI client to download and open it with the system default application +- `ApiClient.DownloadFileAsync()` streams file downloads from the server +- File and audio messages in the chat list now show colored indicators with interaction hints + +### File Cleanup Service + +- `FileCleanupService` (BackgroundService) periodically removes old uploaded files +- Configurable via `Storage:CleanupIntervalHours` (default 1h) and `Storage:RetentionDays` (default 30d) + +### Data Migration Service + +- `DataMigrationService` runs at startup to handle schema/data evolution +- Ensures `#general` channel and pre-existing channels are marked public +- Migrates legacy ANSI escape codes in image messages to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`) +- Migrates legacy single-object `EmbedJson` to array format +- Promotes usernames listed in `Server:Admins` config to Admin role + +### True Transparent Background + +- Transparent theme now uses the terminal's native background instead of solid black +- Powered by `Color.None` (alpha=0) which emits ANSI `CSI 49m` (default background) instead of explicit RGB +- Terminal transparency, acrylic, wallpaper effects now show through the TUI +- Dialogs retain solid `DarkGray` background for readability +- Uses local Terminal.Gui fork (submodule) with transparent color support pending upstream merge ([gui-cs/Terminal.Gui#4234](https://github.com/gui-cs/Terminal.Gui/pull/4234)) + +### Enhanced Status Bar + +- Status bar now shows **EchoHub** branding at the start +- Connection state is color-coded: green (Connected), red (Disconnected), yellow (transitional states like Connecting, Reconnecting, Authenticating) +- Current channel shows its type: `#channel - public` or `#channel - private` + +### Remember Me (Session Persistence) + +- "Remember me" checkbox in the connect dialog — saves a 30-day refresh token so users can reconnect without entering their password +- Saved servers with active sessions show `[session]` indicator in the connect dialog and saved servers list +- Token-based login: selecting a saved server with a session lets you click Login with an empty password +- Graceful expiry handling: if the saved session is expired or revoked, shows an error and prompts for password +- Refresh token rotation: rotated tokens are automatically persisted to config so the session stays valid across refreshes +- New "Logout" menu item (Server menu): revokes the refresh token server-side and clears the saved session +- Removed dead `SavedServer.Token` field (stored 15-min access token that was never read back) + +### Automatic Update Checking + +- `UpdateChecker` rewritten to use the [AlwaysUpToDate](https://github.com/AuriRex/AlwaysUpToDate) library for self-updating +- Polls the EchoHub version manifest hourly (active only in `RELEASE` builds) +- `UpdateConfirmDialog` shows current vs. available version with Update/Cancel buttons +- `UpdateProgressDialog` displays real-time download progress bar +- Errors are logged via Serilog instead of being silently swallowed + +### Windows Installer + +- New Inno Setup script (`installer/Installer.iss`) to build a Windows installer +- Auto-reads product version from the built EXE +- User-level install by default (no admin required), with admin override option +- Creates desktop and quick-launch shortcuts (optional) +- Supports English and German languages + +### Application Icons + +- New `hue_icon` branding assets (ICO, PNG, SVG) in `assets/` +- Application icon set on both Client and Server projects + +### #general Channel Protection + +- `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow) +- Users cannot `/leave` the #general channel (client-side guard) +- Connecting while already connected now prompts to disconnect first instead of silently leaking the previous connection + +## Fixes + +### IRC Gateway + +- **Fixed: IRC JOIN history replay showed encrypted gibberish** — `IrcCommandHandler.HandleJoinAsync` now decrypts channel history before formatting for IRC clients (history was encrypted for SignalR transport but sent raw to IRC) +- **Fixed: `JoinedChannels` race condition** — `IrcClientConnection.JoinedChannels` replaced with thread-safe methods (`JoinChannel`, `LeaveChannel`, `IsInChannel`, `GetJoinedChannels`) using lock synchronization; prevents crashes when broadcaster threads read while the command handler writes +- **Fixed: `RequireRegistered` fire-and-forget** — converted from sync `bool` to `async Task` (`RequireRegisteredAsync`) so the error reply is properly awaited before the handler returns + +### ChannelService Extraction + +- Extracted channel management logic from `ChannelsController` and `ChatService` into a dedicated `IChannelService` / `ChannelService` +- `ChannelsController` is now a thin adapter — delegates CRUD operations to `IChannelService` and maps `ChannelError` to HTTP status codes +- `ChatService.JoinChannelAsync` delegates channel validation + membership to `IChannelService.EnsureChannelMembershipAsync()` +- New `ChannelOperationResult` result type with `ChannelError` enum for typed error handling across service boundaries +- IRC gateway uses `IChannelService` for topic queries and channel listing (instead of `IChatService`) + +### EchoHub Branding + +- Status bar "EchoHub" text now uses golden color (218, 165, 32) + +## Infrastructure + +- Audio MIME types in `FilesController` (mp3, wav, ogg, flac, aac, m4a, wma) +- CI workflows updated to handle Terminal.Gui submodule `nuget.config` workaround (`rm -f` step) +- Root `nuget.config` added for package source management +- Terminal.Gui formatting excluded from CI format checks +- Recursive submodule fetching enabled in CI workflows +- `Server:Admins` config array in `appsettings.example.json` for designating admin usernames +- `Storage:CleanupIntervalHours` and `Storage:RetentionDays` added to `appsettings.example.json` +- `FakeChannelService` test helper added for IRC unit tests +- Test suites: ChatLine, CommandHandler, DataMigrationService, FileValidationHelper, ImageToAsciiService, IrcMessageFormatter, JwtTokenService, LinkEmbedService +- IRC abstraction layer test suite: IrcMessage parsing, IrcMessageFormatter, IrcClientConnection, IrcCommandHandler, IrcBroadcaster +- Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeChannelService`, `FakeEncryptionService` for IRC unit testing without network I/O +- 346 total tests diff --git a/installer/EchoHub-Installer.exe b/installer/EchoHub-Installer.exe new file mode 100644 index 0000000..22a0eb0 Binary files /dev/null and b/installer/EchoHub-Installer.exe differ diff --git a/installer/Installer.iss b/installer/Installer.iss new file mode 100644 index 0000000..b833071 --- /dev/null +++ b/installer/Installer.iss @@ -0,0 +1,52 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#expr Exec('cmd.exe', '/C dotnet build -o "' + SourcePath + '\publish" -c Release ' + SourcePath + '..\src\EchoHub.Client\') + +#define MyAppName "EchoHub" +#define MyAppVersion GetStringFileInfo("/publish/EchoHub.Client.exe","ProductVersion") +#define MyAppPublisher "Hue" +#define MyAppExeName "EchoHub.Client.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId=c95b1292-3022-4c62-a131-4d46ace370f5 +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +SetupIconFile=../assets/hue_icon.ico +; The [Icons] "quicklaunchicon" entry uses {userappdata} but its [Tasks] entry has a proper IsAdminInstallMode Check. +UsedUserAreasWarning=no +; Remove the following line to run in administrative install mode (install for all users.) +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputBaseFilename={#MyAppName}-Installer +OutputDir=. +Compression=lzma +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" +Name: "german"; MessagesFile: "compiler:Languages\German.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 6.1; Check: not IsAdminInstallMode + +[Files] +Source: "publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..4e800bf --- /dev/null +++ b/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e2dfc80..381226e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.4 + 0.2.5 true $(NoWarn);CS1591 diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 40748ac..23449d2 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -22,6 +22,8 @@ public sealed class AppOrchestrator : IDisposable private readonly MainWindow _mainWindow; private readonly CommandHandler _commandHandler; private readonly NotificationSoundService _notificationSound; + private readonly AudioPlaybackService _audioPlayback = new(); + private readonly UpdateChecker _updateService; private EchoHubConnection? _connection; private ApiClient? _apiClient; @@ -44,10 +46,13 @@ public sealed class AppOrchestrator : IDisposable _mainWindow = new MainWindow(app); _commandHandler = new CommandHandler(); _notificationSound = new NotificationSoundService(config.Notifications); + _updateService = new UpdateChecker(app); WireMainWindowEvents(); WireCommandHandlerEvents(); + _updateService.Start(); + _mainWindow.UpdateStatusBar("Disconnected"); } @@ -55,6 +60,7 @@ public sealed class AppOrchestrator : IDisposable { _connection?.DisposeAsync().AsTask().GetAwaiter().GetResult(); _apiClient?.Dispose(); + _updateService.Dispose(); } // ── Convenience Helpers ──────────────────────────────────────────────── @@ -72,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable { _mainWindow.OnConnectRequested += HandleConnect; _mainWindow.OnDisconnectRequested += HandleDisconnect; + _mainWindow.OnLogoutRequested += HandleLogout; _mainWindow.OnMessageSubmitted += HandleMessageSubmitted; _mainWindow.OnChannelSelected += HandleChannelSelected; _mainWindow.OnProfileRequested += HandleProfileRequested; @@ -80,6 +87,8 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnSavedServersRequested += HandleSavedServersRequested; _mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested; _mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested; + _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; + _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -235,6 +244,12 @@ public sealed class AppOrchestrator : IDisposable var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; + if (channel == HubConstants.DefaultChannel) + { + InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel.")); + return; + } + try { await _connection!.LeaveChannelAsync(channel); @@ -364,6 +379,16 @@ public sealed class AppOrchestrator : IDisposable private void HandleConnect() { + if (IsConnected) + { + var confirm = MessageBox.Query(_app, "Already Connected", + "You are already connected to a server.\nDisconnect and connect to a new one?", "Yes", "Cancel"); + + if (confirm != 0) return; + + HandleDisconnect(); + } + var result = ConnectDialog.Show(_app, _config.SavedServers); if (result is null) return; @@ -377,12 +402,55 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating...")); - var loginResponse = result.IsRegister - ? await _apiClient.RegisterAsync(result.Username, result.Password) - : await _apiClient.LoginAsync(result.Username, result.Password); + LoginResponse loginResponse; + + if (result.SavedRefreshToken is not null) + { + try + { + loginResponse = await _apiClient.LoginWithRefreshTokenAsync(result.SavedRefreshToken); + Log.Information("Authenticated via saved session for {User}", loginResponse.Username); + } + catch (Exception ex) + { + Log.Warning(ex, "Saved session expired or revoked"); + ClearSavedToken(result.ServerUrl); + InvokeUI(() => + { + _mainWindow.UpdateStatusBar("Disconnected"); + MessageBox.ErrorQuery(_app, "Session Expired", + "Your saved session has expired or was revoked.\nPlease log in with your password.", "OK"); + }); + _apiClient.Dispose(); + _apiClient = null; + return; + } + } + else if (result.IsRegister) + { + loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password); + } + else + { + loginResponse = await _apiClient.LoginAsync(result.Username, result.Password); + } _currentUsername = loginResponse.Username; + // Persist rotated refresh tokens for Remember Me + _apiClient.OnTokensRefreshed += () => + { + if (_apiClient?.RefreshToken is null) return; + var config = ConfigManager.Load(); + var server = config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase)); + if (server is not null && server.RememberMe) + { + server.RefreshToken = _apiClient.RefreshToken; + ConfigManager.Save(config); + } + }; + // Fetch encryption key for E2E message encryption InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key...")); try @@ -437,18 +505,6 @@ public sealed class AppOrchestrator : IDisposable FetchAndUpdateOnlineUsers(); SaveServerToConfig(result); - - // Check for newer version in the background - _ = Task.Run(async () => - { - var newVersion = await UpdateChecker.CheckForUpdateAsync(); - if (newVersion is not null) - { - InvokeUI(() => _mainWindow.AddSystemMessage( - HubConstants.DefaultChannel, - $"A new version of EchoHub is available: v{newVersion} (current: v{MainWindow.AppVersion}). Visit https://github.com/HueByte/EchoHub/releases")); - } - }); }, "Connection failed", "Connect"); } @@ -477,6 +533,38 @@ public sealed class AppOrchestrator : IDisposable }, "Disconnect error", "Disconnect"); } + private void HandleLogout() + { + Log.Information("Logging out from server"); + + RunAsync(async () => + { + if (_apiClient is not null) + { + var baseUrl = _apiClient.BaseUrl; + await _apiClient.LogoutAsync(); + ClearSavedToken(baseUrl); + } + + if (_connection is not null) + { + await _connection.DisconnectAsync(); + await _connection.DisposeAsync(); + _connection = null; + } + + _apiClient?.Dispose(); + _apiClient = null; + _joinedChannels.Clear(); + + InvokeUI(() => + { + _mainWindow.ClearAll(); + _mainWindow.UpdateStatusBar("Disconnected"); + }); + }, "Logout error", "Logout"); + } + private void HandleMessageSubmitted(string channelName, string content) { if (!IsConnected) @@ -722,7 +810,11 @@ public sealed class AppOrchestrator : IDisposable } var serverLines = _config.SavedServers - .Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}") + .Select(s => + { + var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session saved]" : ""; + return $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}{session}"; + }) .ToList(); MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK"); @@ -798,6 +890,40 @@ public sealed class AppOrchestrator : IDisposable }, "Failed to delete channel"); } + private void HandleAudioPlayRequested(string attachmentUrl, string fileName) + { + if (!IsAuthenticated) return; + + RunAsync(async () => + { + InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); + var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName); + InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName)); + }, "Failed to play audio"); + } + + private void HandleFileDownloadRequested(string attachmentUrl, string fileName) + { + if (!IsAuthenticated) return; + + RunAsync(async () => + { + InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); + var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName); + + try + { + var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true }; + System.Diagnostics.Process.Start(psi); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath); + InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}")); + } + }, "Failed to download file"); + } + // ── Connection Event Wiring ──────────────────────────────────────────── private void WireConnectionEvents(EchoHubConnection connection) @@ -897,7 +1023,7 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => { if (channel.IsPublic) - _mainWindow.EnsureChannelInList(channel.Name); + _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic); _mainWindow.SetChannelTopic(channel.Name, channel.Topic); }); }; @@ -957,11 +1083,25 @@ public sealed class AppOrchestrator : IDisposable Name = new Uri(result.ServerUrl).Host, Url = result.ServerUrl, Username = result.Username, - Token = _apiClient!.Token, + RefreshToken = result.RememberMe ? _apiClient!.RefreshToken : null, + RememberMe = result.RememberMe, LastConnected = DateTimeOffset.Now }; ConfigManager.SaveServer(savedServer); _config = ConfigManager.Load(); Log.Information("Connected successfully to {Url}", result.ServerUrl); } + + private void ClearSavedToken(string serverUrl) + { + var config = ConfigManager.Load(); + var server = config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); + if (server is not null) + { + server.RefreshToken = null; + ConfigManager.Save(config); + _config = config; + } + } } diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index b2304dd..71afd56 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -339,7 +339,7 @@ public class CommandHandler /nick - Set display name /color <#hex> - Set nickname color /theme - Switch theme - /send [-s|-m|-l] - Send a file or image (size: small/medium/large) + /send [-s|-m|-l] - Send file/image/audio (size flag for images) /avatar - Set your avatar /profile [username] - View a profile /servers - Open saved servers diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 80052a7..02154bd 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -20,7 +20,8 @@ public class SavedServer public required string Name { get; set; } public required string Url { get; set; } public string? Username { get; set; } - public string? Token { get; set; } + public string? RefreshToken { get; set; } + public bool RememberMe { get; set; } public DateTimeOffset LastConnected { get; set; } } diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj index 5e838b3..27b0268 100644 --- a/src/EchoHub.Client/EchoHub.Client.csproj +++ b/src/EchoHub.Client/EchoHub.Client.csproj @@ -5,19 +5,22 @@ + - + + PreserveNewest + PreserveNewest @@ -31,6 +34,8 @@ net10.0 enable enable + + hue_icon.ico diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 825cad0..fea3b2a 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable public string? RefreshToken => _refreshToken; public string BaseUrl { get; } + public event Action? OnTokensRefreshed; + public ApiClient(string baseUrl) { BaseUrl = baseUrl.TrimEnd('/'); @@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable SetTokens(result); } + public async Task LoginWithRefreshTokenAsync(string refreshToken) + { + var request = new RefreshRequest(refreshToken); + var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); + await EnsureSuccessAsync(response); + + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Token refresh returned empty response."); + + SetTokens(result); + return result; + } + public async Task LogoutAsync() { if (!string.IsNullOrEmpty(_refreshToken)) @@ -196,6 +211,23 @@ public sealed class ApiClient : IDisposable return await response.Content.ReadFromJsonAsync(); } + public async Task DownloadFileToTempAsync(string relativeUrl, string fileName) + { + EnsureAuthenticated(); + var response = await AuthenticatedGetAsync(relativeUrl); + await EnsureSuccessAsync(response); + + var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub"); + Directory.CreateDirectory(tempDir); + var tempPath = Path.Combine(tempDir, $"{Guid.NewGuid():N}_{fileName}"); + + await using var stream = await response.Content.ReadAsStreamAsync(); + await using var file = File.Create(tempPath); + await stream.CopyToAsync(file); + + return tempPath; + } + public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true) { EnsureAuthenticated(); @@ -296,6 +328,7 @@ public sealed class ApiClient : IDisposable _refreshToken = result.RefreshToken; _expiresAt = result.ExpiresAt; _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); + OnTokensRefreshed?.Invoke(); } /// diff --git a/src/EchoHub.Client/Services/AudioPlaybackService.cs b/src/EchoHub.Client/Services/AudioPlaybackService.cs new file mode 100644 index 0000000..7213ae7 --- /dev/null +++ b/src/EchoHub.Client/Services/AudioPlaybackService.cs @@ -0,0 +1,85 @@ +using NetCoreAudio; +using Serilog; + +namespace EchoHub.Client.Services; + +public class AudioPlaybackService +{ + private readonly Player _player = new(); + + public bool IsPlaying => _player.Playing; + public bool IsPaused => _player.Paused; + + public event EventHandler? PlaybackFinished; + + public AudioPlaybackService() + { + _player.PlaybackFinished += (s, e) => PlaybackFinished?.Invoke(this, EventArgs.Empty); + } + + public async Task PlayAsync(string filePath) + { + try + { + if (_player.Playing) + await _player.Stop(); + + await _player.Play(filePath); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to play audio file: {Path}", filePath); + } + } + + public async Task PauseAsync() + { + try + { + if (_player.Playing && !_player.Paused) + await _player.Pause(); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to pause audio playback"); + } + } + + public async Task ResumeAsync() + { + try + { + if (_player.Paused) + await _player.Resume(); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to resume audio playback"); + } + } + + public async Task StopAsync() + { + try + { + if (_player.Playing) + await _player.Stop(); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to stop audio playback"); + } + } + + public async Task SetVolumeAsync(byte volume) + { + try + { + await _player.SetVolume(Math.Min(volume, (byte)100)); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to set audio volume"); + } + } +} diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs index d73c9df..9a204ad 100644 --- a/src/EchoHub.Client/Services/UpdateChecker.cs +++ b/src/EchoHub.Client/Services/UpdateChecker.cs @@ -1,47 +1,106 @@ -using System.Net.Http.Json; -using System.Text.Json.Serialization; +using AlwaysUpToDate; + +using EchoHub.Client.UI; + +using Serilog; + +using Terminal.Gui.App; namespace EchoHub.Client.Services; -public static class UpdateChecker +public sealed class UpdateChecker : IDisposable { - private static readonly Uri ReleaseUrl = - new("https://api.github.com/repos/HueByte/EchoHub/releases/latest"); + private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version"; - /// - /// Checks GitHub for a newer release. Returns the new version string if one exists, or null. - /// Never throws — all errors are silently swallowed. - /// - public static async Task CheckForUpdateAsync() + private readonly Updater _updater; + private readonly IApplication _app; + private UpdateProgressDialog? _progressDialog; + + public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + + public UpdateChecker(IApplication app) { - try - { - using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client"); + _app = app; + _updater = new Updater(TimeSpan.FromHours(1), ManifestUrl, false); - var release = await http.GetFromJsonAsync(ReleaseUrl); - if (release?.TagName is null) - return null; - - var tag = release.TagName.TrimStart('v', 'V'); - if (!Version.TryParse(tag, out var latest)) - return null; - - var currentStr = typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3); - if (currentStr is null || !Version.TryParse(currentStr, out var current)) - return null; - - return latest > current ? tag : null; - } - catch - { - return null; - } + _updater.UpdateAvailable += OnUpdateAvailable; + _updater.ProgressChanged += OnProgressChanged; + _updater.UpdateStarted += OnUpdateStarted; + _updater.NoUpdateAvailable += OnNoUpdateAvailable; + _updater.OnException += OnException; } - private sealed class GitHubRelease + public void Start() { - [JsonPropertyName("tag_name")] - public string? TagName { get; set; } +#if RELEASE + _updater.Start(); +#endif + } + + private async void OnUpdateAvailable(string version, string changelogUrl) + { + Log.Information("Update available: v{Version}", version); + + var confirmed = false; + _app.Invoke(() => + { + confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version); + + + if (confirmed) + { + _progressDialog = new UpdateProgressDialog(_app, version); + + // Start the update; progress is reported via OnProgressChanged + _ = Task.Run(async () => + { + await _updater.UpdateAsync(); + }); + + _progressDialog?.Show(); + } + }); + } + + private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage) + { + var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f; + var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)"; + + if (!progressPercentage.HasValue) + { + statusText = $"{step}..."; + } + + _progressDialog?.UpdateProgress(fraction, statusText); + } + + private void OnUpdateStarted(string version) + { + Log.Information("Update started: v{Version}", version); + } + + private void OnNoUpdateAvailable() + { + Log.Debug("No update available"); + } + + private void OnException(Exception exception) + { + Log.Error(exception, "Update check failed"); + _app.Invoke(() => + { + _progressDialog?.Close(); + _progressDialog = null; + }); + } + + public void Dispose() + { + _updater.UpdateAvailable -= OnUpdateAvailable; + _updater.ProgressChanged -= OnProgressChanged; + _updater.UpdateStarted -= OnUpdateStarted; + _updater.NoUpdateAvailable -= OnNoUpdateAvailable; + _updater.OnException -= OnException; } } diff --git a/src/EchoHub.Client/Themes/ThemeManager.cs b/src/EchoHub.Client/Themes/ThemeManager.cs index 056e6f3..763057b 100644 --- a/src/EchoHub.Client/Themes/ThemeManager.cs +++ b/src/EchoHub.Client/Themes/ThemeManager.cs @@ -418,30 +418,30 @@ public static class ThemeManager Base = new ThemeColors { Foreground = "White", - Background = "Black", + Background = "None", FocusForeground = "BrightCyan", - FocusBackground = "Black" + FocusBackground = "None" }, Menu = new ThemeColors { Foreground = "White", - Background = "Black", + Background = "None", FocusForeground = "BrightCyan", - FocusBackground = "Black" + FocusBackground = "None" }, Dialog = new ThemeColors { Foreground = "White", - Background = "Black", + Background = "DarkGray", FocusForeground = "BrightCyan", FocusBackground = "Black" }, Status = new ThemeColors { Foreground = "Gray", - Background = "Black", + Background = "None", FocusForeground = "Gray", - FocusBackground = "Black" + FocusBackground = "None" } }; diff --git a/src/EchoHub.Client/UI/AudioPlayerDialog.cs b/src/EchoHub.Client/UI/AudioPlayerDialog.cs new file mode 100644 index 0000000..448a705 --- /dev/null +++ b/src/EchoHub.Client/UI/AudioPlayerDialog.cs @@ -0,0 +1,410 @@ +using EchoHub.Client.Services; +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.UI; + +public sealed class AudioPlayerDialog +{ + // Block characters for wave animation (increasing height) + private static readonly string[] WaveBlocks = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"]; + private const int WaveBarCount = 24; + private const int AnimationIntervalMs = 150; + + private static readonly Attribute WaveActiveAttr = new(new Color(180, 100, 255), Color.None); + private static readonly Attribute WaveIdleAttr = new(new Color(80, 50, 120), Color.None); + private static readonly Attribute FileNameAttr = new(new Color(180, 100, 255), Color.None); + private static readonly Attribute StatusPlayingAttr = new(new Color(0, 200, 0), Color.None); + private static readonly Attribute StatusPausedAttr = new(new Color(220, 180, 0), Color.None); + private static readonly Attribute StatusStoppedAttr = new(new Color(160, 160, 160), Color.None); + + public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName) + { + var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 14 }; + + // ── File name ── + var fileLabel = new Label + { + Text = $"\u266a {TruncateFileName(fileName, 44)}", + X = 2, + Y = 1, + Width = Dim.Fill(2) + }; + + // ── Wave visualization ── + var waveLabel = new Label + { + X = 2, + Y = 3, + Width = Dim.Fill(2), + Height = 1 + }; + + // ── Status label ── + var statusLabel = new Label + { + Text = "Stopped", + X = 2, + Y = 5, + Width = 20 + }; + + // ── Volume controls ── + var volumeHeaderLabel = new Label + { + Text = "Volume:", + X = 2, + Y = 7 + }; + + byte currentVolume = 50; + var volumeBar = new ProgressBar + { + X = 14, + Y = 7, + Width = 20, + Height = 1, + Fraction = currentVolume / 100f, + ProgressBarStyle = ProgressBarStyle.Continuous + }; + + var volumePercentLabel = new Label + { + Text = $"{currentVolume}%", + X = 35, + Y = 7, + Width = 5 + }; + + var volDownButton = new Button + { + Text = "-", + X = 10, + Y = 7, + Width = 3 + }; + + var volUpButton = new Button + { + Text = "+", + X = 41, + Y = 7, + Width = 3 + }; + + // ── Playback controls ── + var playButton = new Button + { + Text = "\u25b6 Play", + X = 2, + Y = 10, + IsDefault = true + }; + + var stopButton = new Button + { + Text = "\u25a0 Stop", + X = Pos.Right(playButton) + 2, + Y = 10 + }; + + var closeButton = new Button + { + Text = "Close", + X = Pos.Right(stopButton) + 2, + Y = 10 + }; + + // ── Animation state ── + var animationOffset = 0; + var random = new Random(); + // Pre-generate a repeating wave pattern + var wavePattern = new int[WaveBarCount + 8]; + for (int i = 0; i < wavePattern.Length; i++) + wavePattern[i] = random.Next(0, WaveBlocks.Length); + + Timer? animationTimer = null; + var isDisposed = false; + + // ── Helper functions ── + void UpdateWave(bool isActive) + { + if (isDisposed) return; + + var bars = new string[WaveBarCount]; + for (int i = 0; i < WaveBarCount; i++) + { + if (isActive) + { + var idx = wavePattern[(i + animationOffset) % wavePattern.Length]; + bars[i] = WaveBlocks[idx]; + } + else + { + bars[i] = WaveBlocks[1]; // low idle bars + } + } + waveLabel.Text = string.Join(" ", bars); + } + + void UpdateStatus() + { + if (isDisposed) return; + + if (audioService.IsPlaying && !audioService.IsPaused) + { + statusLabel.Text = "Playing"; + playButton.Text = "\u23f8 Pause"; + } + else if (audioService.IsPaused) + { + statusLabel.Text = "Paused"; + playButton.Text = "\u25b6 Resume"; + } + else + { + statusLabel.Text = "Stopped"; + playButton.Text = "\u25b6 Play"; + } + } + + void StartAnimation() + { + animationTimer?.Dispose(); + animationTimer = new Timer(_ => + { + if (isDisposed) return; + animationOffset++; + // Shuffle a few bars each tick for organic movement + var idx = random.Next(0, wavePattern.Length); + wavePattern[idx] = random.Next(0, WaveBlocks.Length); + + app.Invoke(() => + { + if (isDisposed) return; + UpdateWave(true); + }); + }, null, 0, AnimationIntervalMs); + } + + void StopAnimation() + { + animationTimer?.Dispose(); + animationTimer = null; + if (!isDisposed) + UpdateWave(false); + } + + async Task UpdateVolume(byte newVolume) + { + currentVolume = Math.Clamp(newVolume, (byte)0, (byte)100); + await audioService.SetVolumeAsync(currentVolume); + if (!isDisposed) + { + volumeBar.Fraction = currentVolume / 100f; + volumePercentLabel.Text = $"{currentVolume}%"; + } + } + + // ── Custom drawing for colored elements ── + fileLabel.DrawingContent += (s, e) => + { + var normalAttr = fileLabel.GetAttributeForRole(VisualRole.Normal); + var resolvedAttr = FileNameAttr.Background == Color.None + ? FileNameAttr with { Background = normalAttr.Background } + : FileNameAttr; + fileLabel.SetAttribute(resolvedAttr); + fileLabel.Move(0, 0); + var text = fileLabel.Text ?? ""; + foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text)) + fileLabel.AddStr(g); + // Fill remaining width + var width = fileLabel.Viewport.Width; + var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text); + for (int i = textCols; i < width; i++) + fileLabel.AddStr(" "); + e.Cancel = true; + }; + + waveLabel.DrawingContent += (s, e) => + { + var normalAttr = waveLabel.GetAttributeForRole(VisualRole.Normal); + var attr = (audioService.IsPlaying && !audioService.IsPaused) ? WaveActiveAttr : WaveIdleAttr; + var resolvedAttr = attr.Background == Color.None + ? attr with { Background = normalAttr.Background } + : attr; + waveLabel.SetAttribute(resolvedAttr); + waveLabel.Move(0, 0); + var text = waveLabel.Text ?? ""; + foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text)) + waveLabel.AddStr(g); + var width = waveLabel.Viewport.Width; + var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text); + for (int i = textCols; i < width; i++) + waveLabel.AddStr(" "); + e.Cancel = true; + }; + + statusLabel.DrawingContent += (s, e) => + { + var normalAttr = statusLabel.GetAttributeForRole(VisualRole.Normal); + Attribute attr; + if (audioService.IsPlaying && !audioService.IsPaused) + attr = StatusPlayingAttr; + else if (audioService.IsPaused) + attr = StatusPausedAttr; + else + attr = StatusStoppedAttr; + + var resolvedAttr = attr.Background == Color.None + ? attr with { Background = normalAttr.Background } + : attr; + statusLabel.SetAttribute(resolvedAttr); + statusLabel.Move(0, 0); + var text = statusLabel.Text ?? ""; + foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text)) + statusLabel.AddStr(g); + var width = statusLabel.Viewport.Width; + var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text); + for (int i = textCols; i < width; i++) + statusLabel.AddStr(" "); + e.Cancel = true; + }; + + // ── Event handlers ── + playButton.Accepting += (s, e) => + { + e.Handled = true; + Task.Run(async () => + { + if (audioService.IsPaused) + { + await audioService.ResumeAsync(); + app.Invoke(() => + { + UpdateStatus(); + StartAnimation(); + }); + } + else if (audioService.IsPlaying) + { + await audioService.PauseAsync(); + app.Invoke(() => + { + UpdateStatus(); + StopAnimation(); + }); + } + else + { + await audioService.SetVolumeAsync(currentVolume); + await audioService.PlayAsync(filePath); + app.Invoke(() => + { + UpdateStatus(); + StartAnimation(); + }); + } + }); + }; + + stopButton.Accepting += (s, e) => + { + e.Handled = true; + Task.Run(async () => + { + await audioService.StopAsync(); + app.Invoke(() => + { + UpdateStatus(); + StopAnimation(); + }); + }); + }; + + closeButton.Accepting += (s, e) => + { + e.Handled = true; + isDisposed = true; + animationTimer?.Dispose(); + _ = audioService.StopAsync(); // fire-and-forget + app.RequestStop(); + }; + + volDownButton.Accepting += (s, e) => + { + e.Handled = true; + var newVol = (byte)Math.Max(0, currentVolume - 10); + Task.Run(async () => + { + await UpdateVolume(newVol); + app.Invoke(() => + { + volumeBar.SetNeedsDraw(); + volumePercentLabel.SetNeedsDraw(); + }); + }); + }; + + volUpButton.Accepting += (s, e) => + { + e.Handled = true; + var newVol = (byte)Math.Min(100, currentVolume + 10); + Task.Run(async () => + { + await UpdateVolume(newVol); + app.Invoke(() => + { + volumeBar.SetNeedsDraw(); + volumePercentLabel.SetNeedsDraw(); + }); + }); + }; + + // Handle playback finishing naturally + EventHandler? finishedHandler = null; + finishedHandler = (s, e) => + { + app.Invoke(() => + { + if (isDisposed) return; + UpdateStatus(); + StopAnimation(); + }); + }; + audioService.PlaybackFinished += finishedHandler; + + // ── Initial state ── + UpdateWave(false); + UpdateStatus(); + + dialog.Add(fileLabel, waveLabel, statusLabel, + volumeHeaderLabel, volDownButton, volumeBar, volumePercentLabel, volUpButton, + playButton, stopButton, closeButton); + + playButton.SetFocus(); + app.Run(dialog); + + // Cleanup + isDisposed = true; + animationTimer?.Dispose(); + audioService.PlaybackFinished -= finishedHandler; + } + + private static string TruncateFileName(string name, int maxLen) + { + if (name.Length <= maxLen) + return name; + + var ext = Path.GetExtension(name); + var stem = Path.GetFileNameWithoutExtension(name); + var available = maxLen - ext.Length - 3; // 3 for "..." + if (available < 1) + return name[..maxLen]; + + return stem[..available] + "..." + ext; + } +} diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index d4727fc..c6830b2 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Collections.Specialized; using System.Text.RegularExpressions; +using EchoHub.Core.Models; using Terminal.Gui.Drawing; using Terminal.Gui.Text; using Terminal.Gui.Views; @@ -22,6 +23,9 @@ public partial class ChatLine public int TextLength { get; } public Guid? MessageId { get; set; } public bool IsMention { get; set; } + public string? AttachmentUrl { get; set; } + public string? AttachmentFileName { get; set; } + public MessageType? Type { get; set; } public ChatLine(string plainText) { @@ -92,6 +96,15 @@ public partial class ChatLine if (currentSegments.Count > 0) results.Add(new ChatLine(currentSegments)); + // Propagate attachment/type metadata to all wrapped lines so they remain clickable + foreach (var wrapped in results) + { + wrapped.AttachmentUrl = AttachmentUrl; + wrapped.AttachmentFileName = AttachmentFileName; + wrapped.Type = Type; + wrapped.MessageId = MessageId; + } + return results; } @@ -118,7 +131,7 @@ public partial class ChatLine Color? currentFg = null; Color? currentBg = null; var defaultFg = defaultAttr?.Foreground; - var defaultBg = defaultAttr?.Background ?? Color.Black; + var defaultBg = defaultAttr?.Background ?? Color.None; Attribute? BuildAttr() { @@ -217,6 +230,8 @@ public class ChatListSource : IListDataSource RaiseCollectionChanged(); } + public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null; + public bool IsMarked(int item) => false; public void SetMark(int item, bool value) { } public IList ToList() => _lines.Select(l => l.ToString()).ToList(); @@ -235,8 +250,10 @@ public class ChatListSource : IListDataSource foreach (var segment in chatLine.Segments) { var attr = segment.Color ?? normalAttr; + if (attr.Background == Color.None) + attr = attr with { Background = normalAttr.Background }; if (mentionBg.HasValue) - attr = new Attribute(attr.Foreground, mentionBg.Value); + attr = attr with { Background = mentionBg.Value }; listView.SetAttribute(attr); foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text)) @@ -287,10 +304,10 @@ public class ChannelListSource : IListDataSource public int MaxItemLength { get; private set; } public bool SuspendCollectionChangedEvent { get; set; } - private static readonly Attribute ActiveAttr = new(Color.White, Color.Black); - private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Black); - private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Black); - private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Black); + private static readonly Attribute ActiveAttr = new(Color.White, Color.None); + private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None); + private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None); + private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None); public void Update(List channels, Dictionary unread, string activeChannel) { @@ -318,11 +335,16 @@ public class ChannelListSource : IListDataSource _unreadCounts.TryGetValue(name, out var unread); var hasUnread = unread > 0; + var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); var prefix = isActive ? "> " : " "; var channelText = $"#{name}"; var badge = hasUnread ? $" ({unread})" : ""; + // Resolve Transparent backgrounds to the view's actual background + Attribute Resolve(Attribute attr) => + attr.Background == Color.None ? attr with { Background = normalAttr.Background } : attr; + int drawnChars = 0; if (selected) @@ -332,20 +354,20 @@ public class ChannelListSource : IListDataSource } else { - listView.SetAttribute(isActive ? ActiveAttr : NormalAttr); + listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr)); drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width); - listView.SetAttribute(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr); + listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr)); drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width); if (hasUnread) { - listView.SetAttribute(BadgeAttr); + listView.SetAttribute(Resolve(BadgeAttr)); drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width); } } - var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal); + var fillAttr = selected ? focusAttr : normalAttr; listView.SetAttribute(fillAttr); for (int i = drawnChars; i < width; i++) listView.AddStr(" "); @@ -457,14 +479,16 @@ static class RenderHelpers /// public static partial class ChatColors { - public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black); - public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black); + public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None); + public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None); public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0)); - public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.Black); - public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black); - public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black); - public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black); - public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black); + public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None); + public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None); + public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None); + public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None); + public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.None); + public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None); + public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None); /// /// Split text around @mentions, giving each @word the MentionTextAttr accent color. @@ -513,7 +537,7 @@ public static class ColorHelper var r = Convert.ToInt32(hex[..2], 16); var g = Convert.ToInt32(hex[2..4], 16); var b = Convert.ToInt32(hex[4..6], 16); - return new Attribute(new Color(r, g, b), Color.Black); + return new Attribute(new Color(r, g, b), Color.None); } catch { diff --git a/src/EchoHub.Client/UI/ConnectDialog.cs b/src/EchoHub.Client/UI/ConnectDialog.cs index 614911d..a3f770e 100644 --- a/src/EchoHub.Client/UI/ConnectDialog.cs +++ b/src/EchoHub.Client/UI/ConnectDialog.cs @@ -9,7 +9,9 @@ namespace EchoHub.Client.UI; /// /// Result returned from the connect dialog. /// -public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister); +public record ConnectDialogResult( + string ServerUrl, string Username, string Password, + bool IsRegister, bool RememberMe, string? SavedRefreshToken); /// /// A Terminal.Gui dialog for entering server connection and authentication details. @@ -27,11 +29,12 @@ public sealed class ConnectDialog savedServers ??= []; var hasSavedServers = savedServers.Count > 0; - var dialogHeight = hasSavedServers ? 20 : 16; + var dialogHeight = hasSavedServers ? 22 : 18; var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight }; int yOffset = 0; + SavedServer? selectedSavedServer = null; // -- Saved Servers section (if any) ----------------------------------- ListView? savedServerList = null; @@ -46,7 +49,11 @@ public sealed class ConnectDialog dialog.Add(savedLabel); var serverDisplayNames = savedServers - .Select(s => $"{s.Name} ({s.Username ?? "?"})") + .Select(s => + { + var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session]" : ""; + return $"{s.Name} ({s.Username ?? "?"}){session}"; + }) .ToList(); savedServerList = new ListView @@ -115,17 +122,34 @@ public sealed class ConnectDialog Secret = true }; + var tokenHintLabel = new Label + { + Text = "Session saved \u2014 password optional", + X = 15, + Y = yOffset + 6, + Width = Dim.Fill(2), + Visible = false + }; + + var rememberMeCheckbox = new CheckBox + { + Text = "Remember me", + X = 15, + Y = yOffset + 7, + Value = CheckState.UnChecked + }; + var displayLabel = new Label { Text = "Display Name:", X = 1, - Y = yOffset + 7 + Y = yOffset + 9 }; var displayField = new TextField { Text = "", X = 15, - Y = yOffset + 7, + Y = yOffset + 9, Width = Dim.Fill(2) }; @@ -134,21 +158,21 @@ public sealed class ConnectDialog Text = "Login", IsDefault = true, X = Pos.Center() - 20, - Y = yOffset + 9 + Y = yOffset + 11 }; var registerButton = new Button { Text = "Register", X = Pos.Center() - 5, - Y = yOffset + 9 + Y = yOffset + 11 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 10, - Y = yOffset + 9 + Y = yOffset + 11 }; // Wire saved server selection to auto-fill fields @@ -159,15 +183,32 @@ public sealed class ConnectDialog var index = e.NewValue; if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count) { - var server = savedServers[index.Value]; - urlField.Text = server.Url; - userField.Text = server.Username ?? ""; + selectedSavedServer = savedServers[index.Value]; + urlField.Text = selectedSavedServer.Url; + userField.Text = selectedSavedServer.Username ?? ""; + rememberMeCheckbox.Value = selectedSavedServer.RememberMe + ? CheckState.Checked : CheckState.UnChecked; + + if (!string.IsNullOrEmpty(selectedSavedServer.RefreshToken)) + { + passField.Text = ""; + tokenHintLabel.Visible = true; + } + else + { + tokenHintLabel.Visible = false; + } } }; // Pre-fill with the first saved server + selectedSavedServer = savedServers[0]; urlField.Text = savedServers[0].Url; userField.Text = savedServers[0].Username ?? ""; + rememberMeCheckbox.Value = savedServers[0].RememberMe + ? CheckState.Checked : CheckState.UnChecked; + if (!string.IsNullOrEmpty(savedServers[0].RefreshToken)) + tokenHintLabel.Visible = true; } loginButton.Accepting += (s, e) => @@ -175,15 +216,34 @@ public sealed class ConnectDialog var url = urlField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty; var pass = passField.Text ?? string.Empty; + var rememberMe = rememberMeCheckbox.Value == CheckState.Checked; - if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user)) { - MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK"); + MessageBox.ErrorQuery(app, "Validation", "Server URL and username are required.", "OK"); e.Handled = true; return; } - result = new ConnectDialogResult(url, user, pass, IsRegister: false); + // Determine if we can use a saved token + string? savedRefreshToken = null; + if (string.IsNullOrEmpty(pass) + && selectedSavedServer is not null + && !string.IsNullOrEmpty(selectedSavedServer.RefreshToken) + && string.Equals(selectedSavedServer.Url, url, StringComparison.OrdinalIgnoreCase) + && string.Equals(selectedSavedServer.Username, user, StringComparison.OrdinalIgnoreCase)) + { + savedRefreshToken = selectedSavedServer.RefreshToken; + } + + if (string.IsNullOrEmpty(pass) && savedRefreshToken is null) + { + MessageBox.ErrorQuery(app, "Validation", "Password is required.", "OK"); + e.Handled = true; + return; + } + + result = new ConnectDialogResult(url, user, pass, IsRegister: false, rememberMe, savedRefreshToken); e.Handled = true; app.RequestStop(); }; @@ -193,6 +253,7 @@ public sealed class ConnectDialog var url = urlField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty; var pass = passField.Text ?? string.Empty; + var rememberMe = rememberMeCheckbox.Value == CheckState.Checked; if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) { @@ -201,7 +262,7 @@ public sealed class ConnectDialog return; } - result = new ConnectDialogResult(url, user, pass, IsRegister: true); + result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null); e.Handled = true; app.RequestStop(); }; @@ -214,7 +275,8 @@ public sealed class ConnectDialog }; dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField, - displayLabel, displayField, loginButton, registerButton, cancelButton); + tokenHintLabel, rememberMeCheckbox, displayLabel, displayField, + loginButton, registerButton, cancelButton); if (hasSavedServers && savedServerList is not null) savedServerList.SetFocus(); diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 804ef21..fdd4000 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -58,9 +58,11 @@ public sealed class MainWindow : Runnable private readonly Dictionary> _channelMessages = []; private readonly Dictionary _channelUnread = []; private readonly Dictionary _channelTopics = []; + private readonly Dictionary _channelPublic = []; private readonly ChannelListSource _channelListSource; private string _currentChannel = string.Empty; private string _currentUser = string.Empty; + private string _connectionStatus = "Disconnected"; private int _lastChatWidth; /// @@ -83,6 +85,11 @@ public sealed class MainWindow : Runnable /// public event Action? OnDisconnectRequested; + /// + /// Fired when the user requests to logout (disconnect + revoke session). + /// + public event Action? OnLogoutRequested; + /// /// Fired when the user requests to open their profile panel. /// @@ -113,6 +120,16 @@ public sealed class MainWindow : Runnable /// public event Action? OnDeleteChannelRequested; + /// + /// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName. + /// + public event Action? OnAudioPlayRequested; + + /// + /// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName. + /// + public event Action? OnFileDownloadRequested; + public MainWindow(IApplication app) { _app = app; @@ -175,6 +192,7 @@ public sealed class MainWindow : Runnable Height = Dim.Fill() }; _messageList.Source = new ChatListSource(); + _messageList.Accepting += OnMessageListAccepting; _chatFrame.Add(_messageList); Add(_chatFrame); @@ -223,16 +241,17 @@ public sealed class MainWindow : Runnable _usersFrame.Add(_usersList); Add(_usersFrame); - // Status bar at the very bottom + // Status bar at the very bottom — custom drawing for colored connection state _statusLabel = new Label { - Text = "Disconnected", + Text = "", X = 0, Y = Pos.AnchorEnd(1), Width = Dim.Fill(), Height = 1 }; _statusLabel.SetScheme(SchemeManager.GetScheme("Menu")); + _statusLabel.DrawingContent += OnStatusBarDrawContent; Add(_statusLabel); // Apply our custom color schemes to all views @@ -308,6 +327,7 @@ public sealed class MainWindow : Runnable { new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty), + new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty), new Line(), new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty), new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty), @@ -360,6 +380,31 @@ public sealed class MainWindow : Runnable } } + private void OnMessageListAccepting(object? sender, CommandEventArgs e) + { + 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?.AttachmentUrl is null || line.AttachmentFileName is null) + return; + + if (line.Type == MessageType.Audio) + { + OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + e.Handled = true; + } + else if (line.Type == MessageType.File) + { + OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + e.Handled = true; + } + } + private void OnInputKeyDown(object? sender, Key e) { if (e.KeyCode == TabKey.KeyCode) @@ -600,10 +645,12 @@ public sealed class MainWindow : Runnable { _channelNames.Clear(); _channelTopics.Clear(); + _channelPublic.Clear(); foreach (var ch in channels) { _channelNames.Add(ch.Name); _channelTopics[ch.Name] = ch.Topic; + _channelPublic[ch.Name] = ch.IsPublic; if (!_channelMessages.ContainsKey(ch.Name)) _channelMessages[ch.Name] = []; } @@ -613,8 +660,11 @@ public sealed class MainWindow : Runnable /// /// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// - public void EnsureChannelInList(string channelName) + public void EnsureChannelInList(string channelName, bool? isPublic = null) { + if (isPublic.HasValue) + _channelPublic[channelName] = isPublic.Value; + if (_channelNames.Contains(channelName)) return; @@ -631,6 +681,7 @@ public sealed class MainWindow : Runnable { _channelNames.Remove(channelName); _channelTopics.Remove(channelName); + _channelPublic.Remove(channelName); RefreshChannelList(); } @@ -657,9 +708,76 @@ public sealed class MainWindow : Runnable /// public void UpdateStatusBar(string status) { - var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}"; - var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}"; - _statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}"; + _connectionStatus = status; + _statusLabel.SetNeedsDraw(); + } + + private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.None); + private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None); + private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None); + private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None); + + private void OnStatusBarDrawContent(object? sender, DrawEventArgs e) + { + var menuScheme = SchemeManager.GetScheme("Menu"); + var normalAttr = menuScheme?.Normal ?? _statusLabel.GetAttributeForRole(VisualRole.Normal); + var width = _statusLabel.Viewport.Width; + if (width <= 0) return; + + // Resolve None background for colored segments + var bg = normalAttr.Background; + Attribute Resolve(Attribute a) => a.Background == Color.None ? a with { Background = bg } : a; + + int col = 0; + + void Write(string text, Attribute attr) + { + _statusLabel.SetAttribute(Resolve(attr)); + foreach (var g in GraphemeHelper.GetGraphemes(text)) + { + var cols = Math.Max(g.GetColumns(), 1); + if (col + cols > width) return; + _statusLabel.Move(col, 0); + _statusLabel.AddStr(g); + col += cols; + } + } + + // EchoHub branding + Write(" EchoHub", Resolve(StatusBrandAttr)); + Write($" \u2502 v{AppVersion} \u2502 ", normalAttr); + + // Connection state with color + var statusAttr = _connectionStatus switch + { + "Connected" => StatusConnectedAttr, + "Disconnected" => StatusDisconnectedAttr, + _ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc. + }; + Write(_connectionStatus, Resolve(statusAttr)); + + // User + if (!string.IsNullOrEmpty(_currentUser)) + Write($" \u2502 User: {_currentUser}", normalAttr); + + // Channel + type + if (!string.IsNullOrEmpty(_currentChannel)) + { + _channelPublic.TryGetValue(_currentChannel, out var isPublic); + var typeSuffix = isPublic ? "public" : "private"; + Write($" \u2502 #{_currentChannel} - {typeSuffix}", normalAttr); + } + + // Fill remaining space + _statusLabel.SetAttribute(normalAttr); + while (col < width) + { + _statusLabel.Move(col, 0); + _statusLabel.AddStr(" "); + col++; + } + + e.Cancel = true; } /// @@ -694,6 +812,7 @@ public sealed class MainWindow : Runnable RefreshMessages(); UpdateTopicBar(); + _statusLabel.SetNeedsDraw(); // Update channel list selection var idx = _channelNames.IndexOf(channelName); @@ -725,6 +844,7 @@ public sealed class MainWindow : Runnable _channelMessages.Clear(); _channelUnread.Clear(); _channelTopics.Clear(); + _channelPublic.Clear(); _currentChannel = string.Empty; _currentUser = string.Empty; _channelListSource.Update([], [], string.Empty); @@ -900,10 +1020,26 @@ public sealed class MainWindow : Runnable } 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 fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : ""; - lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}")); + 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: @@ -962,6 +1098,20 @@ public sealed class MainWindow : Runnable return new ChatLine(segments); } + /// + /// Build a chat line with a colored suffix (used for audio/file indicators). + /// + private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor) + { + var segments = new List + { + new($"[{time}] ", ChatColors.TimestampAttr), + new(senderName, senderColor), + new(suffix, suffixColor) + }; + return new ChatLine(segments); + } + /// /// Build a chat line with @mention highlighting in the suffix text. /// @@ -1055,4 +1205,18 @@ public sealed class MainWindow : Runnable return result; } + + private static string FormatFileSize(long? bytes) + { + if (bytes is null or 0) + return "?"; + + return bytes.Value switch + { + < 1024 => $"{bytes.Value} B", + < 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB", + < 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB", + _ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB" + }; + } } diff --git a/src/EchoHub.Client/UI/UpdateConfirmDialog.cs b/src/EchoHub.Client/UI/UpdateConfirmDialog.cs new file mode 100644 index 0000000..d305377 --- /dev/null +++ b/src/EchoHub.Client/UI/UpdateConfirmDialog.cs @@ -0,0 +1,58 @@ +using Terminal.Gui.App; +using Terminal.Gui.Views; +using Terminal.Gui.ViewBase; + +namespace EchoHub.Client.UI; + +public sealed class UpdateConfirmDialog +{ + public static bool Show(IApplication app, string currentVersion, string newVersion) + { + var confirmed = false; + + var dialog = new Dialog { Title = "Update Available", Width = 50, Height = 10 }; + + var messageLabel = new Label + { + Text = $"A new version of EchoHub is available.\n\n Current: {currentVersion}\n Latest: {newVersion}", + X = 1, + Y = 1, + Width = Dim.Fill(2), + Height = 4 + }; + + var updateButton = new Button + { + Text = "Update", + IsDefault = true, + X = Pos.Center() - 10, + Y = 6 + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center() + 5, + Y = 6 + }; + + updateButton.Accepting += (s, e) => + { + confirmed = true; + e.Handled = true; + app.RequestStop(); + }; + + cancelButton.Accepting += (s, e) => + { + confirmed = false; + e.Handled = true; + app.RequestStop(); + }; + + dialog.Add(messageLabel, updateButton, cancelButton); + app.Run(dialog); + + return confirmed; + } +} diff --git a/src/EchoHub.Client/UI/UpdateProgressDialog.cs b/src/EchoHub.Client/UI/UpdateProgressDialog.cs new file mode 100644 index 0000000..1d71f04 --- /dev/null +++ b/src/EchoHub.Client/UI/UpdateProgressDialog.cs @@ -0,0 +1,61 @@ +using Terminal.Gui.App; +using Terminal.Gui.Views; +using Terminal.Gui.ViewBase; + +namespace EchoHub.Client.UI; + +public sealed class UpdateProgressDialog +{ + private readonly Dialog _dialog; + private readonly ProgressBar _progressBar; + private readonly Label _infoLabel; + private readonly IApplication _app; + + public UpdateProgressDialog(IApplication app, string newVersion) + { + _app = app; + + _dialog = new Dialog { Title = $"Updating to {newVersion}", Width = 50, Height = 10 }; + + _infoLabel = new Label + { + Text = "Preparing update...", + X = 1, + Y = 1, + Width = Dim.Fill(2) + }; + + _progressBar = new ProgressBar + { + X = 1, + Y = 3, + Width = Dim.Fill(2), + Fraction = 0f + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center(), + Y = 6 + }; + + _dialog.Add(_infoLabel, _progressBar); + } + + public void UpdateProgress(float fraction, string statusText) + { + _progressBar.Fraction = fraction; + _infoLabel.Text = statusText; + } + + public void Show() + { + _app.Run(_dialog); + } + + public void Close() + { + _app.RequestStop(); + } +} diff --git a/src/EchoHub.Client/hue_icon.ico b/src/EchoHub.Client/hue_icon.ico new file mode 100644 index 0000000..dd2668c Binary files /dev/null and b/src/EchoHub.Client/hue_icon.ico differ diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 024d956..de149bf 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -6,7 +6,9 @@ public static class HubConstants public const string DefaultChannel = "general"; public const int DefaultHistoryCount = 100; public const int MaxMessageLength = 2000; - public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB + public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB + public const int MaxAudioFileSizeBytes = 10 * 1024 * 1024; // 10 MB + public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB public const int MaxMessageNewlines = 30; public const int MaxConsecutiveNewlines = 1; diff --git a/src/EchoHub.Core/Contracts/IChannelService.cs b/src/EchoHub.Core/Contracts/IChannelService.cs new file mode 100644 index 0000000..e445160 --- /dev/null +++ b/src/EchoHub.Core/Contracts/IChannelService.cs @@ -0,0 +1,22 @@ +using EchoHub.Core.DTOs; + +namespace EchoHub.Core.Contracts; + +public interface IChannelService +{ + // Channel CRUD + Task> GetChannelsAsync(Guid userId, int offset, int limit); + Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic); + Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic); + Task DeleteChannelAsync(Guid callerUserId, string channelName); + + // Channel queries + Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName); + Task> GetChannelListAsync(); + Task GetChannelByNameAsync(string channelName); + + // Membership + Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName); +} + +public record ChannelListItem(string Name, string? Topic, int OnlineCount); diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index c4102fb..03ea1c2 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -25,12 +25,8 @@ public interface IChatService Task BroadcastMessageAsync(string channelName, MessageDto message); Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null); - // Query operations (used by IRC gateway for WHOIS, TOPIC, LIST, AUTH) + // Query operations (used by IRC gateway for WHOIS, AUTH) Task GetUserProfileAsync(string username); - Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName); - Task> GetChannelListAsync(); Task> GetChannelsForUserAsync(string username); Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); } - -public record ChannelListItem(string Name, string? Topic, int OnlineCount); diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index be772b2..ce742d9 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -12,6 +12,7 @@ public record MessageDto( string? AttachmentUrl, string? AttachmentFileName, DateTimeOffset SentAt, + long? AttachmentFileSize = null, List? Embeds = null); public record ChannelDto( diff --git a/src/EchoHub.Core/DTOs/CommonDtos.cs b/src/EchoHub.Core/DTOs/CommonDtos.cs index a5d73c4..d41c4f6 100644 --- a/src/EchoHub.Core/DTOs/CommonDtos.cs +++ b/src/EchoHub.Core/DTOs/CommonDtos.cs @@ -1,5 +1,26 @@ namespace EchoHub.Core.DTOs; +public record ApiResponse(bool Success, string? Message = null, List? Errors = null); + +public record ApiResponse(bool Success, string? Message = null, List? Errors = null, T? Data = default); + public record ErrorResponse(string Error, string? Detail = null); public record PaginatedResponse(List Items, int Total, int Offset, int Limit); + +public enum ChannelError +{ + ValidationFailed, + AlreadyExists, + NotFound, + Forbidden, + Protected +} + +public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, string? ErrorMessage) +{ + public bool IsSuccess => Error is null; + + public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null); + public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message); +} diff --git a/src/EchoHub.Core/Models/Message.cs b/src/EchoHub.Core/Models/Message.cs index 2ace5ac..a574b16 100644 --- a/src/EchoHub.Core/Models/Message.cs +++ b/src/EchoHub.Core/Models/Message.cs @@ -7,6 +7,7 @@ public class Message public MessageType Type { get; set; } = MessageType.Text; public string? AttachmentUrl { get; set; } public string? AttachmentFileName { get; set; } + public long? AttachmentFileSize { get; set; } public string? EmbedJson { get; set; } public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; diff --git a/src/EchoHub.Core/Models/MessageType.cs b/src/EchoHub.Core/Models/MessageType.cs index 6957785..4ffb036 100644 --- a/src/EchoHub.Core/Models/MessageType.cs +++ b/src/EchoHub.Core/Models/MessageType.cs @@ -4,5 +4,6 @@ public enum MessageType { Text, Image, - File + File, + Audio } diff --git a/src/EchoHub.Server.Irc/IrcClientConnection.cs b/src/EchoHub.Server.Irc/IrcClientConnection.cs index 8566268..bbce314 100644 --- a/src/EchoHub.Server.Irc/IrcClientConnection.cs +++ b/src/EchoHub.Server.Irc/IrcClientConnection.cs @@ -27,14 +27,20 @@ public sealed class IrcClientConnection : IAsyncDisposable public bool IsSasl { get; set; } public bool CapNegotiating { get; set; } - // Channel state - public HashSet JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase); + // Channel state — thread-safe: written by command handler, read by broadcaster threads + private readonly HashSet _joinedChannels = new(StringComparer.OrdinalIgnoreCase); + private readonly object _channelLock = new(); // Away state public string? AwayMessage { get; set; } public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub"; + public void JoinChannel(string channel) { lock (_channelLock) _joinedChannels.Add(channel); } + public void LeaveChannel(string channel) { lock (_channelLock) _joinedChannels.Remove(channel); } + public bool IsInChannel(string channel) { lock (_channelLock) return _joinedChannels.Contains(channel); } + public List GetJoinedChannels() { lock (_channelLock) return [.. _joinedChannels]; } + public IrcClientConnection(TcpClient tcpClient, Stream stream) { _tcpClient = tcpClient; diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 1bf1a6b..4d1fa30 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -12,6 +12,8 @@ public sealed class IrcCommandHandler private readonly IrcClientConnection _conn; private readonly IrcOptions _options; private readonly IChatService _chatService; + private readonly IChannelService _channelService; + private readonly IMessageEncryptionService _encryption; private readonly ILogger _logger; private string ServerName => _options.ServerName; @@ -20,11 +22,15 @@ public sealed class IrcCommandHandler IrcClientConnection conn, IrcOptions options, IChatService chatService, + IChannelService channelService, + IMessageEncryptionService encryption, ILogger logger) { _conn = conn; _options = options; _chatService = chatService; + _channelService = channelService; + _encryption = encryption; _logger = logger; } @@ -319,7 +325,7 @@ public sealed class IrcCommandHandler private async Task HandleJoinAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) { @@ -350,7 +356,7 @@ public sealed class IrcCommandHandler continue; } - _conn.JoinedChannels.Add(channelName); + _conn.JoinChannel(channelName); // Confirm JOIN to the client await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}"); @@ -361,10 +367,11 @@ public sealed class IrcCommandHandler // Send NAMES list await SendNamesReplyAsync(channelName); - // Replay history + // Replay history (decrypt — history is encrypted for SignalR transport) foreach (var m in history) { - var lines = IrcMessageFormatter.FormatMessage(m); + var decrypted = m with { Content = _encryption.Decrypt(m.Content) }; + var lines = IrcMessageFormatter.FormatMessage(decrypted); foreach (var line in lines) await _conn.SendAsync(line); } @@ -373,7 +380,7 @@ public sealed class IrcCommandHandler private async Task HandlePartAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries); @@ -385,7 +392,7 @@ public sealed class IrcCommandHandler if (channelName is null) continue; await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName); - _conn.JoinedChannels.Remove(channelName); + _conn.LeaveChannel(channelName); await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" + (partMessage is not null ? $" :{partMessage}" : "")); @@ -394,7 +401,7 @@ public sealed class IrcCommandHandler private async Task HandlePrivmsgAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 2) { @@ -436,7 +443,7 @@ public sealed class IrcCommandHandler private async Task HandleNamesAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var channelName = IrcToEchoHubChannel(msg.Parameters[0]); @@ -458,7 +465,7 @@ public sealed class IrcCommandHandler private async Task HandleTopicAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var channelName = IrcToEchoHubChannel(msg.Parameters[0]); @@ -477,7 +484,7 @@ public sealed class IrcCommandHandler private async Task SendChannelTopicAsync(string channelName) { - var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName); + var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName); if (!exists) return; @@ -495,7 +502,7 @@ public sealed class IrcCommandHandler private async Task HandleWhoAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var channelName = IrcToEchoHubChannel(msg.Parameters[0]); @@ -516,7 +523,7 @@ public sealed class IrcCommandHandler private async Task HandleWhoisAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var nick = msg.Parameters[^1].ToLowerInvariant(); @@ -559,7 +566,7 @@ public sealed class IrcCommandHandler private async Task HandleAwayAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0])) { @@ -581,9 +588,9 @@ public sealed class IrcCommandHandler private async Task HandleListAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; - var channels = await _chatService.GetChannelListAsync(); + var channels = await _channelService.GetChannelListAsync(); foreach (var ch in channels) { @@ -597,7 +604,7 @@ public sealed class IrcCommandHandler private async Task HandleModeAsync(IrcMessage msg) { - if (!RequireRegistered()) return; + if (!await RequireRegisteredAsync()) return; if (msg.Parameters.Count < 1) return; var target = msg.Parameters[0]; @@ -621,11 +628,11 @@ public sealed class IrcCommandHandler // ── Helpers ────────────────────────────────────────────────────────────── - private bool RequireRegistered() + private async Task RequireRegisteredAsync() { if (_conn.IsRegistered) return true; - _ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED, + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED, ":You have not registered"); return false; } diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index e09ec61..d9f555c 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -34,7 +34,7 @@ public sealed class IrcGatewayService : BackgroundService public IEnumerable GetConnectionsInChannel(string channelName) { return _connections.Values - .Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName)); + .Where(c => c.IsAuthenticated && c.IsInChannel(channelName)); } public IEnumerable GetAllConnections() @@ -120,8 +120,10 @@ public sealed class IrcGatewayService : BackgroundService try { chatService = _services.GetRequiredService(); + var channelService = _services.GetRequiredService(); + var encryption = _services.GetRequiredService(); var handler = new IrcCommandHandler( - connection, _options, chatService, _logger); + connection, _options, chatService, channelService, encryption, _logger); await handler.RunAsync(ct); } @@ -133,7 +135,7 @@ public sealed class IrcGatewayService : BackgroundService { if (connection.IsAuthenticated) { - foreach (var ch in connection.JoinedChannels.ToList()) + foreach (var ch in connection.GetJoinedChannels()) { if (chatService is null) break; await chatService.LeaveChannelAsync( diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index fb017c5..c1a91ab 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -48,6 +48,10 @@ public static partial class IrcMessageFormatter 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; diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 753da7f..610b26c 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -8,7 +8,6 @@ using EchoHub.Server.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; namespace EchoHub.Server.Controllers; @@ -18,6 +17,7 @@ namespace EchoHub.Server.Controllers; [EnableRateLimiting("general")] public class ChannelsController : ControllerBase { + private readonly IChannelService _channelService; private readonly EchoHubDbContext _db; private readonly FileStorageService _fileStorage; private readonly ImageToAsciiService _asciiService; @@ -26,6 +26,7 @@ public class ChannelsController : ControllerBase private readonly IMessageEncryptionService _encryption; public ChannelsController( + IChannelService channelService, EchoHubDbContext db, FileStorageService fileStorage, ImageToAsciiService asciiService, @@ -33,6 +34,7 @@ public class ChannelsController : ControllerBase IChatService chatService, IMessageEncryptionService encryption) { + _channelService = channelService; _db = db; _fileStorage = fileStorage; _asciiService = asciiService; @@ -48,74 +50,29 @@ public class ChannelsController : ControllerBase if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var userId = Guid.Parse(userIdClaim); offset = Math.Max(0, offset); limit = Math.Clamp(limit, 1, 100); - // Public channels + private channels the user has joined - var query = _db.Channels.Where(c => - c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId)); - var total = await query.CountAsync(); - - var channels = await query - .OrderBy(c => c.Name) - .Skip(offset) - .Take(limit) - .Select(c => new ChannelDto( - c.Id, - c.Name, - c.Topic, - c.IsPublic, - c.Messages.Count, - c.CreatedAt)) - .ToListAsync(); - - return Ok(new PaginatedResponse(channels, total, offset, limit)); + var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit); + return Ok(result); } [HttpPost] public async Task CreateChannel([FromBody] CreateChannelRequest request) { - if (string.IsNullOrWhiteSpace(request.Name)) - return BadRequest(new ErrorResponse("Channel name is required.")); - - var channelName = request.Name.ToLowerInvariant().Trim(); - - if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) - return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.")); - - if (await _db.Channels.AnyAsync(c => c.Name == channelName)) - return Conflict(new ErrorResponse($"Channel '{channelName}' already exists.")); - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var channel = new Channel - { - Id = Guid.NewGuid(), - Name = channelName, - Topic = request.Topic?.Trim(), - IsPublic = request.IsPublic, - CreatedByUserId = Guid.Parse(userIdClaim), - }; + var result = await _channelService.CreateChannelAsync( + Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic); + if (!result.IsSuccess) + return MapChannelError(result); - _db.Channels.Add(channel); + if (result.Channel!.IsPublic) + await _chatService.BroadcastChannelUpdatedAsync(result.Channel); - // Creator automatically becomes a member - _db.ChannelMemberships.Add(new ChannelMembership - { - UserId = Guid.Parse(userIdClaim), - ChannelId = channel.Id, - }); - - await _db.SaveChangesAsync(); - - var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt); - if (channel.IsPublic) - await _chatService.BroadcastChannelUpdatedAsync(dto); - - return Created($"/api/channels/{channelName}", dto); + return Created($"/api/channels/{result.Channel.Name}", result.Channel); } [HttpPut("{channel}/topic")] @@ -125,26 +82,13 @@ public class ChannelsController : ControllerBase if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var channelName = channel.ToLowerInvariant().Trim(); - var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + var result = await _channelService.UpdateTopicAsync( + Guid.Parse(userIdClaim), channel, request.Topic); + if (!result.IsSuccess) + return MapChannelError(result); - if (dbChannel is null) - return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); - - if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) - return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic.")); - - if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength) - return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.")); - - dbChannel.Topic = request.Topic?.Trim(); - await _db.SaveChangesAsync(); - - var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); - var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt); - await _chatService.BroadcastChannelUpdatedAsync(dto, channelName); - - return Ok(dto); + await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim()); + return Ok(result.Channel); } [HttpDelete("{channel}")] @@ -154,29 +98,17 @@ public class ChannelsController : ControllerBase if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var channelName = channel.ToLowerInvariant().Trim(); - - if (channelName == HubConstants.DefaultChannel) - return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted.")); - - var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - - if (dbChannel is null) - return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); - - var userId = Guid.Parse(userIdClaim); - var caller = await _db.Users.FindAsync(userId); - if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin)) - return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel.")); - - _db.Channels.Remove(dbChannel); - await _db.SaveChangesAsync(); + var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel); + if (!result.IsSuccess) + return MapChannelError(result); return NoContent(); } [HttpPost("{channel}/upload")] [EnableRateLimiting("upload")] + [RequestSizeLimit(HubConstants.MaxFileSizeBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)] public async Task Upload(string channel, [FromQuery] string? size = null) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -190,8 +122,8 @@ public class ChannelsController : ControllerBase if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return BadRequest(new ErrorResponse("Invalid channel name format.")); - var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (dbChannel is null) + var channelDto = await _channelService.GetChannelByNameAsync(channelName); + if (channelDto is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); if (!Request.HasFormContentType || Request.Form.Files.Count == 0) @@ -199,16 +131,23 @@ public class ChannelsController : ControllerBase var file = Request.Form.Files[0]; - if (file.Length > HubConstants.MaxFileSizeBytes) - return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); - - // Detect if file is an image by checking magic bytes + // Detect file type early so we can apply the correct size limit using var stream = file.OpenReadStream(); var isImage = FileValidationHelper.IsValidImage(stream); + var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName); + + var maxSize = isImage ? HubConstants.MaxImageSizeBytes + : isAudio ? HubConstants.MaxAudioFileSizeBytes + : HubConstants.MaxFileSizeBytes; + + if (file.Length > maxSize) + return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB.")); var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); - var messageType = isImage ? MessageType.Image : MessageType.File; + var messageType = isImage ? MessageType.Image + : isAudio ? MessageType.Audio + : MessageType.File; string content; if (isImage) @@ -233,8 +172,9 @@ public class ChannelsController : ControllerBase Type = messageType, AttachmentUrl = attachmentUrl, AttachmentFileName = file.FileName, + AttachmentFileSize = file.Length, SentAt = DateTimeOffset.UtcNow, - ChannelId = dbChannel.Id, + ChannelId = channelDto.Id, SenderUserId = userId, SenderUsername = usernameClaim, }; @@ -252,7 +192,8 @@ public class ChannelsController : ControllerBase messageType, attachmentUrl, file.FileName, - message.SentAt); + message.SentAt, + file.Length); await _chatService.BroadcastMessageAsync(channelName, messageDto); @@ -274,8 +215,8 @@ public class ChannelsController : ControllerBase if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return BadRequest(new ErrorResponse("Invalid channel name format.")); - var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (dbChannel is null) + var channelDto = await _channelService.GetChannelByNameAsync(channelName); + if (channelDto is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); if (string.IsNullOrWhiteSpace(request.Url)) @@ -295,13 +236,13 @@ public class ChannelsController : ControllerBase response.EnsureSuccessStatusCode(); var contentLength = response.Content.Headers.ContentLength; - if (contentLength > HubConstants.MaxFileSizeBytes) - return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); + if (contentLength > HubConstants.MaxImageSizeBytes) + return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB.")); imageBytes = await response.Content.ReadAsByteArrayAsync(); - if (imageBytes.Length > HubConstants.MaxFileSizeBytes) - return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); + if (imageBytes.Length > HubConstants.MaxImageSizeBytes) + return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB.")); fileName = Path.GetFileName(uri.LocalPath); if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) @@ -353,8 +294,9 @@ public class ChannelsController : ControllerBase Type = MessageType.Image, AttachmentUrl = attachmentUrl, AttachmentFileName = fileName, + AttachmentFileSize = imageBytes.Length, SentAt = DateTimeOffset.UtcNow, - ChannelId = dbChannel.Id, + ChannelId = channelDto.Id, SenderUserId = userId, SenderUsername = usernameClaim, }; @@ -372,10 +314,21 @@ public class ChannelsController : ControllerBase MessageType.Image, attachmentUrl, fileName, - message.SentAt); + message.SentAt, + imageBytes.Length); await _chatService.BroadcastMessageAsync(channelName, messageDto); return Ok(messageDto); } + + private IActionResult MapChannelError(ChannelOperationResult result) => result.Error switch + { + ChannelError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)), + ChannelError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)), + ChannelError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)), + ChannelError.Forbidden => StatusCode(403, new ErrorResponse(result.ErrorMessage!)), + ChannelError.Protected => BadRequest(new ErrorResponse(result.ErrorMessage!)), + _ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")), + }; } diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs index 2bcacf9..42db03f 100644 --- a/src/EchoHub.Server/Controllers/FilesController.cs +++ b/src/EchoHub.Server/Controllers/FilesController.cs @@ -36,6 +36,13 @@ public class FilesController : ControllerBase ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", + ".mp3" => "audio/mpeg", + ".wav" => "audio/wav", + ".ogg" => "audio/ogg", + ".flac" => "audio/flac", + ".aac" => "audio/aac", + ".m4a" => "audio/mp4", + ".wma" => "audio/x-ms-wma", ".pdf" => "application/pdf", ".txt" => "text/plain", _ => "application/octet-stream" diff --git a/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.Designer.cs new file mode 100644 index 0000000..02d516e --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.Designer.cs @@ -0,0 +1,267 @@ +// +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("20260221193444_AddAttachmentFileSize")] + partial class AddAttachmentFileSize + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.HasOne("EchoHub.Core.Models.Channel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EchoHub.Core.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.cs b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.cs new file mode 100644 index 0000000..ffd143c --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddAttachmentFileSize : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AttachmentFileSize", + table: "Messages", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AttachmentFileSize", + table: "Messages"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 715ed37..bf38795 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -79,6 +79,9 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(255) .HasColumnType("TEXT"); + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + b.Property("AttachmentUrl") .HasMaxLength(500) .HasColumnType("TEXT"); diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj index 93bbe03..9795f48 100644 --- a/src/EchoHub.Server/EchoHub.Server.csproj +++ b/src/EchoHub.Server/EchoHub.Server.csproj @@ -1,5 +1,9 @@ + + + + @@ -22,6 +26,8 @@ net10.0 enable enable + + hue_icon.ico diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 0e830a5..2ab4dcc 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -108,12 +108,14 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); // ── Encryption ───────────────────────────────────────────────────── builder.Services.AddSingleton(); // ── Chat Service + Broadcasters ───────────────────────────────────── builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); // ── IRC Gateway (optional) ────────────────────────────────────────── diff --git a/src/EchoHub.Server/Services/ChannelService.cs b/src/EchoHub.Server/Services/ChannelService.cs new file mode 100644 index 0000000..d4ef85e --- /dev/null +++ b/src/EchoHub.Server/Services/ChannelService.cs @@ -0,0 +1,247 @@ +using EchoHub.Core.Constants; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EchoHub.Server.Services; + +public class ChannelService : IChannelService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly PresenceTracker _presenceTracker; + private readonly ILogger _logger; + + public ChannelService( + IServiceScopeFactory scopeFactory, + PresenceTracker presenceTracker, + ILogger logger) + { + _scopeFactory = scopeFactory; + _presenceTracker = presenceTracker; + _logger = logger; + } + + public async Task> GetChannelsAsync(Guid userId, int offset, int limit) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await EnsureDefaultChannelAsync(db); + + var query = db.Channels.Where(c => + c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId)); + var total = await query.CountAsync(); + + var channels = await query + .OrderBy(c => c.Name) + .Skip(offset) + .Take(limit) + .Select(c => new ChannelDto( + c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt)) + .ToListAsync(); + + return new PaginatedResponse(channels, total, offset, limit); + } + + public async Task CreateChannelAsync( + Guid creatorUserId, string name, string? topic, bool isPublic) + { + if (string.IsNullOrWhiteSpace(name)) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required."); + + var channelName = name.ToLowerInvariant().Trim(); + + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, + "Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (await db.Channels.AnyAsync(c => c.Name == channelName)) + return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists."); + + var channel = new Channel + { + Id = Guid.NewGuid(), + Name = channelName, + Topic = topic?.Trim(), + IsPublic = isPublic, + CreatedByUserId = creatorUserId, + }; + + db.Channels.Add(channel); + + // Creator automatically becomes a member + db.ChannelMemberships.Add(new ChannelMembership + { + UserId = creatorUserId, + ChannelId = channel.Id, + }); + + await db.SaveChangesAsync(); + + var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt); + return ChannelOperationResult.Success(dto); + } + + public async Task UpdateTopicAsync( + Guid callerUserId, string channelName, string? topic) + { + channelName = channelName.ToLowerInvariant().Trim(); + + if (topic is not null && topic.Length > ValidationConstants.MaxChannelTopicLength) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, + $"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); + + if (dbChannel.CreatedByUserId != callerUserId) + return ChannelOperationResult.Fail(ChannelError.Forbidden, "Only the channel creator can update the topic."); + + dbChannel.Topic = topic?.Trim(); + await db.SaveChangesAsync(); + + var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt); + return ChannelOperationResult.Success(dto); + } + + public async Task DeleteChannelAsync(Guid callerUserId, string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + if (channelName == HubConstants.DefaultChannel) + return ChannelOperationResult.Fail(ChannelError.Protected, + $"The '{HubConstants.DefaultChannel}' channel cannot be deleted."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); + + var caller = await db.Users.FindAsync(callerUserId); + if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin)) + return ChannelOperationResult.Fail(ChannelError.Forbidden, + "Only the channel creator or an admin can delete the channel."); + + db.Channels.Remove(dbChannel); + await db.SaveChangesAsync(); + + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt); + return ChannelOperationResult.Success(dto); + } + + public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (channel is null) return (null, false); + + return (channel.Topic, true); + } + + public async Task> GetChannelListAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync(); + + return channels.Select(c => new ChannelListItem( + c.Name, c.Topic, + _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); + } + + public async Task GetChannelByNameAsync(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName); + if (c is null) return null; + + var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id); + return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt); + } + + public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (channel is null) + { + // Auto-recreate #general if it was somehow removed + if (channelName == HubConstants.DefaultChannel) + { + channel = new Channel + { + Id = Guid.NewGuid(), + Name = HubConstants.DefaultChannel, + Topic = "General discussion", + CreatedByUserId = Guid.Empty, + }; + db.Channels.Add(channel); + await db.SaveChangesAsync(); + _logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel); + } + else + { + return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list."); + } + } + + var hasMembership = await db.ChannelMemberships + .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id); + if (!hasMembership) + { + db.ChannelMemberships.Add(new ChannelMembership + { + UserId = userId, + ChannelId = channel.Id, + }); + await db.SaveChangesAsync(); + } + + return (true, null); + } + + private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db) + { + if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) + { + db.Channels.Add(new Channel + { + Id = Guid.NewGuid(), + Name = HubConstants.DefaultChannel, + Topic = "General discussion", + CreatedByUserId = Guid.Empty, + }); + await db.SaveChangesAsync(); + } + } +} diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index a75343b..b5010fd 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -17,6 +17,7 @@ public class ChatService : IChatService private readonly IEnumerable _broadcasters; private readonly LinkEmbedService _embedService; private readonly IMessageEncryptionService _encryption; + private readonly IChannelService _channelService; private readonly ILogger _logger; public ChatService( @@ -25,6 +26,7 @@ public class ChatService : IChatService IEnumerable broadcasters, LinkEmbedService embedService, IMessageEncryptionService encryption, + IChannelService channelService, ILogger logger) { _scopeFactory = scopeFactory; @@ -32,6 +34,7 @@ public class ChatService : IChatService _broadcasters = broadcasters; _embedService = embedService; _encryption = encryption; + _channelService = channelService; _logger = logger; } @@ -95,28 +98,10 @@ public class ChatService : IChatService { channelName = channelName.ToLowerInvariant().Trim(); - if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) - return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (channel is null) - return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); - - // Persist membership so the channel shows in the user's channel list - var hasMembership = await db.ChannelMemberships - .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id); - if (!hasMembership) - { - db.ChannelMemberships.Add(new ChannelMembership - { - UserId = userId, - ChannelId = channel.Id, - }); - await db.SaveChangesAsync(); - } + // Delegate channel validation + membership to ChannelService + var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName); + if (!success) + return ([], error); var isNewJoin = _presenceTracker.JoinChannel(username, channelName); @@ -126,7 +111,7 @@ public class ChatService : IChatService _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); } - var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount); + var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount); return (history, null); } @@ -228,7 +213,7 @@ public class ChatService : IChatService null, null, message.SentAt, - embeds); + Embeds: embeds); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); @@ -335,32 +320,6 @@ public class ChatService : IChatService user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt); } - public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) - { - channelName = channelName.ToLowerInvariant().Trim(); - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (channel is null) return (null, false); - - return (channel.Topic, true); - } - - public async Task> GetChannelListAsync() - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync(); - - return channels.Select(c => new ChannelListItem( - c.Name, - c.Topic, - _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); - } - public Task> GetChannelsForUserAsync(string username) => Task.FromResult(_presenceTracker.GetChannelsForUser(username)); @@ -457,6 +416,7 @@ public class ChatService : IChatService x.m.AttachmentUrl, x.m.AttachmentFileName, x.m.SentAt, + x.m.AttachmentFileSize, embeds); }).ToList(); } diff --git a/src/EchoHub.Server/Services/FileCleanupService.cs b/src/EchoHub.Server/Services/FileCleanupService.cs new file mode 100644 index 0000000..c5d9cca --- /dev/null +++ b/src/EchoHub.Server/Services/FileCleanupService.cs @@ -0,0 +1,69 @@ +namespace EchoHub.Server.Services; + +public sealed class FileCleanupService : BackgroundService +{ + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public FileCleanupService(IConfiguration configuration, ILogger logger) + { + _configuration = configuration; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1); + var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30); + var storagePath = _configuration["Storage:Path"] + ?? Path.Combine(AppContext.BaseDirectory, "uploads"); + + _logger.LogInformation( + "File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}", + intervalHours, retentionDays, storagePath); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken); + + try + { + CleanupOldFiles(storagePath, retentionDays); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during file cleanup"); + } + } + } + + private void CleanupOldFiles(string storagePath, int retentionDays) + { + if (!Directory.Exists(storagePath)) + return; + + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + var files = Directory.GetFiles(storagePath); + var deleted = 0; + + foreach (var file in files) + { + var createdAt = File.GetCreationTimeUtc(file); + if (createdAt < cutoff) + { + try + { + File.Delete(file); + deleted++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete old file: {File}", file); + } + } + } + + if (deleted > 0) + _logger.LogInformation("File cleanup: deleted {Count} files older than {Days} days", deleted, retentionDays); + } +} diff --git a/src/EchoHub.Server/Services/FileValidationHelper.cs b/src/EchoHub.Server/Services/FileValidationHelper.cs index adf7330..f1d3b40 100644 --- a/src/EchoHub.Server/Services/FileValidationHelper.cs +++ b/src/EchoHub.Server/Services/FileValidationHelper.cs @@ -52,6 +52,20 @@ public static class FileValidationHelper } } + private static readonly HashSet AudioExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma" + }; + + /// + /// Checks whether the file name has a recognized audio extension. + /// + public static bool IsAudioFile(string fileName) + { + var ext = Path.GetExtension(fileName); + return !string.IsNullOrEmpty(ext) && AudioExtensions.Contains(ext); + } + private static bool StartsWith(byte[] buffer, int length, byte[] magic) { if (length < magic.Length) diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs index a61e00b..e1e1902 100644 --- a/src/EchoHub.Server/Setup/DataMigrationService.cs +++ b/src/EchoHub.Server/Setup/DataMigrationService.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using EchoHub.Core.Constants; using EchoHub.Core.DTOs; +using EchoHub.Core.Models; using EchoHub.Server.Data; using Microsoft.EntityFrameworkCore; @@ -12,12 +13,14 @@ public static partial class DataMigrationService { using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + var config = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService() .CreateLogger("EchoHub.Server.Setup.DataMigration"); await EnsureDefaultChannelsPublicAsync(db, logger); await MigrateAnsiMessagesAsync(db, logger); await MigrateEmbedJsonToArrayAsync(db, logger); + await EnsureConfiguredAdminsAsync(db, config, logger); } /// @@ -94,6 +97,40 @@ public static partial class DataMigrationService [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex AnsiColorRegex(); + /// + /// 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. + /// + private static async Task EnsureConfiguredAdminsAsync(EchoHubDbContext db, IConfiguration config, ILogger logger) + { + var adminUsernames = config.GetSection("Server:Admins").Get(); + if (adminUsernames is not { Length: > 0 }) + return; + + var promoted = 0; + foreach (var username in adminUsernames) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user is null) + { + logger.LogWarning("Configured admin '{Username}' not found in database (not registered yet).", username); + continue; + } + + if (user.Role < ServerRole.Admin) + { + var oldRole = user.Role; + user.Role = ServerRole.Admin; + promoted++; + logger.LogInformation("Promoted '{Username}' from {OldRole} to Admin (configured in Server:Admins).", + username, oldRole); + } + } + + if (promoted > 0) + await db.SaveChangesAsync(); + } + /// /// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]"). /// diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index ff89ee0..5d21c45 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -12,7 +12,12 @@ "Name": "My EchoHub Server", "Description": "A self-hosted EchoHub chat server", "PublicServer": false, - "PublicHost": "" + "PublicHost": "", + "Admins": [] + }, + "Storage": { + "CleanupIntervalHours": 1, + "RetentionDays": 30 }, "Encryption": { "Key": "", diff --git a/src/EchoHub.Server/hue_icon.ico b/src/EchoHub.Server/hue_icon.ico new file mode 100644 index 0000000..dd2668c Binary files /dev/null and b/src/EchoHub.Server/hue_icon.ico differ diff --git a/src/EchoHub.Tests/ChatLineTests.cs b/src/EchoHub.Tests/ChatLineTests.cs new file mode 100644 index 0000000..16abab5 --- /dev/null +++ b/src/EchoHub.Tests/ChatLineTests.cs @@ -0,0 +1,107 @@ +using EchoHub.Client.UI; +using Xunit; + +namespace EchoHub.Tests; + +/// +/// Tests for static string-utility methods on ChatLine. +/// Note: Tests that construct ChatLine/ChatListSource or use Terminal.Gui types +/// (Attribute, Color) are excluded because Terminal.Gui's module initializer +/// requires a display driver which is unavailable in CI/test environments. +/// +public class ChatLineTests +{ + // ── HasColorTags ────────────────────────────────────────────────── + + [Fact] + public void HasColorTags_ForegroundTag_ReturnsTrue() + { + Assert.True(ChatLine.HasColorTags("Hello {F:FF0000}world")); + } + + [Fact] + public void HasColorTags_BackgroundTag_ReturnsTrue() + { + Assert.True(ChatLine.HasColorTags("Hello {B:00FF00}world")); + } + + [Fact] + public void HasColorTags_ResetTag_ReturnsTrue() + { + Assert.True(ChatLine.HasColorTags("Hello{X}")); + } + + [Fact] + public void HasColorTags_NoTags_ReturnsFalse() + { + Assert.False(ChatLine.HasColorTags("Hello world")); + } + + [Fact] + public void HasColorTags_PartialTag_ReturnsFalse() + { + // {Z:...} is not a valid tag (only F or B) + Assert.False(ChatLine.HasColorTags("Hello {Z:000000}")); + } + + [Fact] + public void HasColorTags_EmptyString_ReturnsFalse() + { + Assert.False(ChatLine.HasColorTags("")); + } + + [Theory] + [InlineData("{F:AABBCC}text")] + [InlineData("prefix{B:112233}suffix")] + [InlineData("a{X}b")] + [InlineData("{F:000000}{B:FFFFFF}{X}")] + public void HasColorTags_VariousValidTags_ReturnsTrue(string input) + { + Assert.True(ChatLine.HasColorTags(input)); + } + + // ── StripColorTags ──────────────────────────────────────────────── + + [Fact] + public void StripColorTags_RemovesAllTags() + { + var result = ChatLine.StripColorTags("{F:FF0000}red{B:00FF00}green{X}"); + Assert.Equal("redgreen", result); + } + + [Fact] + public void StripColorTags_NoTags_ReturnsOriginal() + { + var result = ChatLine.StripColorTags("plain text"); + Assert.Equal("plain text", result); + } + + [Fact] + public void StripColorTags_OnlyTags_ReturnsEmpty() + { + var result = ChatLine.StripColorTags("{F:AABBCC}{B:112233}{X}"); + Assert.Equal("", result); + } + + [Fact] + public void StripColorTags_MixedContent_KeepsText() + { + var result = ChatLine.StripColorTags("before{F:FF0000}middle{X}after"); + Assert.Equal("beforemiddleafter", result); + } + + [Fact] + public void StripColorTags_MultipleConsecutiveTags_AllStripped() + { + var result = ChatLine.StripColorTags("{F:FF0000}{B:00FF00}{X}{F:0000FF}text{X}"); + Assert.Equal("text", result); + } + + [Fact] + public void StripColorTags_PreservesNonTagBraces() + { + // {Hello} is not a valid tag and should be preserved + var result = ChatLine.StripColorTags("{Hello} world"); + Assert.Equal("{Hello} world", result); + } +} diff --git a/src/EchoHub.Tests/CommandHandlerTests.cs b/src/EchoHub.Tests/CommandHandlerTests.cs new file mode 100644 index 0000000..5c58bf7 --- /dev/null +++ b/src/EchoHub.Tests/CommandHandlerTests.cs @@ -0,0 +1,462 @@ +using EchoHub.Client.Commands; +using EchoHub.Core.Models; +using Xunit; + +namespace EchoHub.Tests; + +public class CommandHandlerTests +{ + private CommandHandler CreateHandler() => new(); + + // ── IsCommand ───────────────────────────────────────────────────── + + [Fact] + public void IsCommand_StartsWithSlash_ReturnsTrue() + { + var handler = CreateHandler(); + Assert.True(handler.IsCommand("/help")); + } + + [Fact] + public void IsCommand_NoSlash_ReturnsFalse() + { + var handler = CreateHandler(); + Assert.False(handler.IsCommand("hello")); + } + + [Fact] + public void IsCommand_EmptyString_ReturnsFalse() + { + var handler = CreateHandler(); + Assert.False(handler.IsCommand("")); + } + + // ── HandleAsync — not a command ─────────────────────────────────── + + [Fact] + public async Task HandleAsync_NotCommand_ReturnsFalse() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("hello world"); + + Assert.False(result.Handled); + } + + // ── HandleAsync — unknown command ───────────────────────────────── + + [Fact] + public async Task HandleAsync_UnknownCommand_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/doesnotexist"); + + Assert.True(result.Handled); + Assert.True(result.IsError); + Assert.Contains("Unknown command", result.Message); + } + + // ── /status ─────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_StatusOnline_SetsOnlineStatus() + { + var handler = CreateHandler(); + UserStatus? capturedStatus = null; + handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; }; + + var result = await handler.HandleAsync("/status online"); + + Assert.True(result.Handled); + Assert.False(result.IsError); + Assert.Equal(UserStatus.Online, capturedStatus); + } + + [Fact] + public async Task HandleAsync_StatusAway_SetsAwayStatus() + { + var handler = CreateHandler(); + UserStatus? capturedStatus = null; + handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; }; + + await handler.HandleAsync("/status away"); + Assert.Equal(UserStatus.Away, capturedStatus); + } + + [Fact] + public async Task HandleAsync_StatusCustomMessage_SetsStatusMessage() + { + var handler = CreateHandler(); + string? capturedMessage = null; + handler.OnSetStatus += (status, msg) => { capturedMessage = msg; return Task.CompletedTask; }; + + var result = await handler.HandleAsync("/status brb lunch"); + + Assert.True(result.Handled); + Assert.Contains("brb lunch", result.Message); + Assert.Equal("brb lunch", capturedMessage); + } + + [Fact] + public async Task HandleAsync_StatusNoArgs_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/status"); + + Assert.True(result.IsError); + Assert.Contains("Usage", result.Message); + } + + // ── /nick ───────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Nick_SetsDisplayName() + { + var handler = CreateHandler(); + string? capturedNick = null; + handler.OnSetNick += nick => { capturedNick = nick; return Task.CompletedTask; }; + + var result = await handler.HandleAsync("/nick Bob Smith"); + + Assert.True(result.Handled); + Assert.Equal("Bob Smith", capturedNick); + } + + [Fact] + public async Task HandleAsync_Nick_EmptyArgs_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/nick"); + + Assert.True(result.IsError); + Assert.Contains("Usage", result.Message); + } + + // ── /color ──────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Color_ValidHex_Succeeds() + { + var handler = CreateHandler(); + string? capturedColor = null; + handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; }; + + var result = await handler.HandleAsync("/color #FF5733"); + + Assert.True(result.Handled); + Assert.False(result.IsError); + Assert.Equal("#FF5733", capturedColor); + } + + [Fact] + public async Task HandleAsync_Color_WithoutHash_AddsHash() + { + var handler = CreateHandler(); + string? capturedColor = null; + handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; }; + + await handler.HandleAsync("/color FF5733"); + Assert.Equal("#FF5733", capturedColor); + } + + [Fact] + public async Task HandleAsync_Color_InvalidHex_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/color #ZZZZZZ"); + + Assert.True(result.IsError); + Assert.Contains("Invalid color", result.Message); + } + + [Fact] + public async Task HandleAsync_Color_TooShort_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/color #FFF"); + + Assert.True(result.IsError); + } + + [Fact] + public async Task HandleAsync_Color_NoArgs_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/color"); + + Assert.True(result.IsError); + } + + // ── /join ───────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Join_StripsHashPrefix() + { + var handler = CreateHandler(); + string? capturedChannel = null; + handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; }; + + await handler.HandleAsync("/join #random"); + Assert.Equal("random", capturedChannel); + } + + [Fact] + public async Task HandleAsync_Join_NoHash_PassedDirectly() + { + var handler = CreateHandler(); + string? capturedChannel = null; + handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; }; + + await handler.HandleAsync("/join random"); + Assert.Equal("random", capturedChannel); + } + + [Fact] + public async Task HandleAsync_Join_NoArgs_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/join"); + + Assert.True(result.IsError); + } + + // ── /kick ───────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Kick_WithReason_ParsesUsernameAndReason() + { + var handler = CreateHandler(); + string? capturedUser = null; + string? capturedReason = null; + handler.OnKickUser += (user, reason) => + { + capturedUser = user; + capturedReason = reason; + return Task.CompletedTask; + }; + + await handler.HandleAsync("/kick baduser being rude"); + Assert.Equal("baduser", capturedUser); + Assert.Equal("being rude", capturedReason); + } + + [Fact] + public async Task HandleAsync_Kick_WithoutReason_NullReason() + { + var handler = CreateHandler(); + string? capturedReason = "initial"; + handler.OnKickUser += (user, reason) => + { + capturedReason = reason; + return Task.CompletedTask; + }; + + await handler.HandleAsync("/kick baduser"); + Assert.Null(capturedReason); + } + + [Fact] + public async Task HandleAsync_Kick_NoArgs_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/kick"); + Assert.True(result.IsError); + } + + // ── /mute ───────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Mute_WithDuration_ParsesDuration() + { + var handler = CreateHandler(); + int? capturedDuration = null; + handler.OnMuteUser += (user, duration) => + { + capturedDuration = duration; + return Task.CompletedTask; + }; + + await handler.HandleAsync("/mute alice 30"); + Assert.Equal(30, capturedDuration); + } + + [Fact] + public async Task HandleAsync_Mute_WithoutDuration_NullDuration() + { + var handler = CreateHandler(); + int? capturedDuration = -1; + handler.OnMuteUser += (user, duration) => + { + capturedDuration = duration; + return Task.CompletedTask; + }; + + await handler.HandleAsync("/mute alice"); + Assert.Null(capturedDuration); + } + + // ── /role ───────────────────────────────────────────────────────── + + [Theory] + [InlineData("admin")] + [InlineData("mod")] + [InlineData("member")] + public async Task HandleAsync_Role_ValidRole_Succeeds(string role) + { + var handler = CreateHandler(); + string? capturedRole = null; + handler.OnAssignRole += (user, r) => { capturedRole = r; return Task.CompletedTask; }; + + var result = await handler.HandleAsync($"/role alice {role}"); + + Assert.True(result.Handled); + Assert.False(result.IsError); + Assert.Equal(role, capturedRole); + } + + [Fact] + public async Task HandleAsync_Role_InvalidRole_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/role alice superadmin"); + + Assert.True(result.IsError); + Assert.Contains("Invalid role", result.Message); + } + + [Fact] + public async Task HandleAsync_Role_MissingRole_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/role alice"); + + Assert.True(result.IsError); + } + + // ── /send ───────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Send_NoArgs_ReturnsUsageError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/send"); + + Assert.True(result.IsError); + Assert.Contains("Usage", result.Message); + } + + [Fact] + public async Task HandleAsync_Send_UrlInput_RecognizedAsUrl() + { + var handler = CreateHandler(); + string? capturedTarget = null; + handler.OnSendFile += (target, size) => { capturedTarget = target; return Task.CompletedTask; }; + + var result = await handler.HandleAsync("/send https://example.com/image.png"); + + Assert.True(result.Handled); + Assert.False(result.IsError); + Assert.Equal("https://example.com/image.png", capturedTarget); + } + + [Fact] + public async Task HandleAsync_Send_UrlWithSizeFlag_ExtractsSizeCorrectly() + { + var handler = CreateHandler(); + string? capturedSize = null; + handler.OnSendFile += (target, size) => { capturedSize = size; return Task.CompletedTask; }; + + await handler.HandleAsync("/send https://example.com/photo.jpg -s"); + Assert.Equal("s", capturedSize); + } + + // ── /help ───────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Help_ReturnsHelpText() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/help"); + + Assert.True(result.Handled); + Assert.Contains("Available commands", result.Message); + } + + [Fact] + public async Task HandleAsync_QuestionMark_ReturnsHelp() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/?"); + + Assert.True(result.Handled); + Assert.Contains("Available commands", result.Message); + } + + // ── /ban ────────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Ban_WithReason_ParsesUsernameAndReason() + { + var handler = CreateHandler(); + string? capturedUser = null; + string? capturedReason = null; + handler.OnBanUser += (user, reason) => + { + capturedUser = user; + capturedReason = reason; + return Task.CompletedTask; + }; + + await handler.HandleAsync("/ban troll spamming links"); + Assert.Equal("troll", capturedUser); + Assert.Equal("spamming links", capturedReason); + } + + // ── /topic ──────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Topic_SetsTopic() + { + var handler = CreateHandler(); + string? capturedTopic = null; + handler.OnSetTopic += topic => { capturedTopic = topic; return Task.CompletedTask; }; + + var result = await handler.HandleAsync("/topic Welcome to our channel!"); + + Assert.True(result.Handled); + Assert.Equal("Welcome to our channel!", capturedTopic); + } + + [Fact] + public async Task HandleAsync_Topic_NoArgs_ReturnsError() + { + var handler = CreateHandler(); + var result = await handler.HandleAsync("/topic"); + Assert.True(result.IsError); + } + + // ── /quit and /exit ─────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_Quit_Handled() + { + var handler = CreateHandler(); + var quitCalled = false; + handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; }; + + var result = await handler.HandleAsync("/quit"); + Assert.True(result.Handled); + Assert.True(quitCalled); + } + + [Fact] + public async Task HandleAsync_Exit_Handled() + { + var handler = CreateHandler(); + var quitCalled = false; + handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; }; + + var result = await handler.HandleAsync("/exit"); + Assert.True(result.Handled); + Assert.True(quitCalled); + } +} diff --git a/src/EchoHub.Tests/DataMigrationServiceTests.cs b/src/EchoHub.Tests/DataMigrationServiceTests.cs new file mode 100644 index 0000000..31c2198 --- /dev/null +++ b/src/EchoHub.Tests/DataMigrationServiceTests.cs @@ -0,0 +1,74 @@ +using EchoHub.Server.Setup; +using Xunit; + +namespace EchoHub.Tests; + +public class DataMigrationServiceTests +{ + // ── AnsiToColorTags ─────────────────────────────────────────────── + + [Fact] + public void AnsiToColorTags_ForegroundEscape_ConvertedToColorTag() + { + var ansi = "\x1b[38;2;255;0;0mred text"; + var result = DataMigrationService.AnsiToColorTags(ansi); + + Assert.Equal("{F:FF0000}red text", result); + } + + [Fact] + public void AnsiToColorTags_BackgroundEscape_ConvertedToColorTag() + { + var ansi = "\x1b[48;2;0;255;0mgreen bg"; + var result = DataMigrationService.AnsiToColorTags(ansi); + + Assert.Equal("{B:00FF00}green bg", result); + } + + [Fact] + public void AnsiToColorTags_ResetEscape_ConvertedToResetTag() + { + var ansi = "\x1b[0m"; + var result = DataMigrationService.AnsiToColorTags(ansi); + + Assert.Equal("{X}", result); + } + + [Fact] + public void AnsiToColorTags_NoEscapes_ReturnsUnchanged() + { + var text = "Hello, world!"; + var result = DataMigrationService.AnsiToColorTags(text); + + Assert.Equal("Hello, world!", result); + } + + [Fact] + public void AnsiToColorTags_MixedContent_ConvertsEscapesOnly() + { + var ansi = "before\x1b[38;2;100;200;50mtextafter"; + var result = DataMigrationService.AnsiToColorTags(ansi); + + Assert.Equal("before{F:64C832}textafter", result); + } + + [Fact] + public void AnsiToColorTags_MultipleTags_AllConverted() + { + var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;0;255mblue bg\x1b[0mreset"; + var result = DataMigrationService.AnsiToColorTags(ansi); + + Assert.Equal("{F:FF0000}red{B:0000FF}blue bg{X}reset", result); + } + + [Fact] + public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi() + { + // AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses + var original = "{F:FF0000}red{B:00FF00}green{X}"; + var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original); + var backToTags = DataMigrationService.AnsiToColorTags(ansi); + + Assert.Equal(original, backToTags); + } +} diff --git a/src/EchoHub.Tests/EchoHub.Tests.csproj b/src/EchoHub.Tests/EchoHub.Tests.csproj index 1a9b2a8..c3d45b4 100644 --- a/src/EchoHub.Tests/EchoHub.Tests.csproj +++ b/src/EchoHub.Tests/EchoHub.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/src/EchoHub.Tests/FileValidationHelperTests.cs b/src/EchoHub.Tests/FileValidationHelperTests.cs index 4a0c3eb..59bc283 100644 --- a/src/EchoHub.Tests/FileValidationHelperTests.cs +++ b/src/EchoHub.Tests/FileValidationHelperTests.cs @@ -53,4 +53,48 @@ public class FileValidationHelperTests FileValidationHelper.IsValidImage(stream); Assert.Equal(0, stream.Position); } + + // ── IsAudioFile tests ───────────────────────────────────────────── + + [Theory] + [InlineData("song.mp3")] + [InlineData("track.wav")] + [InlineData("audio.ogg")] + [InlineData("music.flac")] + [InlineData("clip.aac")] + [InlineData("podcast.m4a")] + [InlineData("old.wma")] + public void IsAudioFile_SupportedExtensions_ReturnsTrue(string fileName) + { + Assert.True(FileValidationHelper.IsAudioFile(fileName)); + } + + [Theory] + [InlineData("song.MP3")] + [InlineData("track.Wav")] + [InlineData("audio.OGG")] + [InlineData("music.FLAC")] + public void IsAudioFile_CaseInsensitive_ReturnsTrue(string fileName) + { + Assert.True(FileValidationHelper.IsAudioFile(fileName)); + } + + [Theory] + [InlineData("document.txt")] + [InlineData("report.pdf")] + [InlineData("app.exe")] + [InlineData("photo.jpg")] + [InlineData("image.png")] + public void IsAudioFile_NonAudioExtension_ReturnsFalse(string fileName) + { + Assert.False(FileValidationHelper.IsAudioFile(fileName)); + } + + [Theory] + [InlineData("")] + [InlineData("noextension")] + public void IsAudioFile_EmptyOrNoExtension_ReturnsFalse(string fileName) + { + Assert.False(FileValidationHelper.IsAudioFile(fileName)); + } } diff --git a/src/EchoHub.Tests/ImageToAsciiServiceTests.cs b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs new file mode 100644 index 0000000..9e9238c --- /dev/null +++ b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs @@ -0,0 +1,62 @@ +using EchoHub.Core.Constants; +using EchoHub.Server.Services; +using Xunit; + +namespace EchoHub.Tests; + +public class ImageToAsciiServiceTests +{ + [Fact] + public void GetDimensions_Small_Returns40x40() + { + var (w, h) = ImageToAsciiService.GetDimensions("s"); + Assert.Equal(40, w); + Assert.Equal(40, h); + } + + [Fact] + public void GetDimensions_Large_Returns120x120() + { + var (w, h) = ImageToAsciiService.GetDimensions("l"); + Assert.Equal(120, w); + Assert.Equal(120, h); + } + + [Fact] + public void GetDimensions_Default_Returns80x80() + { + var (w, h) = ImageToAsciiService.GetDimensions("m"); + Assert.Equal(HubConstants.AsciiArtWidth, w); + Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h); + } + + [Fact] + public void GetDimensions_Null_ReturnsDefault() + { + var (w, h) = ImageToAsciiService.GetDimensions(null); + Assert.Equal(HubConstants.AsciiArtWidth, w); + Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h); + } + + [Fact] + public void GetDimensions_CaseInsensitive() + { + var (w1, h1) = ImageToAsciiService.GetDimensions("S"); + var (w2, h2) = ImageToAsciiService.GetDimensions("s"); + Assert.Equal(w1, w2); + Assert.Equal(h1, h2); + + var (w3, h3) = ImageToAsciiService.GetDimensions("L"); + var (w4, h4) = ImageToAsciiService.GetDimensions("l"); + Assert.Equal(w3, w4); + Assert.Equal(h3, h4); + } + + [Fact] + public void GetDimensions_UnknownSize_ReturnsDefault() + { + var (w, h) = ImageToAsciiService.GetDimensions("xl"); + Assert.Equal(HubConstants.AsciiArtWidth, w); + Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h); + } +} diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs new file mode 100644 index 0000000..eca1170 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs @@ -0,0 +1,316 @@ +using System.Collections.Concurrent; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Irc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcBroadcasterTests +{ + private readonly IrcOptions _options = new() { ServerName = "testserver", Enabled = true }; + private readonly FakeEncryptionService _encryption = new(); + private readonly IrcGatewayService _gateway; + private readonly IrcBroadcaster _broadcaster; + + public IrcBroadcasterTests() + { + var services = new ServiceCollection() + .AddSingleton>(Options.Create(_options)) + .BuildServiceProvider(); + + _gateway = new IrcGatewayService( + Options.Create(_options), services, NullLogger.Instance); + + _broadcaster = new IrcBroadcaster(_gateway, _encryption); + } + + /// + /// Injects a test connection into the gateway's internal connection map. + /// + private IrcClientConnection AddConnection(string nickname, params string[] channels) + { + var (conn, _) = TestIrcConnectionFactory.CreateAuthenticated(nickname); + + foreach (var ch in channels) + conn.JoinChannel(ch); + + // Insert into gateway's ConcurrentDictionary via the public IReadOnlyDictionary + var connections = (ConcurrentDictionary)_gateway.Connections; + connections[conn.ConnectionId] = conn; + + return conn; + } + + private static List CaptureOutput(IrcClientConnection conn) + { + // We need to get the stream from the connection — but it's private. + // Since we used TestIrcConnectionFactory, the TestDuplexStream was passed to the constructor. + // We can't easily access it. Instead, we create connections differently for these tests. + // Let's use a different approach. + throw new NotSupportedException("Use AddConnectionWithCapture instead"); + } + + /// + /// Creates a connection that can capture output and injects it into the gateway. + /// + private (IrcClientConnection Connection, TestDuplexStream Stream) AddConnectionWithCapture( + string nickname, params string[] channels) + { + var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname); + + foreach (var ch in channels) + conn.JoinChannel(ch); + + var connections = (ConcurrentDictionary)_gateway.Connections; + connections[conn.ConnectionId] = conn; + + return (conn, stream); + } + + // ── SendMessageToChannelAsync ──────────────────────────────────────── + + [Fact] + public async Task SendMessage_DecryptsContent() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + var encryptedContent = _encryption.Encrypt("Hello world!"); + var message = new MessageDto( + Guid.NewGuid(), encryptedContent, "alice", null, "general", + MessageType.Text, null, null, DateTimeOffset.UtcNow); + + await _broadcaster.SendMessageToChannelAsync("general", message); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("Hello world!")); + Assert.DoesNotContain(output, l => l.Contains("$ENC$")); + } + + [Fact] + public async Task SendMessage_SkipsSender() + { + var (_, aliceStream) = AddConnectionWithCapture("alice", "general"); + var (_, bobStream) = AddConnectionWithCapture("bob", "general"); + + var message = new MessageDto( + Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", + MessageType.Text, null, null, DateTimeOffset.UtcNow); + + await _broadcaster.SendMessageToChannelAsync("general", message); + + // Alice (sender) should NOT receive the message + Assert.Empty(aliceStream.GetOutputLines()); + + // Bob should receive it + Assert.NotEmpty(bobStream.GetOutputLines()); + } + + [Fact] + public async Task SendMessage_OnlySendsToChannelMembers() + { + var (_, generalStream) = AddConnectionWithCapture("bob", "general"); + var (_, randomStream) = AddConnectionWithCapture("charlie", "random"); + + var message = new MessageDto( + Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", + MessageType.Text, null, null, DateTimeOffset.UtcNow); + + await _broadcaster.SendMessageToChannelAsync("general", message); + + Assert.NotEmpty(generalStream.GetOutputLines()); + Assert.Empty(randomStream.GetOutputLines()); + } + + // ── SendUserJoinedAsync ────────────────────────────────────────────── + + [Fact] + public async Task SendUserJoined_NotifiesOtherMembers() + { + var (_, bobStream) = AddConnectionWithCapture("bob", "general"); + + await _broadcaster.SendUserJoinedAsync("general", "alice"); + + var output = bobStream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice")); + } + + [Fact] + public async Task SendUserJoined_ExcludesConnectionId() + { + var (conn, excludedStream) = AddConnectionWithCapture("alice", "general"); + var (_, bobStream) = AddConnectionWithCapture("bob", "general"); + + await _broadcaster.SendUserJoinedAsync("general", "alice", conn.ConnectionId); + + // Excluded connection should not get the message + Assert.Empty(excludedStream.GetOutputLines()); + Assert.NotEmpty(bobStream.GetOutputLines()); + } + + // ── SendUserLeftAsync ──────────────────────────────────────────────── + + [Fact] + public async Task SendUserLeft_NotifiesOtherMembers() + { + var (_, bobStream) = AddConnectionWithCapture("bob", "general"); + AddConnectionWithCapture("alice", "general"); + + await _broadcaster.SendUserLeftAsync("general", "alice"); + + var output = bobStream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("PART #general") && l.Contains("alice")); + } + + [Fact] + public async Task SendUserLeft_SkipsSender() + { + var (_, aliceStream) = AddConnectionWithCapture("alice", "general"); + + await _broadcaster.SendUserLeftAsync("general", "alice"); + + Assert.Empty(aliceStream.GetOutputLines()); + } + + // ── SendChannelUpdatedAsync ────────────────────────────────────────── + + [Fact] + public async Task SendChannelUpdated_WithTopic_SendsTopicMessage() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + var channel = new ChannelDto( + Guid.NewGuid(), "general", "New topic!", true, 0, DateTimeOffset.UtcNow); + + await _broadcaster.SendChannelUpdatedAsync(channel, "general"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("TOPIC #general") && l.Contains("New topic!")); + } + + [Fact] + public async Task SendChannelUpdated_NullTopic_DoesNotSend() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + var channel = new ChannelDto( + Guid.NewGuid(), "general", null, true, 0, DateTimeOffset.UtcNow); + + await _broadcaster.SendChannelUpdatedAsync(channel, "general"); + + Assert.Empty(stream.GetOutputLines()); + } + + // ── SendErrorAsync ─────────────────────────────────────────────────── + + [Fact] + public async Task SendError_IrcConnection_SendsNotice() + { + var (conn, stream) = AddConnectionWithCapture("alice", "general"); + + await _broadcaster.SendErrorAsync(conn.ConnectionId, "Something went wrong"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("Something went wrong")); + } + + [Fact] + public async Task SendError_NonIrcConnection_DoesNothing() + { + // SignalR connection IDs don't start with "irc-" + await _broadcaster.SendErrorAsync("signalr-connection-123", "Error"); + // No crash, no output — the method silently returns + } + + // ── SendUserKickedAsync ────────────────────────────────────────────── + + [Fact] + public async Task SendUserKicked_NotifiesChannel() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + await _broadcaster.SendUserKickedAsync("general", "alice", "Spam"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("KICK #general alice") && l.Contains("Spam")); + } + + // ── SendUserBannedAsync ────────────────────────────────────────────── + + [Fact] + public async Task SendUserBanned_NotifiesBannedUser() + { + var (_, stream) = AddConnectionWithCapture("alice", "general"); + + await _broadcaster.SendUserBannedAsync("alice", "Repeated violations"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("banned")); + } + + // ── SendMessageDeletedAsync ────────────────────────────────────────── + + [Fact] + public async Task SendMessageDeleted_NotifiesChannel() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + var msgId = Guid.NewGuid(); + + await _broadcaster.SendMessageDeletedAsync("general", msgId); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("deleted") && l.Contains(msgId.ToString())); + } + + // ── SendChannelNukedAsync ──────────────────────────────────────────── + + [Fact] + public async Task SendChannelNuked_NotifiesChannel() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + await _broadcaster.SendChannelNukedAsync("general"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("cleared")); + } + + // ── ForceDisconnectUserAsync ───────────────────────────────────────── + + [Fact] + public async Task ForceDisconnect_IrcConnection_SendsErrorAndCloses() + { + var (conn, stream) = AddConnectionWithCapture("alice", "general"); + + await _broadcaster.ForceDisconnectUserAsync([conn.ConnectionId], "Banned"); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("ERROR") && l.Contains("Banned")); + } + + [Fact] + public async Task ForceDisconnect_NonIrcConnection_Ignores() + { + // Should not throw when given SignalR connection IDs + await _broadcaster.ForceDisconnectUserAsync(["signalr-abc", "signalr-def"], "Banned"); + } + + // ── SendUserStatusChangedAsync ─────────────────────────────────────── + + [Fact] + public async Task SendUserStatusChanged_IsNoOp() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + var presence = new UserPresenceDto( + "alice", null, null, UserStatus.Away, "brb", ServerRole.Member); + + await _broadcaster.SendUserStatusChangedAsync(["general"], presence); + + // IRC doesn't push status changes — clients use WHOIS/WHO + Assert.Empty(stream.GetOutputLines()); + } +} diff --git a/src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs b/src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs new file mode 100644 index 0000000..e753ea9 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs @@ -0,0 +1,213 @@ +using EchoHub.Server.Irc; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcClientConnectionTests +{ + // ── Connection identity ────────────────────────────────────────────── + + [Fact] + public void ConnectionId_StartsWithIrcPrefix() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + Assert.StartsWith("irc-", conn.ConnectionId); + } + + [Fact] + public void ConnectionId_IsUnique() + { + var (conn1, _) = TestIrcConnectionFactory.Create(); + var (conn2, _) = TestIrcConnectionFactory.Create(); + Assert.NotEqual(conn1.ConnectionId, conn2.ConnectionId); + } + + // ── Hostmask ───────────────────────────────────────────────────────── + + [Fact] + public void Hostmask_WithNicknameAndUsername_FormatsCorrectly() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.Nickname = "alice"; + conn.Username = "alice_user"; + + Assert.Equal("alice!alice_user@echohub", conn.Hostmask); + } + + [Fact] + public void Hostmask_WithoutUsername_FallsBackToNickname() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.Nickname = "alice"; + + Assert.Equal("alice!alice@echohub", conn.Hostmask); + } + + // ── I/O ────────────────────────────────────────────────────────────── + + [Fact] + public async Task ReadLineAsync_ReturnsInputLines() + { + var (conn, _) = TestIrcConnectionFactory.Create("PING", "PONG"); + + var line1 = await conn.ReadLineAsync(CancellationToken.None); + var line2 = await conn.ReadLineAsync(CancellationToken.None); + + Assert.Equal("PING", line1); + Assert.Equal("PONG", line2); + } + + [Fact] + public async Task ReadLineAsync_EndOfStream_ReturnsNull() + { + var (conn, _) = TestIrcConnectionFactory.Create("PING"); + + await conn.ReadLineAsync(CancellationToken.None); // consume "PING" + var result = await conn.ReadLineAsync(CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task SendAsync_WritesToOutput() + { + var (conn, stream) = TestIrcConnectionFactory.Create(); + + await conn.SendAsync(":server 001 alice :Welcome!"); + + var output = stream.GetOutputLines(); + Assert.Single(output); + Assert.Equal(":server 001 alice :Welcome!", output[0]); + } + + [Fact] + public async Task SendAsync_MultipleLines_AllCaptured() + { + var (conn, stream) = TestIrcConnectionFactory.Create(); + + await conn.SendAsync("line1"); + await conn.SendAsync("line2"); + await conn.SendAsync("line3"); + + var output = stream.GetOutputLines(); + Assert.Equal(3, output.Count); + } + + [Fact] + public async Task SendNumericAsync_FormatsCorrectly() + { + var (conn, stream) = TestIrcConnectionFactory.Create(); + conn.Nickname = "alice"; + + await conn.SendNumericAsync("testserver", "001", ":Welcome!"); + + var output = stream.GetOutputLines(); + Assert.Single(output); + Assert.Equal(":testserver 001 alice :Welcome!", output[0]); + } + + [Fact] + public async Task SendNumericAsync_NoNickname_UsesStar() + { + var (conn, stream) = TestIrcConnectionFactory.Create(); + + await conn.SendNumericAsync("testserver", "451", ":Not registered"); + + var output = stream.GetOutputLines(); + Assert.Contains("*", output[0]); + } + + // ── Thread-safe channel operations ─────────────────────────────────── + + [Fact] + public void JoinChannel_AddsChannel() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.JoinChannel("general"); + + Assert.True(conn.IsInChannel("general")); + } + + [Fact] + public void LeaveChannel_RemovesChannel() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.JoinChannel("general"); + conn.LeaveChannel("general"); + + Assert.False(conn.IsInChannel("general")); + } + + [Fact] + public void IsInChannel_CaseInsensitive() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.JoinChannel("General"); + + Assert.True(conn.IsInChannel("general")); + Assert.True(conn.IsInChannel("GENERAL")); + } + + [Fact] + public void GetJoinedChannels_ReturnsSnapshot() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + conn.JoinChannel("general"); + conn.JoinChannel("random"); + + var channels = conn.GetJoinedChannels(); + Assert.Equal(2, channels.Count); + Assert.Contains("general", channels); + Assert.Contains("random", channels); + + // Modifying the returned list shouldn't affect the connection state + channels.Clear(); + Assert.True(conn.IsInChannel("general")); + } + + [Fact] + public async Task JoinedChannels_ConcurrentAccess_DoesNotThrow() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + + // Simulate concurrent reads and writes (broadcaster reads while handler writes) + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var writerTask = Task.Run(async () => + { + for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++) + { + conn.JoinChannel($"channel-{i}"); + await Task.Yield(); + if (i % 3 == 0) conn.LeaveChannel($"channel-{i}"); + } + }, cts.Token); + + var readerTask = Task.Run(async () => + { + for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++) + { + _ = conn.IsInChannel($"channel-{i}"); + _ = conn.GetJoinedChannels(); + await Task.Yield(); + } + }, cts.Token); + + // Should complete without exceptions + await Task.WhenAll(writerTask, readerTask); + } + + // ── Default state ──────────────────────────────────────────────────── + + [Fact] + public void NewConnection_IsNotRegistered() + { + var (conn, _) = TestIrcConnectionFactory.Create(); + + Assert.False(conn.IsRegistered); + Assert.False(conn.IsAuthenticated); + Assert.Null(conn.Nickname); + Assert.Null(conn.Username); + Assert.Null(conn.UserId); + Assert.Null(conn.AwayMessage); + } +} diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs new file mode 100644 index 0000000..9aed298 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -0,0 +1,621 @@ +using System.Text; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Irc; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcCommandHandlerTests +{ + private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null }; + private readonly FakeChatService _chatService = new(); + private readonly FakeChannelService _channelService = new(); + private readonly FakeEncryptionService _encryption = new(); + + private IrcCommandHandler CreateHandler(IrcClientConnection conn) => + new(conn, _options, _chatService, _channelService, _encryption, NullLogger.Instance); + + private async Task> RunAndCapture(string[] inputLines, + Action? setup = null) + { + var (conn, stream) = TestIrcConnectionFactory.Create(inputLines); + setup?.Invoke(conn); + + var handler = CreateHandler(conn); + await handler.RunAsync(CancellationToken.None); + + return stream.GetOutputLines(); + } + + private async Task> RunAuthenticated(string[] inputLines, + string nickname = "alice", Guid? userId = null, + Action? setup = null) + { + var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname, userId, inputLines); + setup?.Invoke(conn); + + var handler = CreateHandler(conn); + await handler.RunAsync(CancellationToken.None); + + return stream.GetOutputLines(); + } + + // ── PING / PONG ────────────────────────────────────────────────────── + + [Fact] + public async Task Ping_RespondsWithPong() + { + var lines = await RunAuthenticated(["PING :mytoken"]); + + Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("mytoken")); + } + + [Fact] + public async Task Ping_NoToken_UsesServerName() + { + var lines = await RunAuthenticated(["PING"]); + + Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("testserver")); + } + + // ── Unregistered commands ──────────────────────────────────────────── + + [Fact] + public async Task UnregisteredUser_ChannelCommand_GetsNotRegisteredError() + { + var lines = await RunAndCapture(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("451") && l.Contains("not registered")); + } + + [Fact] + public async Task UnregisteredUser_PrivmsgCommand_GetsNotRegisteredError() + { + var lines = await RunAndCapture(["PRIVMSG #general :hello"]); + + Assert.Contains(lines, l => l.Contains("451")); + } + + // ── PASS / NICK / USER registration ────────────────────────────────── + + [Fact] + public async Task PassNickUser_ValidCredentials_Registers() + { + var userId = Guid.NewGuid(); + _chatService.AuthResult = (userId, "alice"); + + var lines = await RunAndCapture([ + "PASS secret123", + "NICK alice", + "USER alice 0 * :Alice Smith" + ]); + + // Should get welcome burst (001) + Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome")); + Assert.Contains("alice", _chatService.ConnectedUsers); + } + + [Fact] + public async Task NickUser_NoPassword_GetsPasswordError() + { + var lines = await RunAndCapture([ + "NICK alice", + "USER alice 0 * :Alice Smith" + ]); + + Assert.Contains(lines, l => l.Contains("464") && l.Contains("Password required")); + } + + [Fact] + public async Task PassNickUser_WrongPassword_GetsAuthError() + { + _chatService.AuthResult = null; + + var lines = await RunAndCapture([ + "PASS wrongpassword", + "NICK alice", + "USER alice 0 * :Alice Smith" + ]); + + Assert.Contains(lines, l => l.Contains("464") && l.Contains("incorrect")); + } + + [Fact] + public async Task Nick_InvalidNickname_GetsError() + { + var lines = await RunAndCapture(["NICK a"]); // too short + + Assert.Contains(lines, l => l.Contains("432") && l.Contains("Erroneous nickname")); + } + + [Fact] + public async Task Nick_NoParam_GetsNoNicknameError() + { + var lines = await RunAndCapture(["NICK"]); + + Assert.Contains(lines, l => l.Contains("431") && l.Contains("No nickname given")); + } + + [Fact] + public async Task User_AlreadyRegistered_GetsError() + { + var lines = await RunAuthenticated([ + "USER alice 0 * :Alice" + ]); + + Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister")); + } + + [Fact] + public async Task Pass_AlreadyRegistered_GetsError() + { + var lines = await RunAuthenticated([ + "PASS newpassword" + ]); + + Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister")); + } + + // ── CAP / SASL ────────────────────────────────────────────────────── + + [Fact] + public async Task CapLs_AdvertisesSasl() + { + var lines = await RunAndCapture(["CAP LS"]); + + Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl")); + } + + [Fact] + public async Task CapReqSasl_Acknowledged() + { + var lines = await RunAndCapture(["CAP REQ :sasl"]); + + Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl")); + } + + [Fact] + public async Task CapReqUnknown_GetsNak() + { + var lines = await RunAndCapture(["CAP REQ :multi-prefix"]); + + Assert.Contains(lines, l => l.Contains("NAK")); + } + + [Fact] + public async Task SaslPlain_ValidCredentials_Authenticates() + { + var userId = Guid.NewGuid(); + _chatService.AuthResult = (userId, "alice"); + + var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123")); + + var lines = await RunAndCapture([ + "CAP LS", + "CAP REQ :sasl", + $"AUTHENTICATE PLAIN", + $"AUTHENTICATE {saslPayload}", + "NICK alice", + "USER alice 0 * :Alice", + "CAP END" + ]); + + Assert.Contains(lines, l => l.Contains("903") && l.Contains("SASL authentication successful")); + Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome")); + } + + [Fact] + public async Task SaslPlain_InvalidCredentials_GetsError() + { + _chatService.AuthResult = null; + + var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd")); + + var lines = await RunAndCapture([ + "CAP LS", + "CAP REQ :sasl", + "AUTHENTICATE PLAIN", + $"AUTHENTICATE {saslPayload}", + ]); + + Assert.Contains(lines, l => l.Contains("904") && l.Contains("SASL authentication failed")); + } + + [Fact] + public async Task SaslPlain_MalformedPayload_GetsError() + { + var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("malformed")); + + var lines = await RunAndCapture([ + "CAP REQ :sasl", + "AUTHENTICATE PLAIN", + $"AUTHENTICATE {saslPayload}", + ]); + + Assert.Contains(lines, l => l.Contains("904")); + } + + // ── JOIN ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Join_ValidChannel_ConfirmsJoin() + { + _channelService.TopicResult = ("Welcome!", true); + + var lines = await RunAuthenticated(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("JOIN #general")); + Assert.Single(_chatService.JoinedChannels); + Assert.Equal("general", _chatService.JoinedChannels[0].Channel); + } + + [Fact] + public async Task Join_SendsTopic() + { + _channelService.TopicResult = ("Welcome to general!", true); + + var lines = await RunAuthenticated(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("332") && l.Contains("Welcome to general!")); + } + + [Fact] + public async Task Join_NoTopic_SendsNoTopicReply() + { + _channelService.TopicResult = (null, true); + + var lines = await RunAuthenticated(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("331") && l.Contains("No topic is set")); + } + + [Fact] + public async Task Join_SendsNamesReply() + { + _chatService.OnlineUsersToReturn = + [ + new("alice", null, null, UserStatus.Online, null, ServerRole.Member), + new("bob", null, null, UserStatus.Online, null, ServerRole.Member), + ]; + + var lines = await RunAuthenticated(["JOIN #general"]); + + Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob")); + Assert.Contains(lines, l => l.Contains("366") && l.Contains("End of /NAMES")); + } + + [Fact] + public async Task Join_DecryptsHistoryForIrc() + { + // Simulate encrypted history (as ChatService returns it) + var encryptedContent = _encryption.Encrypt("Hello from history!"); + _chatService.HistoryToReturn = + [ + new(Guid.NewGuid(), encryptedContent, "bob", null, "general", + MessageType.Text, null, null, DateTimeOffset.UtcNow) + ]; + + var lines = await RunAuthenticated(["JOIN #general"]); + + // Should contain the DECRYPTED text, not the encrypted version + Assert.Contains(lines, l => l.Contains("Hello from history!")); + Assert.DoesNotContain(lines, l => l.Contains("$ENC$Hello from history!")); + } + + [Fact] + public async Task Join_NonexistentChannel_GetsError() + { + _chatService.JoinError = "Channel 'nope' does not exist."; + + var lines = await RunAuthenticated(["JOIN #nope"]); + + Assert.Contains(lines, l => l.Contains("403") && l.Contains("does not exist")); + } + + [Fact] + public async Task Join_InvalidChannelName_GetsError() + { + var lines = await RunAuthenticated(["JOIN invalid"]); + + Assert.Contains(lines, l => l.Contains("403") && l.Contains("Invalid channel name")); + } + + [Fact] + public async Task Join_MultipleChannels_JoinsAll() + { + var lines = await RunAuthenticated(["JOIN #general,#random"]); + + Assert.Equal(2, _chatService.JoinedChannels.Count); + Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "general"); + Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "random"); + } + + [Fact] + public async Task Join_NoParams_GetsNeedMoreParamsError() + { + var lines = await RunAuthenticated(["JOIN"]); + + Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters")); + } + + // ── PART ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Part_ValidChannel_ConfirmsPart() + { + var lines = await RunAuthenticated(["PART #general"]); + + Assert.Contains(lines, l => l.Contains("PART #general")); + Assert.Single(_chatService.LeftChannels); + Assert.Equal("general", _chatService.LeftChannels[0].Channel); + } + + [Fact] + public async Task Part_WithReason_IncludesReason() + { + var lines = await RunAuthenticated(["PART #general :Leaving for now"]); + + Assert.Contains(lines, l => l.Contains("PART #general") && l.Contains("Leaving for now")); + } + + // ── PRIVMSG ────────────────────────────────────────────────────────── + + [Fact] + public async Task Privmsg_ChannelMessage_SendsViaService() + { + var lines = await RunAuthenticated(["PRIVMSG #general :Hello everyone!"]); + + Assert.Single(_chatService.SentMessages); + Assert.Equal("general", _chatService.SentMessages[0].Channel); + Assert.Equal("Hello everyone!", _chatService.SentMessages[0].Content); + } + + [Fact] + public async Task Privmsg_PrivateMessage_GetsError() + { + var lines = await RunAuthenticated(["PRIVMSG bob :Hey bob"]); + + Assert.Contains(lines, l => l.Contains("401") && l.Contains("Private messages are not supported")); + Assert.Empty(_chatService.SentMessages); + } + + [Fact] + public async Task Privmsg_ServiceError_ReturnsError() + { + _chatService.SendMessageError = "You are muted."; + + var lines = await RunAuthenticated(["PRIVMSG #general :Hello"]); + + Assert.Contains(lines, l => l.Contains("404") && l.Contains("muted")); + } + + [Fact] + public async Task Privmsg_NoParams_GetsNeedMoreParamsError() + { + var lines = await RunAuthenticated(["PRIVMSG"]); + + Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters")); + } + + // ── QUIT ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Quit_WithMessage_SendsClosingLink() + { + var lines = await RunAuthenticated(["QUIT :Goodbye!"]); + + Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Goodbye!")); + } + + [Fact] + public async Task Quit_NoMessage_UsesDefault() + { + var lines = await RunAuthenticated(["QUIT"]); + + Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Client quit")); + } + + // ── NAMES ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Names_ReturnsUserList() + { + _chatService.OnlineUsersToReturn = + [ + new("alice", null, null, UserStatus.Online, null, ServerRole.Member), + new("bob", "Bob", null, UserStatus.Away, null, ServerRole.Mod), + ]; + + var lines = await RunAuthenticated(["NAMES #general"]); + + Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob")); + Assert.Contains(lines, l => l.Contains("366")); + } + + // ── TOPIC ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Topic_Query_ReturnsTopic() + { + _channelService.TopicResult = ("Chat about everything", true); + + var lines = await RunAuthenticated(["TOPIC #general"]); + + Assert.Contains(lines, l => l.Contains("332") && l.Contains("Chat about everything")); + } + + [Fact] + public async Task Topic_SetAttempt_GetsPermissionDenied() + { + var lines = await RunAuthenticated(["TOPIC #general :New topic"]); + + Assert.Contains(lines, l => l.Contains("482") && l.Contains("channel creator")); + } + + // ── WHO ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Who_ReturnsUserListWithAwayFlags() + { + _chatService.OnlineUsersToReturn = + [ + new("alice", "Alice", null, UserStatus.Online, null, ServerRole.Member), + new("bob", "Bob", null, UserStatus.Away, "brb", ServerRole.Member), + ]; + + var lines = await RunAuthenticated(["WHO #general"]); + + Assert.Contains(lines, l => l.Contains("352") && l.Contains("alice") && l.Contains("H")); // Here + Assert.Contains(lines, l => l.Contains("352") && l.Contains("bob") && l.Contains("G")); // Gone + Assert.Contains(lines, l => l.Contains("315") && l.Contains("End of WHO")); + } + + // ── WHOIS ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Whois_ExistingUser_ReturnsInfo() + { + _chatService.ProfileToReturn = new UserProfileDto( + Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null, + UserStatus.Online, null, ServerRole.Member, + DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow); + _chatService.ChannelsForUserToReturn = ["general", "random"]; + + var lines = await RunAuthenticated(["WHOIS bob"]); + + Assert.Contains(lines, l => l.Contains("311") && l.Contains("bob") && l.Contains("Bob S.")); + Assert.Contains(lines, l => l.Contains("312") && l.Contains("testserver")); + Assert.Contains(lines, l => l.Contains("319") && l.Contains("#general") && l.Contains("#random")); + Assert.Contains(lines, l => l.Contains("317")); // idle + Assert.Contains(lines, l => l.Contains("318") && l.Contains("End of WHOIS")); + } + + [Fact] + public async Task Whois_NonexistentUser_GetsNoSuchNickError() + { + _chatService.ProfileToReturn = null; + + var lines = await RunAuthenticated(["WHOIS ghost"]); + + Assert.Contains(lines, l => l.Contains("401") && l.Contains("No such nick")); + } + + [Fact] + public async Task Whois_AwayUser_ShowsAwayMessage() + { + _chatService.ProfileToReturn = new UserProfileDto( + Guid.NewGuid(), "bob", null, null, null, null, + UserStatus.Away, "Gone fishing", ServerRole.Member, + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow); + _chatService.ChannelsForUserToReturn = []; + + var lines = await RunAuthenticated(["WHOIS bob"]); + + Assert.Contains(lines, l => l.Contains("301") && l.Contains("Gone fishing")); + } + + // ── AWAY ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Away_WithMessage_SetsAway() + { + var lines = await RunAuthenticated(["AWAY :Be right back"]); + + Assert.Contains(lines, l => l.Contains("306") && l.Contains("marked as being away")); + Assert.Single(_chatService.StatusUpdates); + Assert.Equal(UserStatus.Away, _chatService.StatusUpdates[0].Status); + } + + [Fact] + public async Task Away_NoMessage_ClearsAway() + { + var lines = await RunAuthenticated(["AWAY"]); + + Assert.Contains(lines, l => l.Contains("305") && l.Contains("no longer marked")); + Assert.Single(_chatService.StatusUpdates); + Assert.Equal(UserStatus.Online, _chatService.StatusUpdates[0].Status); + } + + // ── LIST ───────────────────────────────────────────────────────────── + + [Fact] + public async Task List_ReturnsChannels() + { + _channelService.ChannelListToReturn = + [ + new("general", "General chat", 5), + new("random", null, 2), + ]; + + var lines = await RunAuthenticated(["LIST"]); + + Assert.Contains(lines, l => l.Contains("322") && l.Contains("#general") && l.Contains("General chat")); + Assert.Contains(lines, l => l.Contains("322") && l.Contains("#random")); + Assert.Contains(lines, l => l.Contains("323") && l.Contains("End of LIST")); + } + + // ── MODE ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Mode_Channel_ReturnsChannelModes() + { + var lines = await RunAuthenticated(["MODE #general"]); + + Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general")); + } + + [Fact] + public async Task Mode_User_ReturnsUserModes() + { + var lines = await RunAuthenticated(["MODE alice"]); + + Assert.Contains(lines, l => l.Contains("221")); + } + + // ── MOTD ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Motd_NoMotdConfigured_GetsNoMotdError() + { + var lines = await RunAuthenticated(["MOTD"]); + + Assert.Contains(lines, l => l.Contains("422") && l.Contains("MOTD File is missing")); + } + + [Fact] + public async Task Motd_WithMotd_DisplaysMotd() + { + _options.Motd = "Welcome to EchoHub!\nEnjoy your stay."; + + var lines = await RunAuthenticated(["MOTD"]); + + Assert.Contains(lines, l => l.Contains("375")); // MOTDSTART + Assert.Contains(lines, l => l.Contains("372") && l.Contains("Welcome to EchoHub!")); + Assert.Contains(lines, l => l.Contains("372") && l.Contains("Enjoy your stay.")); + Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD + } + + // ── Unknown command ────────────────────────────────────────────────── + + [Fact] + public async Task UnknownCommand_GetsError() + { + var lines = await RunAuthenticated(["FOOBAR"]); + + Assert.Contains(lines, l => l.Contains("421") && l.Contains("FOOBAR") && l.Contains("Unknown command")); + } + + // ── Channel name conversion ────────────────────────────────────────── + + [Fact] + public async Task Join_ChannelNameNormalized_ToLowerCase() + { + var lines = await RunAuthenticated(["JOIN #General"]); + + Assert.Single(_chatService.JoinedChannels); + Assert.Equal("general", _chatService.JoinedChannels[0].Channel); + } +} diff --git a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs new file mode 100644 index 0000000..b845b50 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs @@ -0,0 +1,294 @@ +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Irc; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcMessageFormatterTests +{ + private static MessageDto CreateTextMessage(string content, string sender = "alice", + string channel = "general", List? embeds = null) + { + return new MessageDto( + Guid.NewGuid(), content, sender, null, channel, + MessageType.Text, null, null, DateTimeOffset.UtcNow, Embeds: embeds); + } + + private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png", + string url = "https://example.com/image.png", string sender = "alice", string channel = "general") + { + return new MessageDto( + Guid.NewGuid(), asciiArt, sender, null, channel, + MessageType.Image, url, fileName, DateTimeOffset.UtcNow); + } + + private static MessageDto CreateFileMessage(string fileName = "doc.pdf", + string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general") + { + return new MessageDto( + Guid.NewGuid(), "", sender, null, channel, + MessageType.File, url, fileName, DateTimeOffset.UtcNow); + } + + private static MessageDto CreateAudioMessage(string fileName = "song.mp3", + string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general") + { + return new MessageDto( + Guid.NewGuid(), "", sender, null, channel, + MessageType.Audio, url, fileName, DateTimeOffset.UtcNow); + } + + // ── FormatMessage ──────────────────────────────────────────────────── + + [Fact] + public void FormatMessage_TextMessage_FormatsAsPrivmsg() + { + var msg = CreateTextMessage("Hello world"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Single(lines); + Assert.Equal(":alice!alice@echohub PRIVMSG #general :Hello world", lines[0]); + } + + [Fact] + public void FormatMessage_TextMessage_IncludesChannelHash() + { + var msg = CreateTextMessage("test", channel: "random"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Contains("#random", lines[0]); + } + + [Fact] + public void FormatMessage_TextWithEmbeds_AppendsEmbedLines() + { + var embeds = new List + { + new("Example Site", "Page Title", "A description of the page", null, "https://example.com") + }; + var msg = CreateTextMessage("Check this: https://example.com", embeds: embeds); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.True(lines.Count >= 2); + Assert.Contains("Check this: https://example.com", lines[0]); + // Embed header + Assert.Contains("Example Site", lines[1]); + Assert.Contains("Page Title", lines[1]); + } + + [Fact] + public void FormatMessage_EmbedWithDescription_IncludesDescription() + { + var embeds = new List + { + new("Site", "Title", "This is a description", null, "https://example.com") + }; + var msg = CreateTextMessage("url", embeds: embeds); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.True(lines.Count >= 3); + Assert.Contains("This is a description", lines[2]); + } + + [Fact] + public void FormatMessage_EmbedWithLongDescription_Truncates() + { + var longDesc = new string('x', 300); + var embeds = new List + { + new("Site", "Title", longDesc, null, "https://example.com") + }; + var msg = CreateTextMessage("url", embeds: embeds); + var lines = IrcMessageFormatter.FormatMessage(msg); + + var descLine = lines.First(l => l.Contains("xxx")); + Assert.Contains("...", descLine); + // Should be truncated to ~200 chars + var descContent = descLine[(descLine.LastIndexOf(':') + 2)..]; // after ":│ " + Assert.True(descContent.Length <= 210); + } + + [Fact] + public void FormatMessage_ImageMessage_IncludesFileNameAndUrl() + { + var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]")); + Assert.Contains(lines, l => l.Contains("Download: https://example.com/photo.jpg")); + } + + [Fact] + public void FormatMessage_ImageMessage_IncludesAsciiArt() + { + var msg = CreateImageMessage("line1\nline2"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Contains(lines, l => l.Contains("line1")); + Assert.Contains(lines, l => l.Contains("line2")); + } + + [Fact] + public void FormatMessage_ImageMessage_SkipsEmptyAsciiLines() + { + var msg = CreateImageMessage("line1\n\nline2"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + // Empty lines should be skipped + var asciiLines = lines.Where(l => !l.Contains("[Image:") && !l.Contains("Download:")).ToList(); + Assert.Equal(2, asciiLines.Count); + } + + [Fact] + public void FormatMessage_FileMessage_FormatsCorrectly() + { + var msg = CreateFileMessage("report.pdf", "https://example.com/report.pdf"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Single(lines); + Assert.Contains("[File: report.pdf]", lines[0]); + Assert.Contains("https://example.com/report.pdf", lines[0]); + } + + [Fact] + public void FormatMessage_AudioMessage_FormatsWithMusicNote() + { + var msg = CreateAudioMessage("track.mp3", "https://example.com/track.mp3"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Single(lines); + Assert.Contains("\u266a", lines[0]); // ♪ + Assert.Contains("[Audio: track.mp3]", lines[0]); + Assert.Contains("https://example.com/track.mp3", lines[0]); + } + + // ── SplitMessage ───────────────────────────────────────────────────── + + [Fact] + public void SplitMessage_ShortMessage_ReturnsSingleChunk() + { + var chunks = IrcMessageFormatter.SplitMessage("Hello", 400); + Assert.Single(chunks); + Assert.Equal("Hello", chunks[0]); + } + + [Fact] + public void SplitMessage_ExactlyAtLimit_ReturnsSingleChunk() + { + var msg = new string('a', 400); + var chunks = IrcMessageFormatter.SplitMessage(msg, 400); + Assert.Single(chunks); + } + + [Fact] + public void SplitMessage_LongMessage_SplitsAtWordBoundary() + { + // Create a message that's longer than 50 bytes + var words = string.Join(" ", Enumerable.Repeat("hello", 20)); // 20 * 6 - 1 = 119 bytes + var chunks = IrcMessageFormatter.SplitMessage(words, 50); + + Assert.True(chunks.Count > 1); + // Each chunk should be roughly <= 50 bytes + foreach (var chunk in chunks) + { + Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 55, + $"Chunk too long: {chunk.Length} chars"); + } + // Reassembled content should match original + var reassembled = string.Join(" ", chunks); + Assert.Equal(words, reassembled); + } + + [Fact] + public void SplitMessage_SingleLongWord_ForcedIntoOneChunk() + { + var longWord = new string('a', 500); + var chunks = IrcMessageFormatter.SplitMessage(longWord, 400); + // A single word can't be split at word boundaries, so it stays as one chunk + Assert.Single(chunks); + Assert.Equal(longWord, chunks[0]); + } + + [Fact] + public void SplitMessage_EmptyString_ReturnsSingleEmpty() + { + var chunks = IrcMessageFormatter.SplitMessage("", 400); + Assert.Single(chunks); + Assert.Equal("", chunks[0]); + } + + [Fact] + public void SplitMessage_UnicodeContent_CountsUtf8Bytes() + { + // Japanese text: each char is 3 bytes in UTF-8 + var text = string.Join(" ", Enumerable.Repeat("\u3042\u3044\u3046", 50)); + var chunks = IrcMessageFormatter.SplitMessage(text, 100); + + Assert.True(chunks.Count > 1); + foreach (var chunk in chunks) + { + Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 110, + $"Chunk too long in bytes: {System.Text.Encoding.UTF8.GetByteCount(chunk)}"); + } + } + + // ── ColorTagsToAnsi ────────────────────────────────────────────────── + + [Fact] + public void ColorTagsToAnsi_NoTags_ReturnsUnchanged() + { + Assert.Equal("Hello world", IrcMessageFormatter.ColorTagsToAnsi("Hello world")); + } + + [Fact] + public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsi() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red text"); + Assert.Equal("\x1b[38;2;255;0;0mRed text", result); + } + + [Fact] + public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsi() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}Green bg"); + Assert.Equal("\x1b[48;2;0;255;0mGreen bg", result); + } + + [Fact] + public void ColorTagsToAnsi_ResetTag_ConvertsToReset() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red{X} Normal"); + Assert.Equal("\x1b[38;2;255;0;0mRed\x1b[0m Normal", result); + } + + [Fact] + public void ColorTagsToAnsi_MultipleTags_ConvertsAll() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red {F:0000FF}Blue{X}"); + Assert.Contains("\x1b[38;2;255;0;0m", result); + Assert.Contains("\x1b[38;2;0;0;255m", result); + Assert.Contains("\x1b[0m", result); + } + + [Fact] + public void ColorTagsToAnsi_LowercaseHex_ConvertsCorrectly() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{F:ff8800}text"); + Assert.Equal("\x1b[38;2;255;136;0mtext", result); + } + + [Fact] + public void ColorTagsToAnsi_NoBraces_SkipsProcessing() + { + var text = "plain text without braces"; + Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text)); + } + + [Fact] + public void ColorTagsToAnsi_ExistingAnsiCodes_PreservesUnchanged() + { + var text = "\x1b[31mAlready colored\x1b[0m"; + Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text)); + } +} diff --git a/src/EchoHub.Tests/Irc/IrcMessageTests.cs b/src/EchoHub.Tests/Irc/IrcMessageTests.cs new file mode 100644 index 0000000..566af96 --- /dev/null +++ b/src/EchoHub.Tests/Irc/IrcMessageTests.cs @@ -0,0 +1,183 @@ +using EchoHub.Server.Irc; +using Xunit; + +namespace EchoHub.Tests.Irc; + +public class IrcMessageTests +{ + [Fact] + public void Parse_SimpleCommand_ExtractsCommand() + { + var msg = IrcMessage.Parse("PING"); + Assert.Equal("PING", msg.Command); + Assert.Null(msg.Prefix); + Assert.Empty(msg.Parameters); + } + + [Fact] + public void Parse_CommandWithOneParam_ExtractsParam() + { + var msg = IrcMessage.Parse("NICK alice"); + Assert.Equal("NICK", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("alice", msg.Parameters[0]); + } + + [Fact] + public void Parse_CommandWithTrailing_ExtractsTrailingAsLastParam() + { + var msg = IrcMessage.Parse("PRIVMSG #general :Hello world!"); + Assert.Equal("PRIVMSG", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("#general", msg.Parameters[0]); + Assert.Equal("Hello world!", msg.Parameters[1]); + Assert.Equal("Hello world!", msg.Trailing); + } + + [Fact] + public void Parse_MessageWithPrefix_ExtractsPrefix() + { + var msg = IrcMessage.Parse(":alice!alice@echohub PRIVMSG #general :hi"); + Assert.Equal("alice!alice@echohub", msg.Prefix); + Assert.Equal("PRIVMSG", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("#general", msg.Parameters[0]); + Assert.Equal("hi", msg.Parameters[1]); + } + + [Fact] + public void Parse_MultipleParams_ExtractsAll() + { + var msg = IrcMessage.Parse("USER alice 0 * :Alice Smith"); + Assert.Equal("USER", msg.Command); + Assert.Equal(4, msg.Parameters.Count); + Assert.Equal("alice", msg.Parameters[0]); + Assert.Equal("0", msg.Parameters[1]); + Assert.Equal("*", msg.Parameters[2]); + Assert.Equal("Alice Smith", msg.Parameters[3]); + } + + [Fact] + public void Parse_PingWithToken_ExtractsToken() + { + var msg = IrcMessage.Parse("PING :server.example.com"); + Assert.Equal("PING", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("server.example.com", msg.Parameters[0]); + } + + [Fact] + public void Parse_CapLs_ParsesSubcommand() + { + var msg = IrcMessage.Parse("CAP LS 302"); + Assert.Equal("CAP", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("LS", msg.Parameters[0]); + Assert.Equal("302", msg.Parameters[1]); + } + + [Fact] + public void Parse_CapReqWithTrailing_ParsesSasl() + { + var msg = IrcMessage.Parse("CAP REQ :sasl"); + Assert.Equal("CAP", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("REQ", msg.Parameters[0]); + Assert.Equal("sasl", msg.Parameters[1]); + } + + [Fact] + public void Parse_JoinMultipleChannels_ExtractsCsv() + { + var msg = IrcMessage.Parse("JOIN #general,#random"); + Assert.Equal("JOIN", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("#general,#random", msg.Parameters[0]); + } + + [Fact] + public void Parse_PartWithReason_ExtractsReason() + { + var msg = IrcMessage.Parse("PART #general :Leaving for now"); + Assert.Equal("PART", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("#general", msg.Parameters[0]); + Assert.Equal("Leaving for now", msg.Parameters[1]); + } + + [Fact] + public void Parse_EmptyTrailing_ExtractsEmptyString() + { + var msg = IrcMessage.Parse("PRIVMSG #general :"); + Assert.Equal("PRIVMSG", msg.Command); + Assert.Equal(2, msg.Parameters.Count); + Assert.Equal("#general", msg.Parameters[0]); + Assert.Equal("", msg.Parameters[1]); + } + + [Fact] + public void Parse_TrailingWithColons_PreservesColons() + { + var msg = IrcMessage.Parse("PRIVMSG #general :time is 12:30:00"); + Assert.Equal("time is 12:30:00", msg.Parameters[1]); + } + + [Fact] + public void Parse_CrLfTrimmed() + { + var msg = IrcMessage.Parse("PING\r\n"); + Assert.Equal("PING", msg.Command); + Assert.Empty(msg.Parameters); + } + + [Fact] + public void Parse_ExtraSpaces_Handled() + { + var msg = IrcMessage.Parse("NICK alice"); + Assert.Equal("NICK", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("alice", msg.Parameters[0]); + } + + [Fact] + public void Parse_Authenticate_Base64Payload() + { + var payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("\0alice\0secret")); + var msg = IrcMessage.Parse($"AUTHENTICATE {payload}"); + Assert.Equal("AUTHENTICATE", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal(payload, msg.Parameters[0]); + } + + [Fact] + public void Parse_PassCommand_ExtractsPassword() + { + var msg = IrcMessage.Parse("PASS mysecretpassword"); + Assert.Equal("PASS", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("mysecretpassword", msg.Parameters[0]); + } + + [Fact] + public void Parse_QuitWithMessage_ExtractsMessage() + { + var msg = IrcMessage.Parse("QUIT :Goodbye!"); + Assert.Equal("QUIT", msg.Command); + Assert.Single(msg.Parameters); + Assert.Equal("Goodbye!", msg.Parameters[0]); + } + + [Fact] + public void Trailing_NoParams_ReturnsNull() + { + var msg = IrcMessage.Parse("PING"); + Assert.Null(msg.Trailing); + } + + [Fact] + public void Trailing_WithParams_ReturnsLastParam() + { + var msg = IrcMessage.Parse("MODE #channel +o alice"); + Assert.Equal("alice", msg.Trailing); + } +} diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs new file mode 100644 index 0000000..86001d2 --- /dev/null +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -0,0 +1,260 @@ +using System.Net.Sockets; +using System.Text; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Irc; + +namespace EchoHub.Tests.Irc; + +/// +/// A duplex stream that reads from one buffer and writes to another, +/// allowing test code to inject input and capture output. +/// +internal sealed class TestDuplexStream : Stream +{ + private readonly MemoryStream _readBuffer; + private readonly MemoryStream _writeBuffer = new(); + + public TestDuplexStream(string input = "") + { + _readBuffer = new MemoryStream(Encoding.UTF8.GetBytes(input)); + } + + public string GetOutput() + { + var raw = Encoding.UTF8.GetString(_writeBuffer.ToArray()); + // Strip UTF-8 BOM emitted by StreamWriter + return raw.TrimStart('\uFEFF'); + } + + public List GetOutputLines() => + GetOutput().Split("\r\n", StringSplitOptions.RemoveEmptyEntries).ToList(); + + // Read from the input buffer + public override int Read(byte[] buffer, int offset, int count) => + _readBuffer.Read(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + _readBuffer.ReadAsync(buffer, offset, count, ct); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => + _readBuffer.ReadAsync(buffer, ct); + + // Write to the output buffer + public override void Write(byte[] buffer, int offset, int count) => + _writeBuffer.Write(buffer, offset, count); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + _writeBuffer.WriteAsync(buffer, offset, count, ct); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) => + _writeBuffer.WriteAsync(buffer, ct); + + public override void Flush() => _writeBuffer.Flush(); + public override Task FlushAsync(CancellationToken ct) => _writeBuffer.FlushAsync(ct); + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _readBuffer.Dispose(); + _writeBuffer.Dispose(); + } + base.Dispose(disposing); + } +} + +/// +/// Creates IrcClientConnections backed by test streams for unit testing. +/// +internal static class TestIrcConnectionFactory +{ + /// + /// Creates a test IRC connection with the given input lines. + /// Returns the connection and the test stream (for inspecting output). + /// + public static (IrcClientConnection Connection, TestDuplexStream Stream) Create(params string[] inputLines) + { + var input = string.Join("\r\n", inputLines); + if (inputLines.Length > 0) input += "\r\n"; + + var stream = new TestDuplexStream(input); + var tcpClient = new TcpClient(); + var conn = new IrcClientConnection(tcpClient, stream); + return (conn, stream); + } + + /// + /// Creates a pre-authenticated, registered IRC connection. + /// + public static (IrcClientConnection Connection, TestDuplexStream Stream) CreateAuthenticated( + string nickname = "alice", Guid? userId = null, params string[] inputLines) + { + var (conn, stream) = Create(inputLines); + conn.Nickname = nickname; + conn.Username = nickname; + conn.UserId = userId ?? Guid.NewGuid(); + conn.IsRegistered = true; + conn.IsAuthenticated = true; + return (conn, stream); + } +} + +/// +/// Fake encryption service that uses a simple reversible prefix-based scheme. +/// Encrypt("hello") → "$ENC$hello", Decrypt("$ENC$hello") → "hello". +/// +internal sealed class FakeEncryptionService : IMessageEncryptionService +{ + private const string Prefix = "$ENC$"; + + public bool EncryptDatabaseEnabled => true; + + public string Encrypt(string plaintext) => $"{Prefix}{plaintext}"; + + public string Decrypt(string content) + { + if (content.StartsWith(Prefix)) + return content[Prefix.Length..]; + return content; + } + + public string? EncryptNullable(string? value) => + value is not null ? Encrypt(value) : null; + + public string? DecryptNullable(string? value) => + value is not null ? Decrypt(value) : null; +} + +/// +/// Fake chat service that records method calls and returns pre-configured results. +/// +internal sealed class FakeChatService : IChatService +{ + // Recorded calls + public List ConnectedUsers { get; } = []; + public List DisconnectedConnections { get; } = []; + public List<(string Channel, string Username)> JoinedChannels { get; } = []; + public List<(string Channel, string Username)> LeftChannels { get; } = []; + public List<(string Channel, string Content)> SentMessages { get; } = []; + public List<(string Username, UserStatus Status)> StatusUpdates { get; } = []; + + // Configurable results + public List HistoryToReturn { get; set; } = []; + public string? JoinError { get; set; } + public string? SendMessageError { get; set; } + public (Guid UserId, string Username)? AuthResult { get; set; } + public UserProfileDto? ProfileToReturn { get; set; } + public List ChannelsForUserToReturn { get; set; } = []; + public List OnlineUsersToReturn { get; set; } = []; + + public Task UserConnectedAsync(string connectionId, Guid userId, string username) + { + ConnectedUsers.Add(username); + return Task.CompletedTask; + } + + public Task UserDisconnectedAsync(string connectionId) + { + DisconnectedConnections.Add(connectionId); + return Task.FromResult(null); + } + + public Task<(List History, string? Error)> JoinChannelAsync( + string connectionId, Guid userId, string username, string channelName) + { + JoinedChannels.Add((channelName, username)); + return Task.FromResult((HistoryToReturn, JoinError)); + } + + public Task LeaveChannelAsync(string connectionId, string username, string channelName) + { + LeftChannels.Add((channelName, username)); + return Task.CompletedTask; + } + + public Task SendMessageAsync(Guid userId, string username, string channelName, string content) + { + SentMessages.Add((channelName, content)); + return Task.FromResult(SendMessageError); + } + + public Task> GetChannelHistoryAsync(string channelName, int count) => + Task.FromResult(HistoryToReturn); + + public Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) + { + StatusUpdates.Add((username, status)); + return Task.FromResult(null); + } + + public Task> GetOnlineUsersAsync(string channelName) => + Task.FromResult(OnlineUsersToReturn); + + public Task BroadcastMessageAsync(string channelName, MessageDto message) => + Task.CompletedTask; + + public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) => + Task.CompletedTask; + + public Task GetUserProfileAsync(string username) => + Task.FromResult(ProfileToReturn); + + public Task> GetChannelsForUserAsync(string username) => + Task.FromResult(ChannelsForUserToReturn); + + public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) => + Task.FromResult(AuthResult); +} + +/// +/// Fake channel service that records method calls and returns pre-configured results. +/// +internal sealed class FakeChannelService : IChannelService +{ + // Configurable results + public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true); + public List ChannelListToReturn { get; set; } = []; + public ChannelDto? ChannelByNameToReturn { get; set; } + public ChannelOperationResult? CreateResult { get; set; } + public ChannelOperationResult? UpdateTopicResult { get; set; } + public ChannelOperationResult? DeleteResult { get; set; } + public (bool Success, string? Error) MembershipResult { get; set; } = (true, null); + + public Task> GetChannelsAsync(Guid userId, int offset, int limit) => + Task.FromResult(new PaginatedResponse([], 0, offset, limit)); + + public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) => + Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + + public Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) => + Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + + public Task DeleteChannelAsync(Guid callerUserId, string channelName) => + Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + + public Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) => + Task.FromResult(TopicResult); + + public Task> GetChannelListAsync() => + Task.FromResult(ChannelListToReturn); + + public Task GetChannelByNameAsync(string channelName) => + Task.FromResult(ChannelByNameToReturn); + + public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) => + Task.FromResult(MembershipResult); +} diff --git a/src/EchoHub.Tests/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/IrcMessageFormatterTests.cs new file mode 100644 index 0000000..5e407b5 --- /dev/null +++ b/src/EchoHub.Tests/IrcMessageFormatterTests.cs @@ -0,0 +1,186 @@ +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Irc; +using Xunit; + +namespace EchoHub.Tests; + +public class IrcMessageFormatterTests +{ + private static MessageDto CreateMessage( + MessageType type = MessageType.Text, + string content = "hello", + string sender = "alice", + string channel = "general", + string? attachmentUrl = null, + string? attachmentFileName = null, + List? embeds = null) => new( + Id: Guid.NewGuid(), + Content: content, + SenderUsername: sender, + SenderNicknameColor: null, + ChannelName: channel, + Type: type, + AttachmentUrl: attachmentUrl, + AttachmentFileName: attachmentFileName, + SentAt: DateTimeOffset.UtcNow, + Embeds: embeds); + + // ── FormatMessage ───────────────────────────────────────────────── + + [Fact] + public void FormatMessage_TextMessage_FormatsAsPRIVMSG() + { + var msg = CreateMessage(content: "Hello world"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Single(lines); + Assert.Contains("PRIVMSG #general :Hello world", lines[0]); + Assert.StartsWith(":alice!alice@echohub", lines[0]); + } + + [Fact] + public void FormatMessage_TextMessage_WithEmbeds_AppendsEmbedLines() + { + var embeds = new List + { + new("GitHub", "Repo Title", "A description", null, "https://github.com/test") + }; + var msg = CreateMessage(content: "check this out https://github.com/test", embeds: embeds); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.True(lines.Count >= 2); + Assert.Contains("PRIVMSG #general :check this out", lines[0]); + // Embed lines contain the Unicode pipe char and site/title + Assert.Contains("GitHub", lines[1]); + Assert.Contains("Repo Title", lines[1]); + } + + [Fact] + public void FormatMessage_ImageMessage_IncludesImageTagAndDownloadUrl() + { + var msg = CreateMessage( + type: MessageType.Image, + content: "{F:FF0000}\u2588{X}", + attachmentUrl: "/api/files/abc", + attachmentFileName: "photo.png"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.True(lines.Count >= 2); + Assert.Contains("[Image: photo.png]", lines[0]); + Assert.Contains("Download: /api/files/abc", lines[1]); + } + + [Fact] + public void FormatMessage_FileMessage_IncludesFileTag() + { + var msg = CreateMessage( + type: MessageType.File, + content: "report.pdf", + attachmentUrl: "/api/files/xyz", + attachmentFileName: "report.pdf"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Single(lines); + Assert.Contains("[File: report.pdf]", lines[0]); + Assert.Contains("/api/files/xyz", lines[0]); + } + + [Fact] + public void FormatMessage_AudioMessage_IncludesMusicNoteAndAudioTag() + { + var msg = CreateMessage( + type: MessageType.Audio, + content: "song.mp3", + attachmentUrl: "/api/files/def", + attachmentFileName: "song.mp3"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + Assert.Single(lines); + Assert.Contains("\u266a", lines[0]); // ♪ + Assert.Contains("[Audio: song.mp3]", lines[0]); + Assert.Contains("/api/files/def", lines[0]); + } + + // ── ColorTagsToAnsi ─────────────────────────────────────────────── + + [Fact] + public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsiEscape() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}text"); + Assert.Contains("\x1b[38;2;255;0;0m", result); + Assert.Contains("text", result); + } + + [Fact] + public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsiEscape() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}text"); + Assert.Contains("\x1b[48;2;0;255;0m", result); + } + + [Fact] + public void ColorTagsToAnsi_ResetTag_ConvertsToAnsiReset() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{X}"); + Assert.Equal("\x1b[0m", result); + } + + [Fact] + public void ColorTagsToAnsi_NoTags_ReturnsUnchanged() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("plain text"); + Assert.Equal("plain text", result); + } + + [Fact] + public void ColorTagsToAnsi_MultipleTags_ConvertsAll() + { + var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}red{F:0000FF}blue{X}"); + Assert.Contains("\x1b[38;2;255;0;0m", result); + Assert.Contains("\x1b[38;2;0;0;255m", result); + Assert.Contains("\x1b[0m", result); + Assert.Contains("red", result); + Assert.Contains("blue", result); + } + + // ── SplitMessage ────────────────────────────────────────────────── + + [Fact] + public void SplitMessage_ShortMessage_ReturnsSingleChunk() + { + var result = IrcMessageFormatter.SplitMessage("Hello", 400); + Assert.Single(result); + Assert.Equal("Hello", result[0]); + } + + [Fact] + public void SplitMessage_LongMessage_SplitsAtWordBoundary() + { + var words = string.Join(" ", Enumerable.Repeat("word", 200)); + var result = IrcMessageFormatter.SplitMessage(words, 50); + + Assert.True(result.Count > 1); + foreach (var chunk in result) + Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 50); + } + + [Fact] + public void SplitMessage_EmptyMessage_ReturnsSingleEmptyChunk() + { + var result = IrcMessageFormatter.SplitMessage("", 400); + Assert.Single(result); + Assert.Equal("", result[0]); + } + + [Fact] + public void SplitMessage_SingleLongWord_KeptAsOneChunk() + { + var longWord = new string('a', 500); + var result = IrcMessageFormatter.SplitMessage(longWord, 400); + + // Single word can't be split at word boundary, so it stays as one chunk + Assert.Single(result); + Assert.Equal(longWord, result[0]); + } +} diff --git a/src/EchoHub.Tests/JwtTokenServiceTests.cs b/src/EchoHub.Tests/JwtTokenServiceTests.cs new file mode 100644 index 0000000..a9367c8 --- /dev/null +++ b/src/EchoHub.Tests/JwtTokenServiceTests.cs @@ -0,0 +1,195 @@ +using System.IdentityModel.Tokens.Jwt; +using EchoHub.Core.Models; +using EchoHub.Server.Auth; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace EchoHub.Tests; + +public class JwtTokenServiceTests +{ + private const string TestSecret = "this_is_a_test_secret_key_that_is_long_enough_for_hmac_sha256"; + private const string TestIssuer = "TestIssuer"; + private const string TestAudience = "TestAudience"; + + private static JwtTokenService CreateService( + string? secret = null, string? issuer = null, string? audience = null) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Secret"] = secret ?? TestSecret, + ["Jwt:Issuer"] = issuer ?? TestIssuer, + ["Jwt:Audience"] = audience ?? TestAudience, + }) + .Build(); + + return new JwtTokenService(config); + } + + private static User CreateUser( + string username = "alice", + ServerRole role = ServerRole.Member, + string? displayName = null) => new() + { + Id = Guid.NewGuid(), + Username = username, + PasswordHash = "hash", + DisplayName = displayName, + Role = role, + }; + + // ── Constructor ─────────────────────────────────────────────────── + + [Fact] + public void Constructor_MissingSecret_Throws() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Issuer"] = TestIssuer, + ["Jwt:Audience"] = TestAudience, + }) + .Build(); + + Assert.Throws(() => new JwtTokenService(config)); + } + + [Fact] + public void Constructor_MissingIssuer_Throws() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Secret"] = TestSecret, + ["Jwt:Audience"] = TestAudience, + }) + .Build(); + + Assert.Throws(() => new JwtTokenService(config)); + } + + [Fact] + public void Constructor_MissingAudience_Throws() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Secret"] = TestSecret, + ["Jwt:Issuer"] = TestIssuer, + }) + .Build(); + + Assert.Throws(() => new JwtTokenService(config)); + } + + // ── GenerateAccessToken ─────────────────────────────────────────── + + [Fact] + public void GenerateAccessToken_ContainsExpectedClaims() + { + var service = CreateService(); + var user = CreateUser(username: "bob", role: ServerRole.Admin, displayName: "Bob Smith"); + + var (token, _) = service.GenerateAccessToken(user); + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); + + Assert.Equal(user.Id.ToString(), jwt.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("bob", jwt.Claims.First(c => c.Type == "username").Value); + Assert.Equal("Bob Smith", jwt.Claims.First(c => c.Type == "display_name").Value); + Assert.Equal("Admin", jwt.Claims.First(c => c.Type == "role").Value); + Assert.NotNull(jwt.Claims.FirstOrDefault(c => c.Type == "jti")); + } + + [Fact] + public void GenerateAccessToken_ExpiresIn15Minutes() + { + var service = CreateService(); + var user = CreateUser(); + + var (_, expiresAt) = service.GenerateAccessToken(user); + var diff = expiresAt - DateTimeOffset.UtcNow; + + // Should be approximately 15 minutes (allow 30s tolerance) + Assert.InRange(diff.TotalMinutes, 14.5, 15.5); + } + + [Fact] + public void GenerateAccessToken_DifferentTokensForSameUser() + { + var service = CreateService(); + var user = CreateUser(); + + var (token1, _) = service.GenerateAccessToken(user); + var (token2, _) = service.GenerateAccessToken(user); + + Assert.NotEqual(token1, token2); + } + + [Fact] + public void GenerateAccessToken_DisplayNameFallsBackToUsername() + { + var service = CreateService(); + var user = CreateUser(username: "alice"); // DisplayName is null + + var (token, _) = service.GenerateAccessToken(user); + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); + + Assert.Equal("alice", jwt.Claims.First(c => c.Type == "display_name").Value); + } + + // ── GenerateRefreshToken ────────────────────────────────────────── + + [Fact] + public void GenerateRefreshToken_Returns88CharBase64() + { + var token = JwtTokenService.GenerateRefreshToken(); + + // 64 bytes → 88 base64 characters + Assert.Equal(88, token.Length); + // Should be valid base64 + var bytes = Convert.FromBase64String(token); + Assert.Equal(64, bytes.Length); + } + + [Fact] + public void GenerateRefreshToken_UniqueBetweenCalls() + { + var token1 = JwtTokenService.GenerateRefreshToken(); + var token2 = JwtTokenService.GenerateRefreshToken(); + + Assert.NotEqual(token1, token2); + } + + // ── HashToken ───────────────────────────────────────────────────── + + [Fact] + public void HashToken_DeterministicForSameInput() + { + var hash1 = JwtTokenService.HashToken("test-token"); + var hash2 = JwtTokenService.HashToken("test-token"); + + Assert.Equal(hash1, hash2); + } + + [Fact] + public void HashToken_DifferentForDifferentInput() + { + var hash1 = JwtTokenService.HashToken("token-a"); + var hash2 = JwtTokenService.HashToken("token-b"); + + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public void HashToken_ReturnsBase64String() + { + var hash = JwtTokenService.HashToken("test-token"); + + // SHA256 → 32 bytes → 44 base64 characters + var bytes = Convert.FromBase64String(hash); + Assert.Equal(32, bytes.Length); + } +} diff --git a/src/EchoHub.Tests/LinkEmbedServiceTests.cs b/src/EchoHub.Tests/LinkEmbedServiceTests.cs new file mode 100644 index 0000000..735d67a --- /dev/null +++ b/src/EchoHub.Tests/LinkEmbedServiceTests.cs @@ -0,0 +1,192 @@ +using System.Reflection; +using EchoHub.Server.Services; +using Xunit; + +namespace EchoHub.Tests; + +public class LinkEmbedServiceTests +{ + private static readonly MethodInfo ExtractUrlsMethod = typeof(LinkEmbedService) + .GetMethod("ExtractUrls", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo IsPrivateHostMethod = typeof(LinkEmbedService) + .GetMethod("IsPrivateHost", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo ParseOgTagsMethod = typeof(LinkEmbedService) + .GetMethod("ParseOgTags", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static List ExtractUrls(string content) => + (List)ExtractUrlsMethod.Invoke(null, [content])!; + + private static bool IsPrivateHost(Uri uri) => + (bool)IsPrivateHostMethod.Invoke(null, [uri])!; + + private static Dictionary ParseOgTags(string html) => + (Dictionary)ParseOgTagsMethod.Invoke(null, [html])!; + + // ── ExtractUrls ─────────────────────────────────────────────────── + + [Fact] + public void ExtractUrls_SingleUrl_ReturnsIt() + { + var urls = ExtractUrls("Check this: https://example.com"); + Assert.Single(urls); + Assert.Equal("https://example.com", urls[0]); + } + + [Fact] + public void ExtractUrls_MultipleUrls_ReturnsAll() + { + var urls = ExtractUrls("See https://a.com and https://b.com"); + Assert.Equal(2, urls.Count); + Assert.Contains("https://a.com", urls); + Assert.Contains("https://b.com", urls); + } + + [Fact] + public void ExtractUrls_UrlWithTrailingPunctuation_Trimmed() + { + var urls = ExtractUrls("Visit https://example.com."); + Assert.Single(urls); + Assert.Equal("https://example.com", urls[0]); + } + + [Fact] + public void ExtractUrls_NoUrls_ReturnsEmpty() + { + var urls = ExtractUrls("No links here"); + Assert.Empty(urls); + } + + [Fact] + public void ExtractUrls_MaxUrlsLimit_Respected() + { + // EmbedMaxUrlsPerMessage = 3 + var text = "https://a.com https://b.com https://c.com https://d.com https://e.com"; + var urls = ExtractUrls(text); + Assert.Equal(3, urls.Count); + } + + [Fact] + public void ExtractUrls_DuplicateUrls_Deduped() + { + var urls = ExtractUrls("https://example.com and https://example.com again"); + Assert.Single(urls); + } + + [Fact] + public void ExtractUrls_HttpUrl_Extracted() + { + var urls = ExtractUrls("http://example.com"); + Assert.Single(urls); + Assert.StartsWith("http://", urls[0]); + } + + // ── IsPrivateHost ───────────────────────────────────────────────── + + [Fact] + public void IsPrivateHost_Localhost_ReturnsTrue() + { + Assert.True(IsPrivateHost(new Uri("http://localhost/test"))); + } + + [Fact] + public void IsPrivateHost_LoopbackIP_ReturnsTrue() + { + Assert.True(IsPrivateHost(new Uri("http://127.0.0.1/test"))); + } + + [Fact] + public void IsPrivateHost_10Network_ReturnsTrue() + { + Assert.True(IsPrivateHost(new Uri("http://10.0.0.1/test"))); + } + + [Fact] + public void IsPrivateHost_172_16Network_ReturnsTrue() + { + Assert.True(IsPrivateHost(new Uri("http://172.16.0.1/test"))); + } + + [Fact] + public void IsPrivateHost_192_168Network_ReturnsTrue() + { + Assert.True(IsPrivateHost(new Uri("http://192.168.1.1/test"))); + } + + [Fact] + public void IsPrivateHost_PublicIP_ReturnsFalse() + { + Assert.False(IsPrivateHost(new Uri("http://8.8.8.8/test"))); + } + + [Fact] + public void IsPrivateHost_PublicDomain_ReturnsFalse() + { + Assert.False(IsPrivateHost(new Uri("https://example.com/test"))); + } + + [Fact] + public void IsPrivateHost_ZeroIP_ReturnsTrue() + { + Assert.True(IsPrivateHost(new Uri("http://0.0.0.0/test"))); + } + + // ── ParseOgTags ─────────────────────────────────────────────────── + + [Fact] + public void ParseOgTags_StandardOgTags_ParsedCorrectly() + { + var html = """ + + + + + + """; + var tags = ParseOgTags(html); + + Assert.Equal("Test Title", tags["title"]); + Assert.Equal("A description", tags["description"]); + Assert.Equal("TestSite", tags["site_name"]); + } + + [Fact] + public void ParseOgTags_ReversedOrder_ParsedCorrectly() + { + var html = """"""; + var tags = ParseOgTags(html); + + Assert.Equal("Reversed Title", tags["title"]); + } + + [Fact] + public void ParseOgTags_NoOgTags_ReturnsEmptyDictionary() + { + var html = "Page"; + var tags = ParseOgTags(html); + + Assert.Empty(tags); + } + + [Fact] + public void ParseOgTags_SingleQuotes_ParsedCorrectly() + { + var html = """"""; + var tags = ParseOgTags(html); + + Assert.Equal("Single Quoted", tags["title"]); + } + + [Fact] + public void ParseOgTags_DuplicateKeys_FirstWins() + { + var html = """ + + + """; + var tags = ParseOgTags(html); + + Assert.Equal("First", tags["title"]); + } +} diff --git a/src/Terminal.Gui b/src/Terminal.Gui new file mode 160000 index 0000000..0061d03 --- /dev/null +++ b/src/Terminal.Gui @@ -0,0 +1 @@ +Subproject commit 0061d03558264e0005d8c5ba53845e629ea2f8da