40 Commits
Author SHA1 Message Date
HueByte bc8e2fd7da feat: add attachment file size support for messages and update upload limits 2026-02-21 20:37:13 +01:00
HueByte 35a162d013 refactor: streamline audio service stop logic in AudioPlayerDialog 2026-02-21 20:36:57 +01:00
HueByte de34fb43b9 feat: enhance audio playback features and UI, including audio player dialog and improved file size handling 2026-02-21 19:50:09 +01:00
Hue 3e03a77aeb Merge pull request #17 from HueByte/dev_api_update
feat: add ApiResponse and ApiResponse<T> records for improved API res…
2026-02-21 18:08:09 +01:00
Hue 5ef338bbf7 Merge branch 'dev' into dev_api_update 2026-02-21 18:01:25 +01:00
HueByte f96f70f09a feat: add ApiResponse and ApiResponse<T> records for improved API response handling 2026-02-21 18:00:10 +01:00
Hue b7d58dc10d Merge pull request #16 from HueByte/dev_changelog_docs
Dev changelog docs
2026-02-21 17:59:04 +01:00
HueByte 7fadd08702 docs: update API documentation and changelog for v0.2.5 release 2026-02-21 17:55:08 +01:00
Hue 576dd4bc94 Merge pull request #14 from HueByte/dev_session_persistence
Dev session persistence
2026-02-21 17:31:19 +01:00
HueByte d4cf35e04e feat: implement session persistence with "Remember Me" functionality and logout support 2026-02-21 17:29:16 +01:00
Hue 23abb9b0bb Merge pull request #13 from HueByte/dev_channel_logic_update
feat: extract channel management to IChannelService and implement CRU…
2026-02-21 17:28:53 +01:00
HueByte dcb8bbc05f fix: exclude Terminal.Gui from formatting checks in CI workflow 2026-02-21 17:26:30 +01:00
HueByte 4930aee615 chore: update CI workflows to temporarily remove Terminal.Gui submodule's nuget.config to fix restore issues 2026-02-21 17:23:14 +01:00
HueByte 2cd3bbb570 chore: remove submodule NuGet config from CI workflows 2026-02-21 17:22:39 +01:00
HueByte 0d959e317e feat: extract channel management to IChannelService and implement CRUD operations 2026-02-21 17:16:41 +01:00
HueByte 75491d65e2 feat: add nuget.config for package source management and update CI workflows to restore dependencies correctly 2026-02-21 17:16:05 +01:00
Hue 1861fd00f9 Merge pull request #12 from Stone-Red-Code/dev
feat: installer & auto update system
2026-02-21 17:12:47 +01:00
Stone_Red 9996a6be97 chore: Add icon files and set application icon for installer and projects 2026-02-21 17:11:47 +01:00
Stone_Red cf9c647c03 feat: add automatic update checking with confirmation and progress dialogs 2026-02-21 17:11:47 +01:00
Stone_Red 1fa4348700 feat: add EchoHub installer Inno Setup script 2026-02-21 17:11:47 +01:00
HueByte 0c7c620f24 feat: enable recursive submodule fetching in CI workflows 2026-02-21 17:09:04 +01:00
HueByte 56a962c1af feat: add hue icon SVG asset for improved UI representation 2026-02-21 16:17:39 +01:00
HueByte a65b41fb7f feat: enhance channel management and status bar functionality 2026-02-21 16:02:49 +01:00
HueByte ab9e09a609 feat: add support for ignoring diff files in .gitignore 2026-02-21 15:44:13 +01:00
HueByte 1bdc6ec99e feat: update chat rendering to support transparent backgrounds 2026-02-21 13:59:32 +01:00
HueByte fb12848e9a feat: add true transparent background support via Terminal.Gui fork
Add Terminal.Gui fork as submodule with transparent color support
(Color.Transparent emitting CSI 39m/49m default bg/fg sequences).
Switch EchoHub.Client from NuGet to project reference.
Update Transparent theme to use real terminal transparency.
2026-02-21 12:53:46 +01:00
HueByte 7e78c57665 chore: update changelog for v0.2.5 release with new features and fixes 2026-02-21 11:31:55 +01:00
Hue 9d4979d4a7 Merge pull request #10 from HueByte/dev_irc_abstraction_fixes
Dev irc abstraction fixes
2026-02-21 11:26:34 +01:00
HueByte c0d38015ff Add comprehensive unit tests for IRC command handling and message formatting
- Implemented IrcCommandHandlerTests to cover various IRC commands including PING, JOIN, PART, and authentication scenarios.
- Added IrcMessageFormatterTests to validate message formatting for different message types such as text, images, files, and audio.
- Created IrcMessageTests to ensure correct parsing of IRC messages and handling of various command formats.
- Introduced TestHelpers to facilitate testing with mock connections and streams, including a FakeChatService and FakeEncryptionService for simulating chat behavior.
2026-02-21 11:25:34 +01:00
Hue 3bc1446d1c Merge pull request #9 from HueByte/dev_more_tests
Dev more tests
2026-02-21 11:18:07 +01:00
HueByte 411042c397 fix: correct formatting in JwtTokenServiceTests for improved readability 2026-02-20 23:02:51 +01:00
HueByte 50817ce6e9 Add unit tests for various services and functionalities
- Implement tests for ChatLine utility methods including color tag detection and stripping.
- Create comprehensive tests for CommandHandler to validate command handling and status updates.
- Add tests for DataMigrationService to ensure correct ANSI to color tag conversion.
- Enhance FileValidationHelperTests with audio file validation tests.
- Introduce ImageToAsciiServiceTests to verify image dimension retrieval.
- Develop IrcMessageFormatterTests for message formatting and color tag conversion.
- Add JwtTokenServiceTests to validate JWT token generation and hashing.
- Implement LinkEmbedServiceTests for URL extraction and Open Graph tag parsing.
- Update EchoHub.Tests.csproj to include necessary project references.
2026-02-20 23:02:51 +01:00
Hue 9265a9f312 Merge pull request #8 from HueByte/dev_send_rework
feat: add audio playback and file download features with correspondin…
2026-02-20 23:01:03 +01:00
HueByte a860a8fbc3 feat: add audio playback and file download features with corresponding UI updates 2026-02-20 20:47:28 +01:00
Hue ae06e7ae12 Merge pull request #7 from HueByte/dev_e2e_encrypt
feat: Implement message encryption and decryption support
2026-02-20 17:33:28 +01:00
HueByte f49148a85a fix: format table in encryption documentation for better readability 2026-02-20 17:29:52 +01:00
HueByte 575115cdca fix: update table of contents to include specific file paths for articles 2026-02-20 17:24:34 +01:00
HueByte ca9973b411 feat: Update table of contents for documentation and changelog with new sections 2026-02-20 17:20:49 +01:00
HueByte 4c850fc3c9 feat: Update encryption documentation with mermaid sequence diagram for clarity 2026-02-20 17:19:18 +01:00
HueByte 2efe54e417 feat: Implement message encryption and decryption support
- Added IMessageEncryptionService and its implementation MessageEncryptionService for handling message encryption.
- Updated ChannelsController and ChatService to encrypt messages before storing and sending.
- Introduced encryption key retrieval endpoint in ServerController.
- Modified EchoHubDbContext to accommodate increased message content and embed JSON lengths for encrypted data.
- Created migrations to support encryption-related database changes.
- Enhanced FirstRunSetup to ensure encryption key is generated if not present.
- Updated appsettings.example.json to include encryption configuration.
- Added comprehensive unit tests for encryption service and compatibility tests between client and server encryption.
2026-02-20 17:16:32 +01:00
94 changed files with 7040 additions and 341 deletions
+12 -1
View File
@@ -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
+6
View File
@@ -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
+5
View File
@@ -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
+3
View File
@@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/*
# DocFx generated output
docs/_site/
docs/_api_meta/
# Diff files
*.diff
+3
View File
@@ -0,0 +1,3 @@
[submodule "src/Terminal.Gui"]
path = src/Terminal.Gui
url = https://github.com/HueByte/Terminal.Gui.git
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="32" height="32" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg">
<g stroke="#E6C06E" stroke-width="28" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="500" cy="500" r="420" />
<circle cx="500" cy="500" r="180" />
<polygon points="500,440 550,532 450,532" fill="#E6C06E" />
<line x1="500" y1="80" x2="500" y2="320" />
<circle cx="285" cy="390" r="8" fill="#E6C06E" />
<circle cx="715" cy="390" r="8" fill="#E6C06E" />
<circle cx="500" cy="750" r="8" fill="#E6C06E" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 590 B

+4 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+3 -2
View File
@@ -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
+3 -3
View File
@@ -3,19 +3,19 @@
- name: API Reference
href: ../_api_meta/client/toc.yml
- name: Articles
href: client-articles/
href: client-articles/toc.yml
- name: Core
items:
- name: API Reference
href: ../_api_meta/core/toc.yml
- name: Articles
href: core-articles/
href: core-articles/toc.yml
- name: Server
items:
- name: API Reference
href: ../_api_meta/server/toc.yml
- name: Articles
href: server-articles/
href: server-articles/toc.yml
- name: Server.Irc
items:
- name: API Reference
+5 -5
View File
@@ -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
+141
View File
@@ -0,0 +1,141 @@
# Message Encryption
EchoHub uses application-layer AES-256-GCM encryption to protect message content in transit between clients and the server. This is an additional layer on top of TLS, protecting against ISPs, proxies, and any middleman that can inspect HTTPS traffic (e.g. corporate proxies with trusted root CA certificates).
## How It Works
```mermaid
sequenceDiagram
participant Sender as TUI Client (Sender)
participant Server
participant Receiver as TUI Client (Receiver)
participant IRC as IRC Client
Sender->>Sender: encrypt(plaintext)
Sender->>Server: $ENC$v1$... (SignalR)
Server->>Server: decrypt → validate/sanitize
Server->>Server: fetch embeds on plaintext
Server->>Server: (optional) encrypt for DB storage
Server->>Server: encrypt(plaintext) with fresh nonce
Server->>Receiver: $ENC$v1$... (SignalR broadcast)
Receiver->>Receiver: decrypt → display
Server->>Server: decrypt for IRC
Server->>IRC: plaintext (IRC PRIVMSG)
```
1. **Client encrypts** the message before sending it over SignalR
2. **Server decrypts** to validate content, sanitize newlines, and fetch link embeds
3. **Server re-encrypts** with a fresh nonce and broadcasts to all connected SignalR clients
4. **Clients decrypt** the broadcast and display the plaintext
5. **IRC clients** receive plaintext automatically (the IRC broadcaster decrypts before forwarding)
Each encryption uses a random 12-byte nonce, so the same message produces different ciphertext every time.
## Encryption Key
A 256-bit AES key is auto-generated on first server startup and saved to `appsettings.json`:
```json
{
"Encryption": {
"Key": "base64-encoded-32-byte-key",
"EncryptDatabase": false
}
}
```
The key is generated by `FirstRunSetup` using `RandomNumberGenerator.GetBytes(32)`. If the key is missing or empty when the server starts, a new one is created automatically.
Clients fetch the key after login via an authenticated endpoint (`GET /api/server/encryption-key`). No manual configuration is needed on the client side.
## Encrypted Content Format
Encrypted content uses a self-describing format:
```text
$ENC$v1${nonce_base64}${ciphertext+tag_base64}
```
- **`$ENC$v1$`** — version prefix (allows future algorithm changes)
- **Nonce** — 12 bytes, Base64-encoded, randomly generated per message
- **Ciphertext + Tag** — AES-256-GCM output with 16-byte authentication tag appended
The authentication tag ensures both confidentiality and integrity — any tampering with the ciphertext is detected during decryption.
## Database Encryption (Optional)
By default, messages are stored as **plaintext** in the database. Transport encryption still protects everything on the wire, but the SQLite file itself contains readable messages.
To encrypt messages at rest, enable the setting:
```json
{
"Encryption": {
"Key": "...",
"EncryptDatabase": true
}
}
```
### Behavior by Setting
| Setting | DB Storage | Transport | Key Rotation Risk |
| ----------------- | ---------- | --------- | ---------------------------------------------- |
| `false` (default) | Plaintext | Encrypted | None - stored data is unaffected |
| `true` | Encrypted | Encrypted | Changing the key makes old messages unreadable |
### Mixed Content
The server handles mixed encrypted/plaintext content in the database gracefully. When reading messages:
- Content starting with `$ENC$v1$` is decrypted
- Everything else is treated as plaintext
This means you can safely toggle `EncryptDatabase` at any time. Old messages remain readable regardless of the current setting.
### Enabling Encryption at Rest
When you set `EncryptDatabase: true`, only **new messages** are encrypted going forward. Existing plaintext messages in the database are not retroactively encrypted. This is intentional — it keeps key rotation safe and avoids irreversible bulk changes.
### Key Rotation
Changing the encryption key is safe:
- **Plaintext messages** — always readable regardless of key
- **Messages encrypted with the old key** — will show `[encrypted message — decryption failed]`
- **New messages** — encrypted with the new key going forward
If you need to recover old encrypted messages, restore the original key from a backup of `appsettings.json`. Since plaintext messages are never retroactively encrypted, you'll never lose access to your entire history from a key change.
## Security Considerations
### What This Protects Against
- **Passive network sniffing** — messages are encrypted even if captured off the wire
- **ISP/proxy inspection** — content is encrypted at the application layer, independent of TLS
- **Database theft** (when `EncryptDatabase: true`) — SQLite file contains only ciphertext
### Limitations
- **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this.
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server.
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
## Troubleshooting
### Messages show `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
The client's encryption key doesn't match the server's. This happens when:
- The server's encryption key was rotated while the client was connected
- The client cached a stale key
**Fix**: Disconnect and reconnect (re-login). The client fetches the current key on each login.
### Messages show `[encrypted message — decryption failed]` in channel history
The server cannot decrypt messages stored in the database. This happens when:
- `EncryptDatabase` was enabled, and the key was changed afterwards
**Fix**: Restore the original key from a backup. There is no way to recover messages encrypted with a lost key.
+4
View File
@@ -2,3 +2,7 @@
href: getting-started.md
- name: Architecture
href: architecture.md
- name: Encryption
href: encryption.md
- name: Notification Sounds
href: notification-sounds.md
+2
View File
@@ -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
+4
View File
@@ -1,5 +1,9 @@
- 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
href: v0.2.3.md
- name: v0.2.2
+32
View File
@@ -0,0 +1,32 @@
# v0.2.4 - E2E Message Encryption
## Features
### E2E Message Encryption
- Application-layer AES-256-GCM encryption for all message content between client and server
- Protects against ISPs, proxies, and any middleman reading chat messages — even if TLS is compromised
- 256-bit encryption key auto-generated on first server startup and saved to `appsettings.json`
- Client fetches the encryption key automatically after login via `GET /api/server/encryption-key`
- Client encrypts messages before sending via SignalR; server decrypts for validation and processing
- Server broadcasts encrypted content to SignalR clients; client decrypts transparently — no user action required
- Optional database encryption at rest via `Encryption:EncryptDatabase` setting (disabled by default)
- When enabled: new messages encrypted before DB storage (existing plaintext messages are not retroactively encrypted)
- When disabled: messages stored as plaintext, no risk of data loss from key rotation
- Reads handle mixed content (encrypted + plaintext) regardless of setting — safe to toggle at any time
- Key rotation is safe: old plaintext stays readable, new messages use the new key
- IRC gateway automatically decrypts messages before forwarding to IRC clients (plaintext over IRC)
- Encrypted content format: `$ENC$v1${nonce}${ciphertext+tag}` (Base64, 12-byte nonce, 16-byte auth tag)
- Server strips `$ENC$` prefix from user-typed messages to prevent format spoofing
- Graceful fallback: if decryption fails, shows `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
## Infrastructure
- `IMessageEncryptionService` interface in Core; `MessageEncryptionService` server implementation and `ClientEncryptionService` client implementation
- `EncryptionKeyResponse` DTO and `GET /api/server/encryption-key` endpoint (authenticated, rate-limited)
- `FirstRunSetup.EnsureEncryptionKey()` auto-generates AES-256 key on first server run
- `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
- Documentation article: `docs/articles/encryption.md`
+119
View File
@@ -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.Transparent` (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<bool>` (`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
Binary file not shown.
+52
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.2.3</Version>
<Version>0.2.5</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
+173 -19
View File
@@ -22,9 +22,12 @@ 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;
private readonly ClientEncryptionService _encryption = new();
private ClientConfig _config;
private UserStatus _currentStatus = UserStatus.Online;
private string? _currentStatusMessage;
@@ -43,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");
}
@@ -54,6 +60,7 @@ public sealed class AppOrchestrator : IDisposable
{
_connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_apiClient?.Dispose();
_updateService.Dispose();
}
// ── Convenience Helpers ────────────────────────────────────────────────
@@ -71,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;
@@ -79,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 ─────────────────────────────────────────────
@@ -234,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);
@@ -363,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;
@@ -376,12 +402,68 @@ 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
{
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
_encryption.SetKey(encryptionKey);
Log.Information("E2E encryption key established");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
}
InvokeUI(() =>
{
_mainWindow.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username);
@@ -391,7 +473,7 @@ public sealed class AppOrchestrator : IDisposable
if (_connection is not null)
await _connection.DisposeAsync();
_connection = new EchoHubConnection(result.ServerUrl, _apiClient);
_connection = new EchoHubConnection(result.ServerUrl, _apiClient, _encryption);
WireConnectionEvents(_connection);
await _connection.ConnectAsync();
@@ -423,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");
}
@@ -463,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)
@@ -708,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");
@@ -784,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)
@@ -883,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);
});
};
@@ -943,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;
}
}
}
@@ -339,7 +339,7 @@ public class CommandHandler
/nick <name> - Set display name
/color <#hex> - Set nickname color
/theme <name> - Switch theme
/send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large)
/send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
/avatar <URL or filepath> - Set your avatar
/profile [username] - View a profile
/servers - Open saved servers
+2 -1
View File
@@ -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; }
}
+6 -1
View File
@@ -5,19 +5,22 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AlwaysUpToDate" Version="2.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" />
<!-- Using local fork until transparent color support is merged upstream (gui-cs/Terminal.Gui#4234) -->
<ProjectReference Include="..\Terminal.Gui\Terminal.Gui\Terminal.Gui.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="hue_icon.ico" />
<Content Include="Assets\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@@ -31,6 +34,8 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageIcon></PackageIcon>
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
</PropertyGroup>
</Project>
+43
View File
@@ -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<LoginResponse> 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<LoginResponse>()
?? throw new InvalidOperationException("Token refresh returned empty response.");
SetTokens(result);
return result;
}
public async Task LogoutAsync()
{
if (!string.IsNullOrEmpty(_refreshToken))
@@ -128,6 +143,16 @@ public sealed class ApiClient : IDisposable
return info;
}
public async Task<string> GetEncryptionKeyAsync()
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
?? throw new InvalidOperationException("Server returned empty encryption key response.");
return result.Key;
}
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
EnsureAuthenticated();
@@ -186,6 +211,23 @@ public sealed class ApiClient : IDisposable
return await response.Content.ReadFromJsonAsync<MessageDto>();
}
public async Task<string> 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<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
{
EnsureAuthenticated();
@@ -286,6 +328,7 @@ public sealed class ApiClient : IDisposable
_refreshToken = result.RefreshToken;
_expiresAt = result.ExpiresAt;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
OnTokensRefreshed?.Invoke();
}
/// <summary>
@@ -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");
}
}
}
@@ -0,0 +1,95 @@
using System.Security.Cryptography;
using System.Text;
using EchoHub.Core.Contracts;
namespace EchoHub.Client.Services;
/// <summary>
/// Client-side encryption service. Uses the same AES-256-GCM format as the server
/// so messages are encrypted end-to-end between client and server.
/// </summary>
public sealed class ClientEncryptionService : IMessageEncryptionService
{
private const string EncryptionPrefix = "$ENC$v1$";
private const int NonceSizeBytes = 12;
private const int TagSizeBytes = 16;
private byte[]? _key;
public bool IsInitialized => _key is not null;
public bool EncryptDatabaseEnabled => false; // Not relevant for client
/// <summary>
/// Initialize with the server's encryption key (fetched after login).
/// </summary>
public void SetKey(string base64Key)
{
_key = Convert.FromBase64String(base64Key);
if (_key.Length != 32)
throw new InvalidOperationException($"Encryption key must be exactly 32 bytes (256-bit). Got {_key.Length} bytes.");
}
public string Encrypt(string plaintext)
{
if (_key is null)
return plaintext; // Not initialized — pass through
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
var ciphertext = new byte[plaintextBytes.Length];
var tag = new byte[TagSizeBytes];
using var aes = new AesGcm(_key, TagSizeBytes);
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);
var combined = new byte[ciphertext.Length + tag.Length];
Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length);
Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length);
return $"{EncryptionPrefix}{Convert.ToBase64String(nonce)}${Convert.ToBase64String(combined)}";
}
public string Decrypt(string content)
{
if (_key is null || !content.StartsWith(EncryptionPrefix))
return content; // Not initialized or legacy plaintext
try
{
var payload = content[EncryptionPrefix.Length..];
var separatorIndex = payload.IndexOf('$');
if (separatorIndex < 0)
return content;
var nonceBase64 = payload[..separatorIndex];
var combinedBase64 = payload[(separatorIndex + 1)..];
var nonce = Convert.FromBase64String(nonceBase64);
var combined = Convert.FromBase64String(combinedBase64);
if (combined.Length < TagSizeBytes)
return content;
var ciphertextLength = combined.Length - TagSizeBytes;
var ciphertext = combined.AsSpan(0, ciphertextLength);
var tag = combined.AsSpan(ciphertextLength, TagSizeBytes);
var plaintext = new byte[ciphertextLength];
using var aes = new AesGcm(_key, TagSizeBytes);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return Encoding.UTF8.GetString(plaintext);
}
catch
{
return "[encrypted message — decryption failed, try re-logging to fetch the latest key]";
}
}
public string? EncryptNullable(string? value)
=> value is null ? null : Encrypt(value);
public string? DecryptNullable(string? value)
=> value is null ? null : Decrypt(value);
}
@@ -8,6 +8,7 @@ namespace EchoHub.Client.Services;
public sealed class EchoHubConnection : IAsyncDisposable
{
private readonly HubConnection _connection;
private readonly ClientEncryptionService _encryption;
public event Action<MessageDto>? OnMessageReceived;
public event Action<string, string>? OnUserJoined;
@@ -25,8 +26,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
public bool IsConnected => _connection.State == HubConnectionState.Connected;
public EchoHubConnection(string serverUrl, ApiClient apiClient)
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
{
_encryption = encryption;
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
_connection = new HubConnectionBuilder()
@@ -63,7 +65,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
{
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
{
OnMessageReceived?.Invoke(message);
// Decrypt message content received from server
var decrypted = message with { Content = _encryption.Decrypt(message.Content) };
OnMessageReceived?.Invoke(decrypted);
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
@@ -132,7 +136,8 @@ public sealed class EchoHubConnection : IAsyncDisposable
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
{
return await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
var messages = await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
return DecryptMessages(messages);
}
public async Task LeaveChannelAsync(string channelName)
@@ -142,12 +147,15 @@ public sealed class EchoHubConnection : IAsyncDisposable
public async Task SendMessageAsync(string channelName, string content)
{
await _connection.InvokeAsync("SendMessage", channelName, content);
// Encrypt content before sending to server
var encrypted = _encryption.Encrypt(content);
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
}
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
{
return await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
return DecryptMessages(messages);
}
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
@@ -160,6 +168,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
}
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
{
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
}
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
+94 -35
View File
@@ -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";
/// <summary>
/// Checks GitHub for a newer release. Returns the new version string if one exists, or null.
/// Never throws — all errors are silently swallowed.
/// </summary>
public static async Task<string?> 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<GitHubRelease>(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;
}
}
+7 -7
View File
@@ -418,30 +418,30 @@ public static class ThemeManager
Base = new ThemeColors
{
Foreground = "White",
Background = "Black",
Background = "Transparent",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
FocusBackground = "Transparent"
},
Menu = new ThemeColors
{
Foreground = "White",
Background = "Black",
Background = "Transparent",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
FocusBackground = "Transparent"
},
Dialog = new ThemeColors
{
Foreground = "White",
Background = "Black",
Background = "DarkGray",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
},
Status = new ThemeColors
{
Foreground = "Gray",
Background = "Black",
Background = "Transparent",
FocusForeground = "Gray",
FocusBackground = "Black"
FocusBackground = "Transparent"
}
};
+410
View File
@@ -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.Transparent);
private static readonly Attribute WaveIdleAttr = new(new Color(80, 50, 120), Color.Transparent);
private static readonly Attribute FileNameAttr = new(new Color(180, 100, 255), Color.Transparent);
private static readonly Attribute StatusPlayingAttr = new(new Color(0, 200, 0), Color.Transparent);
private static readonly Attribute StatusPausedAttr = new(new Color(220, 180, 0), Color.Transparent);
private static readonly Attribute StatusStoppedAttr = new(new Color(160, 160, 160), Color.Transparent);
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.Transparent
? 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.Transparent
? 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.Transparent
? 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;
}
}
+42 -18
View File
@@ -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.Transparent;
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.Transparent)
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.Transparent);
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Transparent);
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Transparent);
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Transparent);
public void Update(List<string> channels, Dictionary<string, int> 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.Transparent ? 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
/// </summary>
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.Transparent);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Transparent);
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.Transparent);
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Transparent);
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Transparent);
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Transparent);
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Transparent);
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.Transparent);
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.Transparent);
/// <summary>
/// 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.Transparent);
}
catch
{
+78 -16
View File
@@ -9,7 +9,9 @@ namespace EchoHub.Client.UI;
/// <summary>
/// Result returned from the connect dialog.
/// </summary>
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);
/// <summary>
/// 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();
+172 -8
View File
@@ -58,9 +58,11 @@ public sealed class MainWindow : Runnable
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = [];
private readonly Dictionary<string, string?> _channelTopics = [];
private readonly Dictionary<string, bool> _channelPublic = [];
private readonly ChannelListSource _channelListSource;
private string _currentChannel = string.Empty;
private string _currentUser = string.Empty;
private string _connectionStatus = "Disconnected";
private int _lastChatWidth;
/// <summary>
@@ -83,6 +85,11 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action? OnDisconnectRequested;
/// <summary>
/// Fired when the user requests to logout (disconnect + revoke session).
/// </summary>
public event Action? OnLogoutRequested;
/// <summary>
/// Fired when the user requests to open their profile panel.
/// </summary>
@@ -113,6 +120,16 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action? OnDeleteChannelRequested;
/// <summary>
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
/// </summary>
public event Action<string, string>? OnAudioPlayRequested;
/// <summary>
/// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName.
/// </summary>
public event Action<string, string>? 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
/// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary>
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
/// </summary>
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.Transparent);
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.Transparent);
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.Transparent);
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.Transparent);
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 transparent background for colored segments
var bg = normalAttr.Background;
Attribute Resolve(Attribute a) => a.Background == Color.Transparent ? 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;
}
/// <summary>
@@ -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);
}
/// <summary>
/// Build a chat line with a colored suffix (used for audio/file indicators).
/// </summary>
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
new(suffix, suffixColor)
};
return new ChatLine(segments);
}
/// <summary>
/// Build a chat line with @mention highlighting in the suffix text.
/// </summary>
@@ -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"
};
}
}
@@ -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;
}
}
@@ -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();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+3 -1
View File
@@ -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;
@@ -0,0 +1,22 @@
using EchoHub.Core.DTOs;
namespace EchoHub.Core.Contracts;
public interface IChannelService
{
// Channel CRUD
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic);
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
// Channel queries
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
// Membership
Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName);
}
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
+1 -5
View File
@@ -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<UserProfileDto?> GetUserProfileAsync(string username);
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<List<string>> GetChannelsForUserAsync(string username);
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
}
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
@@ -0,0 +1,14 @@
namespace EchoHub.Core.Contracts;
public interface IMessageEncryptionService
{
/// <summary>
/// Whether database content should be encrypted at rest (server setting).
/// </summary>
bool EncryptDatabaseEnabled { get; }
string Encrypt(string plaintext);
string Decrypt(string content);
string? EncryptNullable(string? value);
string? DecryptNullable(string? value);
}
+1
View File
@@ -12,6 +12,7 @@ public record MessageDto(
string? AttachmentUrl,
string? AttachmentFileName,
DateTimeOffset SentAt,
long? AttachmentFileSize = null,
List<EmbedDto>? Embeds = null);
public record ChannelDto(
+21
View File
@@ -1,5 +1,26 @@
namespace EchoHub.Core.DTOs;
public record ApiResponse(bool Success, string? Message = null, List<string>? Errors = null);
public record ApiResponse<T>(bool Success, string? Message = null, List<string>? Errors = null, T? Data = default);
public record ErrorResponse(string Error, string? Detail = null);
public record PaginatedResponse<T>(List<T> 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);
}
+2
View File
@@ -1,3 +1,5 @@
namespace EchoHub.Core.DTOs;
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
public record EncryptionKeyResponse(string Key);
+1
View File
@@ -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;
+2 -1
View File
@@ -4,5 +4,6 @@ public enum MessageType
{
Text,
Image,
File
File,
Audio
}
+6 -2
View File
@@ -6,15 +6,19 @@ namespace EchoHub.Server.Irc;
public class IrcBroadcaster : IChatBroadcaster
{
private readonly IrcGatewayService _gateway;
private readonly IMessageEncryptionService _encryption;
public IrcBroadcaster(IrcGatewayService gateway)
public IrcBroadcaster(IrcGatewayService gateway, IMessageEncryptionService encryption)
{
_gateway = gateway;
_encryption = encryption;
}
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
{
var lines = IrcMessageFormatter.FormatMessage(message);
// Decrypt content for IRC clients (they can't handle app-layer encryption)
var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) };
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
@@ -27,14 +27,20 @@ public sealed class IrcClientConnection : IAsyncDisposable
public bool IsSasl { get; set; }
public bool CapNegotiating { get; set; }
// Channel state
public HashSet<string> JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase);
// Channel state — thread-safe: written by command handler, read by broadcaster threads
private readonly HashSet<string> _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<string> GetJoinedChannels() { lock (_channelLock) return [.. _joinedChannels]; }
public IrcClientConnection(TcpClient tcpClient, Stream stream)
{
_tcpClient = tcpClient;
+25 -18
View File
@@ -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<bool> 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;
}
+5 -3
View File
@@ -34,7 +34,7 @@ public sealed class IrcGatewayService : BackgroundService
public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName)
{
return _connections.Values
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
.Where(c => c.IsAuthenticated && c.IsInChannel(channelName));
}
public IEnumerable<IrcClientConnection> GetAllConnections()
@@ -120,8 +120,10 @@ public sealed class IrcGatewayService : BackgroundService
try
{
chatService = _services.GetRequiredService<IChatService>();
var channelService = _services.GetRequiredService<IChannelService>();
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
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(
@@ -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;
@@ -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,24 +17,30 @@ 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;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IChatService _chatService;
private readonly IMessageEncryptionService _encryption;
public ChannelsController(
IChannelService channelService,
EchoHubDbContext db,
FileStorageService fileStorage,
ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory,
IChatService chatService)
IChatService chatService,
IMessageEncryptionService encryption)
{
_channelService = channelService;
_db = db;
_fileStorage = fileStorage;
_asciiService = asciiService;
_httpClientFactory = httpClientFactory;
_chatService = chatService;
_encryption = encryption;
}
[HttpGet]
@@ -45,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<ChannelDto>(channels, total, offset, limit));
var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> 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")]
@@ -122,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}")]
@@ -151,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<IActionResult> Upload(string channel, [FromQuery] string? size = null)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -187,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)
@@ -196,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)
@@ -221,16 +163,18 @@ public class ChannelsController : ControllerBase
var attachmentUrl = $"/api/files/{fileId}";
var sender = await _db.Users.FindAsync(userId);
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Content = dbContent,
Type = messageType,
AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName,
AttachmentFileSize = file.Length,
SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id,
ChannelId = channelDto.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
@@ -238,16 +182,18 @@ public class ChannelsController : ControllerBase
_db.Messages.Add(message);
await _db.SaveChangesAsync();
// Encrypt for transport — clients decrypt
var messageDto = new MessageDto(
message.Id,
message.Content,
_encryption.Encrypt(content),
message.SenderUsername,
sender?.NicknameColor,
channelName,
messageType,
attachmentUrl,
file.FileName,
message.SentAt);
message.SentAt,
file.Length);
await _chatService.BroadcastMessageAsync(channelName, messageDto);
@@ -269,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))
@@ -290,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('.'))
@@ -339,16 +285,18 @@ public class ChannelsController : ControllerBase
var attachmentUrl = $"/api/files/{fileId}";
var sender = await _db.Users.FindAsync(userId);
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Content = dbContent,
Type = MessageType.Image,
AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName,
AttachmentFileSize = imageBytes.Length,
SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id,
ChannelId = channelDto.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
@@ -356,19 +304,31 @@ public class ChannelsController : ControllerBase
_db.Messages.Add(message);
await _db.SaveChangesAsync();
// Encrypt for transport — clients decrypt
var messageDto = new MessageDto(
message.Id,
message.Content,
_encryption.Encrypt(content),
message.SenderUsername,
sender?.NicknameColor,
channelName,
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.")),
};
}
@@ -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"
@@ -1,6 +1,8 @@
using EchoHub.Core.DTOs;
using EchoHub.Server.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
@@ -32,4 +34,17 @@ public class ServerController : ControllerBase
return Ok(status);
}
[HttpGet("encryption-key")]
[Authorize]
[EnableRateLimiting("auth")]
public IActionResult GetEncryptionKey()
{
var key = _config["Encryption:Key"];
if (string.IsNullOrEmpty(key))
return StatusCode(503, new ErrorResponse("Encryption is not configured on this server."));
return Ok(new EncryptionKeyResponse(key));
}
}
+2 -2
View File
@@ -55,11 +55,11 @@ public class EchoHubDbContext : DbContext
{
entity.HasKey(m => m.Id);
entity.HasIndex(m => m.SentAt);
entity.Property(m => m.Content).IsRequired().HasMaxLength(2000);
entity.Property(m => m.Content).IsRequired().HasMaxLength(16000); // Increased for encrypted content (Base64 overhead)
entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50);
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
entity.Property(m => m.EmbedJson).HasMaxLength(8000);
entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON
});
modelBuilder.Entity<ChannelMembership>(entity =>
@@ -0,0 +1,264 @@
// <auto-generated />
using System;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
[DbContext(typeof(EchoHubDbContext))]
[Migration("20260220133627_AddEncryptionSupport")]
partial class AddEncryptionSupport
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("JoinedAt")
.HasColumnType("INTEGER");
b.HasKey("UserId", "ChannelId");
b.HasIndex("ChannelId");
b.HasIndex("UserId");
b.ToTable("ChannelMemberships");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("EchoHub.Core.Models.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
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddEncryptionSupport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,267 @@
// <auto-generated />
using System;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
[DbContext(typeof(EchoHubDbContext))]
[Migration("20260221193444_AddAttachmentFileSize")]
partial class AddAttachmentFileSize
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("JoinedAt")
.HasColumnType("INTEGER");
b.HasKey("UserId", "ChannelId");
b.HasIndex("ChannelId");
b.HasIndex("UserId");
b.ToTable("ChannelMemberships");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("EchoHub.Core.Models.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
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddAttachmentFileSize : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "AttachmentFileSize",
table: "Messages",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AttachmentFileSize",
table: "Messages");
}
}
}
@@ -79,6 +79,9 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
@@ -88,11 +91,11 @@ namespace EchoHub.Server.Data.Migrations
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(2000)
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(8000)
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
+6
View File
@@ -1,5 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<Content Include="hue_icon.ico" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
@@ -22,6 +26,8 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PackageIcon></PackageIcon>
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
</PropertyGroup>
</Project>
+5
View File
@@ -108,9 +108,14 @@ while (true)
builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddSingleton<LinkEmbedService>();
builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHostedService<FileCleanupService>();
// ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
// ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IChannelService, ChannelService>();
builder.Services.AddSingleton<IChatService, ChatService>();
// ── IRC Gateway (optional) ──────────────────────────────────────────
@@ -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<ChannelService> _logger;
public ChannelService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
ILogger<ChannelService> logger)
{
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_logger = logger;
}
public async Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<ChannelDto>(channels, total, offset, limit);
}
public async Task<ChannelOperationResult> 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<EchoHubDbContext>();
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<ChannelOperationResult> 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<EchoHubDbContext>();
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<ChannelOperationResult> 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<EchoHubDbContext>();
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<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<ChannelDto?> GetChannelByNameAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<EchoHubDbContext>();
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();
}
}
}
+45 -63
View File
@@ -16,6 +16,8 @@ public class ChatService : IChatService
private readonly PresenceTracker _presenceTracker;
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly LinkEmbedService _embedService;
private readonly IMessageEncryptionService _encryption;
private readonly IChannelService _channelService;
private readonly ILogger<ChatService> _logger;
public ChatService(
@@ -23,12 +25,16 @@ public class ChatService : IChatService
PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters,
LinkEmbedService embedService,
IMessageEncryptionService encryption,
IChannelService channelService,
ILogger<ChatService> logger)
{
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_broadcasters = broadcasters;
_embedService = embedService;
_encryption = encryption;
_channelService = channelService;
_logger = logger;
}
@@ -92,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<EchoHubDbContext>();
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);
@@ -123,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);
}
@@ -142,14 +130,21 @@ public class ChatService : IChatService
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return "Invalid channel name.";
if (string.IsNullOrWhiteSpace(content))
// Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both)
var plaintext = _encryption.Decrypt(content);
// Strip encryption prefix if a user typed it literally (prevents spoofing)
while (plaintext.StartsWith("$ENC$"))
plaintext = plaintext["$ENC$".Length..];
if (string.IsNullOrWhiteSpace(plaintext))
return "Message content cannot be empty.";
if (content.Length > HubConstants.MaxMessageLength)
if (plaintext.Length > HubConstants.MaxMessageLength)
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
// Sanitize: collapse excessive newlines
content = SanitizeNewlines(content);
// Sanitize on plaintext: collapse excessive newlines
plaintext = SanitizeNewlines(plaintext);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
@@ -175,35 +170,42 @@ public class ChatService : IChatService
}
}
// Attempt to fetch link embeds for URLs in the message
// Attempt to fetch link embeds for URLs in the plaintext message
List<EmbedDto>? embeds = null;
try
{
embeds = await _embedService.TryGetEmbedsAsync(content);
embeds = await _embedService.TryGetEmbedsAsync(plaintext);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName);
}
// Store in DB — encrypted at rest if enabled, plaintext otherwise
var embedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null;
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(plaintext) : plaintext;
var dbEmbedJson = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(embedJson) : embedJson;
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Content = dbContent,
Type = MessageType.Text,
SentAt = DateTimeOffset.UtcNow,
ChannelId = channel.Id,
SenderUserId = userId,
SenderUsername = username,
EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null,
EmbedJson = dbEmbedJson,
};
db.Messages.Add(message);
await db.SaveChangesAsync();
// Broadcast encrypted for SignalR clients; IRC broadcaster gets plaintext
var encryptedContent = _encryption.Encrypt(plaintext);
var messageDto = new MessageDto(
message.Id,
message.Content,
encryptedContent,
message.SenderUsername,
sender?.NicknameColor,
channelName,
@@ -211,7 +213,7 @@ public class ChatService : IChatService
null,
null,
message.SentAt,
embeds);
Embeds: embeds);
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
@@ -318,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<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<List<string>> GetChannelsForUserAsync(string username)
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
@@ -398,7 +374,7 @@ public class ChatService : IChatService
return string.Join('\n', result);
}
private static async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
{
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
@@ -418,16 +394,21 @@ public class ChatService : IChatService
return raw.Select(x =>
{
// Decrypt DB content (handles both encrypted and plaintext via prefix detection)
var plaintext = _encryption.Decrypt(x.m.Content);
var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson);
List<EmbedDto>? embeds = null;
if (x.m.EmbedJson is not null)
if (embedJsonPlain is not null)
{
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(x.m.EmbedJson); }
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(embedJsonPlain); }
catch { /* ignore malformed JSON */ }
}
// Encrypt for transport — client decrypts
return new MessageDto(
x.m.Id,
x.m.Content,
_encryption.Encrypt(plaintext),
x.m.SenderUsername,
x.NicknameColor,
channelName,
@@ -435,6 +416,7 @@ public class ChatService : IChatService
x.m.AttachmentUrl,
x.m.AttachmentFileName,
x.m.SentAt,
x.m.AttachmentFileSize,
embeds);
}).ToList();
}
@@ -0,0 +1,69 @@
namespace EchoHub.Server.Services;
public sealed class FileCleanupService : BackgroundService
{
private readonly IConfiguration _configuration;
private readonly ILogger<FileCleanupService> _logger;
public FileCleanupService(IConfiguration configuration, ILogger<FileCleanupService> 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);
}
}
@@ -52,6 +52,20 @@ public static class FileValidationHelper
}
}
private static readonly HashSet<string> AudioExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma"
};
/// <summary>
/// Checks whether the file name has a recognized audio extension.
/// </summary>
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)
@@ -0,0 +1,102 @@
using System.Security.Cryptography;
using System.Text;
using EchoHub.Core.Contracts;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace EchoHub.Server.Services;
public class MessageEncryptionService : IMessageEncryptionService
{
private const string EncryptionPrefix = "$ENC$v1$";
private const int NonceSizeBytes = 12;
private const int TagSizeBytes = 16;
private readonly byte[] _key;
private readonly ILogger<MessageEncryptionService> _logger;
public bool EncryptDatabaseEnabled { get; }
public MessageEncryptionService(IConfiguration configuration, ILogger<MessageEncryptionService> logger)
{
_logger = logger;
var keyBase64 = configuration["Encryption:Key"]
?? throw new InvalidOperationException("Encryption:Key must be configured in appsettings.json.");
_key = Convert.FromBase64String(keyBase64);
if (_key.Length != 32)
throw new InvalidOperationException($"Encryption:Key must be exactly 32 bytes (256-bit). Got {_key.Length} bytes.");
EncryptDatabaseEnabled = configuration.GetValue<bool>("Encryption:EncryptDatabase");
}
public string Encrypt(string plaintext)
{
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
var ciphertext = new byte[plaintextBytes.Length];
var tag = new byte[TagSizeBytes];
using var aes = new AesGcm(_key, TagSizeBytes);
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);
// Combine ciphertext + tag for storage
var combined = new byte[ciphertext.Length + tag.Length];
Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length);
Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length);
return $"{EncryptionPrefix}{Convert.ToBase64String(nonce)}${Convert.ToBase64String(combined)}";
}
public string Decrypt(string content)
{
if (!content.StartsWith(EncryptionPrefix))
return content; // Legacy plaintext
try
{
var payload = content[EncryptionPrefix.Length..];
var separatorIndex = payload.IndexOf('$');
if (separatorIndex < 0)
{
_logger.LogWarning("Malformed encrypted content: missing separator");
return "[encrypted message — decryption failed]";
}
var nonceBase64 = payload[..separatorIndex];
var combinedBase64 = payload[(separatorIndex + 1)..];
var nonce = Convert.FromBase64String(nonceBase64);
var combined = Convert.FromBase64String(combinedBase64);
if (combined.Length < TagSizeBytes)
{
_logger.LogWarning("Malformed encrypted content: data too short");
return "[encrypted message — decryption failed]";
}
var ciphertextLength = combined.Length - TagSizeBytes;
var ciphertext = combined.AsSpan(0, ciphertextLength);
var tag = combined.AsSpan(ciphertextLength, TagSizeBytes);
var plaintext = new byte[ciphertextLength];
using var aes = new AesGcm(_key, TagSizeBytes);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return Encoding.UTF8.GetString(plaintext);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to decrypt message content");
return "[encrypted message — decryption failed]";
}
}
public string? EncryptNullable(string? value)
=> value is null ? null : Encrypt(value);
public string? DecryptNullable(string? value)
=> value is null ? null : Decrypt(value);
}
@@ -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<EchoHubDbContext>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("EchoHub.Server.Setup.DataMigration");
await EnsureDefaultChannelsPublicAsync(db, logger);
await MigrateAnsiMessagesAsync(db, logger);
await MigrateEmbedJsonToArrayAsync(db, logger);
await EnsureConfiguredAdminsAsync(db, config, logger);
}
/// <summary>
@@ -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();
/// <summary>
/// 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.
/// </summary>
private static async Task EnsureConfiguredAdminsAsync(EchoHubDbContext db, IConfiguration config, ILogger logger)
{
var adminUsernames = config.GetSection("Server:Admins").Get<string[]>();
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();
}
/// <summary>
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
/// </summary>
@@ -136,4 +173,5 @@ public static partial class DataMigrationService
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
}
}
}
+24
View File
@@ -22,6 +22,7 @@ public static class FirstRunSetup
return;
EnsureJwtSecret(settingsPath);
EnsureEncryptionKey(settingsPath);
}
private static void EnsureJwtSecret(string settingsPath)
@@ -45,4 +46,27 @@ public static class FirstRunSetup
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
Console.WriteLine("Generated new JWT secret in appsettings.json.");
}
private static void EnsureEncryptionKey(string settingsPath)
{
var json = File.ReadAllText(settingsPath);
var root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip });
if (root is null)
return;
var currentKey = root["Encryption"]?["Key"]?.GetValue<string>();
if (!string.IsNullOrEmpty(currentKey))
return;
// Generate a 256-bit (32-byte) AES key
var key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
root["Encryption"] ??= new JsonObject();
root["Encryption"]!["Key"] = key;
var writeOptions = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
Console.WriteLine("Generated new encryption key in appsettings.json.");
}
}
+10 -1
View File
@@ -12,7 +12,16 @@
"Name": "My EchoHub Server",
"Description": "A self-hosted EchoHub chat server",
"PublicServer": false,
"PublicHost": ""
"PublicHost": "",
"Admins": []
},
"Storage": {
"CleanupIntervalHours": 1,
"RetentionDays": 30
},
"Encryption": {
"Key": "",
"EncryptDatabase": false
},
"Irc": {
"Enabled": false,
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+107
View File
@@ -0,0 +1,107 @@
using EchoHub.Client.UI;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -0,0 +1,233 @@
using System.Security.Cryptography;
using EchoHub.Client.Services;
using Xunit;
namespace EchoHub.Tests;
public class ClientEncryptionServiceTests
{
private static string GenerateKey() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
private static ClientEncryptionService CreateInitialized(string? key = null)
{
var service = new ClientEncryptionService();
service.SetKey(key ?? GenerateKey());
return service;
}
// ── Initialization ───────────────────────────────────────────────
[Fact]
public void IsInitialized_DefaultFalse()
{
var service = new ClientEncryptionService();
Assert.False(service.IsInitialized);
}
[Fact]
public void IsInitialized_TrueAfterSetKey()
{
var service = CreateInitialized();
Assert.True(service.IsInitialized);
}
[Fact]
public void EncryptDatabaseEnabled_AlwaysFalse()
{
var service = CreateInitialized();
Assert.False(service.EncryptDatabaseEnabled);
}
[Fact]
public void SetKey_WrongLength_Throws()
{
var service = new ClientEncryptionService();
var shortKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
Assert.Throws<InvalidOperationException>(() => service.SetKey(shortKey));
}
[Fact]
public void SetKey_InvalidBase64_Throws()
{
var service = new ClientEncryptionService();
Assert.Throws<FormatException>(() => service.SetKey("not-valid-base64!!!"));
}
// ── Encrypt before initialization ────────────────────────────────
[Fact]
public void Encrypt_NotInitialized_PassesThrough()
{
var service = new ClientEncryptionService();
var result = service.Encrypt("hello");
Assert.Equal("hello", result);
}
[Fact]
public void Decrypt_NotInitialized_PassesThrough()
{
var service = new ClientEncryptionService();
var result = service.Decrypt("$ENC$v1$something$else");
Assert.Equal("$ENC$v1$something$else", result);
}
// ── Encrypt after initialization ─────────────────────────────────
[Fact]
public void Encrypt_ProducesEncryptedFormat()
{
var service = CreateInitialized();
var encrypted = service.Encrypt("Hello, world!");
Assert.StartsWith("$ENC$v1$", encrypted);
}
[Fact]
public void Encrypt_DifferentNonceEachTime()
{
var service = CreateInitialized();
var a = service.Encrypt("same message");
var b = service.Encrypt("same message");
Assert.NotEqual(a, b);
}
[Fact]
public void Encrypt_EmptyString_Works()
{
var service = CreateInitialized();
var encrypted = service.Encrypt("");
Assert.StartsWith("$ENC$v1$", encrypted);
}
// ── Decrypt ──────────────────────────────────────────────────────
[Fact]
public void Decrypt_RoundTrip()
{
var service = CreateInitialized();
var original = "Hello, world!";
var encrypted = service.Encrypt(original);
var decrypted = service.Decrypt(encrypted);
Assert.Equal(original, decrypted);
}
[Fact]
public void Decrypt_EmptyString_RoundTrip()
{
var service = CreateInitialized();
var encrypted = service.Encrypt("");
var decrypted = service.Decrypt(encrypted);
Assert.Equal("", decrypted);
}
[Fact]
public void Decrypt_Unicode_RoundTrip()
{
var service = CreateInitialized();
var original = "Hello 🌍 世界 مرحبا";
var encrypted = service.Encrypt(original);
var decrypted = service.Decrypt(encrypted);
Assert.Equal(original, decrypted);
}
[Fact]
public void Decrypt_PlaintextPassthrough()
{
var service = CreateInitialized();
var plaintext = "This is just plain text";
var result = service.Decrypt(plaintext);
Assert.Equal(plaintext, result);
}
[Fact]
public void Decrypt_WrongKey_ReturnsFailureMessage()
{
var encryptor = CreateInitialized();
var decryptor = CreateInitialized(); // different key
var encrypted = encryptor.Encrypt("secret message");
var result = decryptor.Decrypt(encrypted);
Assert.Contains("decryption failed", result);
}
[Fact]
public void Decrypt_MalformedContent_ReturnsOriginal()
{
var service = CreateInitialized();
// Malformed: prefix present but no valid separator after nonce
var result = service.Decrypt("$ENC$v1$noseperatorhere");
Assert.Equal("$ENC$v1$noseperatorhere", result);
}
[Fact]
public void Decrypt_TamperedCiphertext_ReturnsFailure()
{
var service = CreateInitialized();
var encrypted = service.Encrypt("original message");
var tampered = encrypted[..^5] + "XXXXX";
var result = service.Decrypt(tampered);
Assert.Contains("decryption failed", result);
}
// ── Nullable helpers ─────────────────────────────────────────────
[Fact]
public void EncryptNullable_Null_ReturnsNull()
{
var service = CreateInitialized();
Assert.Null(service.EncryptNullable(null));
}
[Fact]
public void DecryptNullable_Null_ReturnsNull()
{
var service = CreateInitialized();
Assert.Null(service.DecryptNullable(null));
}
// ── Key replacement ──────────────────────────────────────────────
[Fact]
public void SetKey_CanBeCalledMultipleTimes()
{
var service = new ClientEncryptionService();
var key1 = GenerateKey();
var key2 = GenerateKey();
service.SetKey(key1);
var encrypted1 = service.Encrypt("test");
service.SetKey(key2);
var encrypted2 = service.Encrypt("test");
// Both produce encrypted content
Assert.StartsWith("$ENC$v1$", encrypted1);
Assert.StartsWith("$ENC$v1$", encrypted2);
// Old key's content can't be decrypted with new key
var result = service.Decrypt(encrypted1);
Assert.Contains("decryption failed", result);
// New key's content decrypts fine
Assert.Equal("test", service.Decrypt(encrypted2));
}
}
+462
View File
@@ -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);
}
}
@@ -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);
}
}
+2
View File
@@ -18,8 +18,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" />
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,208 @@
using System.Security.Cryptography;
using EchoHub.Client.Services;
using EchoHub.Server.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// Tests that server and client encryption services are fully interoperable —
/// content encrypted by one can be decrypted by the other using the same key.
/// </summary>
public class EncryptionCompatibilityTests
{
private static readonly string SharedKey =
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
private static MessageEncryptionService CreateServer(string? key = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Encryption:Key"] = key ?? SharedKey,
["Encryption:EncryptDatabase"] = "false",
})
.Build();
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
return new MessageEncryptionService(config, logger);
}
private static ClientEncryptionService CreateClient(string? key = null)
{
var service = new ClientEncryptionService();
service.SetKey(key ?? SharedKey);
return service;
}
// ── Cross-service round trips ────────────────────────────────────
[Fact]
public void ClientEncrypt_ServerDecrypt()
{
var client = CreateClient();
var server = CreateServer();
var original = "Hello from client!";
var encrypted = client.Encrypt(original);
var decrypted = server.Decrypt(encrypted);
Assert.Equal(original, decrypted);
}
[Fact]
public void ServerEncrypt_ClientDecrypt()
{
var server = CreateServer();
var client = CreateClient();
var original = "Hello from server!";
var encrypted = server.Encrypt(original);
var decrypted = client.Decrypt(encrypted);
Assert.Equal(original, decrypted);
}
[Theory]
[InlineData("")]
[InlineData("a")]
[InlineData("Hello, world!")]
[InlineData("Hello 🌍 世界 مرحبا")]
[InlineData("Line1\nLine2\nLine3")]
public void CrossDecrypt_VariousMessages(string message)
{
var server = CreateServer();
var client = CreateClient();
// Client → Server
var clientEncrypted = client.Encrypt(message);
Assert.Equal(message, server.Decrypt(clientEncrypted));
// Server → Client
var serverEncrypted = server.Encrypt(message);
Assert.Equal(message, client.Decrypt(serverEncrypted));
}
[Fact]
public void CrossDecrypt_LargeMessage()
{
var server = CreateServer();
var client = CreateClient();
var original = new string('X', 10000);
var encrypted = client.Encrypt(original);
Assert.Equal(original, server.Decrypt(encrypted));
}
// ── Key mismatch ─────────────────────────────────────────────────
[Fact]
public void DifferentKeys_ClientEncrypt_ServerCantDecrypt()
{
var clientKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var serverKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var client = CreateClient(clientKey);
var server = CreateServer(serverKey);
var encrypted = client.Encrypt("secret");
var result = server.Decrypt(encrypted);
Assert.Contains("decryption failed", result);
}
[Fact]
public void DifferentKeys_ServerEncrypt_ClientCantDecrypt()
{
var clientKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var serverKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var server = CreateServer(serverKey);
var client = CreateClient(clientKey);
var encrypted = server.Encrypt("secret");
var result = client.Decrypt(encrypted);
Assert.Contains("decryption failed", result);
}
// ── Plaintext passthrough ────────────────────────────────────────
[Fact]
public void Server_DecryptsPlaintext_AsPassthrough()
{
var server = CreateServer();
Assert.Equal("plain text", server.Decrypt("plain text"));
}
[Fact]
public void Client_DecryptsPlaintext_AsPassthrough()
{
var client = CreateClient();
Assert.Equal("plain text", client.Decrypt("plain text"));
}
// ── Full E2E flow simulation ─────────────────────────────────────
[Fact]
public void FullFlow_ClientSend_ServerProcess_BroadcastBack()
{
var client = CreateClient();
var server = CreateServer();
// 1. Client encrypts and sends
var originalMessage = "Hello everyone!";
var clientEncrypted = client.Encrypt(originalMessage);
Assert.StartsWith("$ENC$v1$", clientEncrypted);
// 2. Server decrypts for processing
var serverPlaintext = server.Decrypt(clientEncrypted);
Assert.Equal(originalMessage, serverPlaintext);
// 3. Server re-encrypts for broadcast (different nonce)
var serverEncrypted = server.Encrypt(serverPlaintext);
Assert.StartsWith("$ENC$v1$", serverEncrypted);
Assert.NotEqual(clientEncrypted, serverEncrypted); // different nonce
// 4. Receiving client decrypts the broadcast
var receivedPlaintext = client.Decrypt(serverEncrypted);
Assert.Equal(originalMessage, receivedPlaintext);
}
[Fact]
public void FullFlow_ServerGeneratedMessage_EncryptForBroadcast()
{
var server = CreateServer();
var client = CreateClient();
// Server generates a system message (e.g. file upload notification)
var systemMessage = "user uploaded file.png";
// Server encrypts for broadcast
var encrypted = server.Encrypt(systemMessage);
// Client decrypts
var decrypted = client.Decrypt(encrypted);
Assert.Equal(systemMessage, decrypted);
}
[Fact]
public void FullFlow_HistoryRetrieve_ServerEncrypts_ClientDecrypts()
{
var server = CreateServer();
var client = CreateClient();
// Simulate loading N messages from DB (plaintext) and encrypting for transport
var messages = new[] { "msg1", "Hello 🌍", "msg with\nnewline" };
foreach (var original in messages)
{
var encrypted = server.Encrypt(original);
var decrypted = client.Decrypt(encrypted);
Assert.Equal(original, decrypted);
}
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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<IOptions<IrcOptions>>(Options.Create(_options))
.BuildServiceProvider();
_gateway = new IrcGatewayService(
Options.Create(_options), services, NullLogger<IrcGatewayService>.Instance);
_broadcaster = new IrcBroadcaster(_gateway, _encryption);
}
/// <summary>
/// Injects a test connection into the gateway's internal connection map.
/// </summary>
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<string, IrcClientConnection>)_gateway.Connections;
connections[conn.ConnectionId] = conn;
return conn;
}
private static List<string> 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");
}
/// <summary>
/// Creates a connection that can capture output and injects it into the gateway.
/// </summary>
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<string, IrcClientConnection>)_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());
}
}
@@ -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);
}
}
@@ -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<List<string>> RunAndCapture(string[] inputLines,
Action<IrcClientConnection>? 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<List<string>> RunAuthenticated(string[] inputLines,
string nickname = "alice", Guid? userId = null,
Action<IrcClientConnection>? 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);
}
}
@@ -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<EmbedDto>? 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<EmbedDto>
{
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<EmbedDto>
{
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<EmbedDto>
{
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));
}
}
+183
View File
@@ -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);
}
}
+260
View File
@@ -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;
/// <summary>
/// A duplex stream that reads from one buffer and writes to another,
/// allowing test code to inject input and capture output.
/// </summary>
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<string> 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<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
_readBuffer.ReadAsync(buffer, offset, count, ct);
public override ValueTask<int> ReadAsync(Memory<byte> 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<byte> 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);
}
}
/// <summary>
/// Creates IrcClientConnections backed by test streams for unit testing.
/// </summary>
internal static class TestIrcConnectionFactory
{
/// <summary>
/// Creates a test IRC connection with the given input lines.
/// Returns the connection and the test stream (for inspecting output).
/// </summary>
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);
}
/// <summary>
/// Creates a pre-authenticated, registered IRC connection.
/// </summary>
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);
}
}
/// <summary>
/// Fake encryption service that uses a simple reversible prefix-based scheme.
/// Encrypt("hello") → "$ENC$hello", Decrypt("$ENC$hello") → "hello".
/// </summary>
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;
}
/// <summary>
/// Fake chat service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeChatService : IChatService
{
// Recorded calls
public List<string> ConnectedUsers { get; } = [];
public List<string> 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<MessageDto> 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<string> ChannelsForUserToReturn { get; set; } = [];
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
public Task UserConnectedAsync(string connectionId, Guid userId, string username)
{
ConnectedUsers.Add(username);
return Task.CompletedTask;
}
public Task<string?> UserDisconnectedAsync(string connectionId)
{
DisconnectedConnections.Add(connectionId);
return Task.FromResult<string?>(null);
}
public Task<(List<MessageDto> 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<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
{
SentMessages.Add((channelName, content));
return Task.FromResult(SendMessageError);
}
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) =>
Task.FromResult(HistoryToReturn);
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
{
StatusUpdates.Add((username, status));
return Task.FromResult<string?>(null);
}
public Task<List<UserPresenceDto>> 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<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
public Task<List<string>> GetChannelsForUserAsync(string username) =>
Task.FromResult(ChannelsForUserToReturn);
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
Task.FromResult(AuthResult);
}
/// <summary>
/// Fake channel service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeChannelService : IChannelService
{
// Configurable results
public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true);
public List<ChannelListItem> 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<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) =>
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> 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<List<ChannelListItem>> GetChannelListAsync() =>
Task.FromResult(ChannelListToReturn);
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
Task.FromResult(ChannelByNameToReturn);
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
Task.FromResult(MembershipResult);
}
@@ -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<EmbedDto>? 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<EmbedDto>
{
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]);
}
}
+195
View File
@@ -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<string, string?>
{
["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<string, string?>
{
["Jwt:Issuer"] = TestIssuer,
["Jwt:Audience"] = TestAudience,
})
.Build();
Assert.Throws<InvalidOperationException>(() => new JwtTokenService(config));
}
[Fact]
public void Constructor_MissingIssuer_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = TestSecret,
["Jwt:Audience"] = TestAudience,
})
.Build();
Assert.Throws<InvalidOperationException>(() => new JwtTokenService(config));
}
[Fact]
public void Constructor_MissingAudience_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = TestSecret,
["Jwt:Issuer"] = TestIssuer,
})
.Build();
Assert.Throws<InvalidOperationException>(() => 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);
}
}
+192
View File
@@ -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<string> ExtractUrls(string content) =>
(List<string>)ExtractUrlsMethod.Invoke(null, [content])!;
private static bool IsPrivateHost(Uri uri) =>
(bool)IsPrivateHostMethod.Invoke(null, [uri])!;
private static Dictionary<string, string> ParseOgTags(string html) =>
(Dictionary<string, string>)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 = """
<html><head>
<meta property="og:title" content="Test Title" />
<meta property="og:description" content="A description" />
<meta property="og:site_name" content="TestSite" />
</head></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 = """<meta content="Reversed Title" property="og:title" />""";
var tags = ParseOgTags(html);
Assert.Equal("Reversed Title", tags["title"]);
}
[Fact]
public void ParseOgTags_NoOgTags_ReturnsEmptyDictionary()
{
var html = "<html><head><title>Page</title></head></html>";
var tags = ParseOgTags(html);
Assert.Empty(tags);
}
[Fact]
public void ParseOgTags_SingleQuotes_ParsedCorrectly()
{
var html = """<meta property='og:title' content='Single Quoted' />""";
var tags = ParseOgTags(html);
Assert.Equal("Single Quoted", tags["title"]);
}
[Fact]
public void ParseOgTags_DuplicateKeys_FirstWins()
{
var html = """
<meta property="og:title" content="First" />
<meta property="og:title" content="Second" />
""";
var tags = ParseOgTags(html);
Assert.Equal("First", tags["title"]);
}
}
@@ -0,0 +1,259 @@
using System.Security.Cryptography;
using EchoHub.Server.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace EchoHub.Tests;
public class MessageEncryptionServiceTests
{
private static MessageEncryptionService CreateService(
string? key = null, bool encryptDatabase = false)
{
key ??= Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Encryption:Key"] = key,
["Encryption:EncryptDatabase"] = encryptDatabase.ToString(),
})
.Build();
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
return new MessageEncryptionService(config, logger);
}
// ── Constructor validation ───────────────────────────────────────
[Fact]
public void Constructor_MissingKey_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
Assert.Throws<InvalidOperationException>(() =>
new MessageEncryptionService(config, logger));
}
[Fact]
public void Constructor_WrongKeyLength_Throws()
{
var shortKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
Assert.Throws<InvalidOperationException>(() => CreateService(shortKey));
}
[Fact]
public void Constructor_ValidKey_Succeeds()
{
var service = CreateService();
Assert.NotNull(service);
}
// ── EncryptDatabaseEnabled ───────────────────────────────────────
[Fact]
public void EncryptDatabaseEnabled_DefaultFalse()
{
var service = CreateService();
Assert.False(service.EncryptDatabaseEnabled);
}
[Fact]
public void EncryptDatabaseEnabled_WhenConfiguredTrue()
{
var service = CreateService(encryptDatabase: true);
Assert.True(service.EncryptDatabaseEnabled);
}
// ── Encrypt ──────────────────────────────────────────────────────
[Fact]
public void Encrypt_ProducesEncryptedFormat()
{
var service = CreateService();
var encrypted = service.Encrypt("Hello, world!");
Assert.StartsWith("$ENC$v1$", encrypted);
}
[Fact]
public void Encrypt_DifferentNonceEachTime()
{
var service = CreateService();
var a = service.Encrypt("same message");
var b = service.Encrypt("same message");
Assert.NotEqual(a, b);
}
[Fact]
public void Encrypt_EmptyString_Works()
{
var service = CreateService();
var encrypted = service.Encrypt("");
Assert.StartsWith("$ENC$v1$", encrypted);
}
[Fact]
public void Encrypt_Unicode_Works()
{
var service = CreateService();
var encrypted = service.Encrypt("Hello 🌍 世界 مرحبا");
Assert.StartsWith("$ENC$v1$", encrypted);
}
// ── Decrypt ──────────────────────────────────────────────────────
[Fact]
public void Decrypt_RoundTrip()
{
var service = CreateService();
var original = "Hello, world!";
var encrypted = service.Encrypt(original);
var decrypted = service.Decrypt(encrypted);
Assert.Equal(original, decrypted);
}
[Fact]
public void Decrypt_EmptyString_RoundTrip()
{
var service = CreateService();
var encrypted = service.Encrypt("");
var decrypted = service.Decrypt(encrypted);
Assert.Equal("", decrypted);
}
[Fact]
public void Decrypt_Unicode_RoundTrip()
{
var service = CreateService();
var original = "Hello 🌍 世界 مرحبا";
var encrypted = service.Encrypt(original);
var decrypted = service.Decrypt(encrypted);
Assert.Equal(original, decrypted);
}
[Fact]
public void Decrypt_LongMessage_RoundTrip()
{
var service = CreateService();
var original = new string('A', 5000);
var encrypted = service.Encrypt(original);
var decrypted = service.Decrypt(encrypted);
Assert.Equal(original, decrypted);
}
[Fact]
public void Decrypt_PlaintextPassthrough()
{
var service = CreateService();
var plaintext = "This is just plain text";
var result = service.Decrypt(plaintext);
Assert.Equal(plaintext, result);
}
[Fact]
public void Decrypt_WrongKey_ReturnsFailureMessage()
{
var key1 = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var key2 = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var encryptor = CreateService(key1);
var decryptor = CreateService(key2);
var encrypted = encryptor.Encrypt("secret message");
var result = decryptor.Decrypt(encrypted);
Assert.Contains("decryption failed", result);
}
[Fact]
public void Decrypt_MalformedContent_MissingSeparator_ReturnsFailure()
{
var service = CreateService();
var malformed = "$ENC$v1$noseperatorhere";
var result = service.Decrypt(malformed);
Assert.Contains("decryption failed", result);
}
[Fact]
public void Decrypt_MalformedContent_TooShort_ReturnsFailure()
{
var service = CreateService();
var malformed = "$ENC$v1$AAAA$BBBB";
var result = service.Decrypt(malformed);
Assert.Contains("decryption failed", result);
}
[Fact]
public void Decrypt_TamperedCiphertext_ReturnsFailure()
{
var service = CreateService();
var encrypted = service.Encrypt("original message");
// Tamper with the ciphertext by flipping a character
var tampered = encrypted[..^5] + "XXXXX";
var result = service.Decrypt(tampered);
Assert.Contains("decryption failed", result);
}
// ── Nullable helpers ─────────────────────────────────────────────
[Fact]
public void EncryptNullable_Null_ReturnsNull()
{
var service = CreateService();
Assert.Null(service.EncryptNullable(null));
}
[Fact]
public void EncryptNullable_Value_Encrypts()
{
var service = CreateService();
var result = service.EncryptNullable("test");
Assert.NotNull(result);
Assert.StartsWith("$ENC$v1$", result);
}
[Fact]
public void DecryptNullable_Null_ReturnsNull()
{
var service = CreateService();
Assert.Null(service.DecryptNullable(null));
}
[Fact]
public void DecryptNullable_EncryptedValue_Decrypts()
{
var service = CreateService();
var encrypted = service.Encrypt("test");
var result = service.DecryptNullable(encrypted);
Assert.Equal("test", result);
}
}
+1
Submodule src/Terminal.Gui added at acc7b81923