29 Commits
Author SHA1 Message Date
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
Hue dfa1b21d32 Merge pull request #6 from HueByte/dev
Dev merge
2026-02-19 22:49:14 +01:00
HueByte 981875c495 fix: correct code block syntax for notification sound file path 2026-02-19 22:48:00 +01:00
HueByte c90ed27194 chore: update changelog for v0.2.3 with new features and improvements 2026-02-19 22:46:49 +01:00
HueByte 8f46ea0f2c Dotnet format 2026-02-19 22:45:47 +01:00
HueByte f20ce3d50f feat: add test sound command and enable notifications by default 2026-02-19 22:42:20 +01:00
HueByte 107152298e feat: add force disconnect handling and improve user ban notifications 2026-02-19 22:09:54 +01:00
HueByte 7a8b5f02d3 feat: implement notification sound functionality with user customization options 2026-02-19 22:03:16 +01:00
HueByte b508a5854a feat: Add notification sound functionality and embed support
- Introduced NotificationConfig to manage notification settings, including sound file.
- Added Notification.mp3 asset for notifications.
- Implemented NotificationSoundService to handle playing notification sounds.
- Enhanced ChatRenderer and MainWindow to support emoji rendering.
- Updated MessageDto to allow multiple embeds per message.
- Modified LinkEmbedService to fetch multiple embeds from message content.
- Added data migration for legacy embed JSON format to new array format.
- Adjusted embed handling in ChatService and IrcMessageFormatter to accommodate multiple embeds.
- Updated HubConstants for new embed dimensions and limits.
2026-02-19 21:52:18 +01:00
HueByte 7167b40013 feat: implement link embed functionality to enhance message previews with metadata and thumbnails 2026-02-19 21:21:51 +01:00
HueByte 6965ba573e fix: enhance chat message handling by implementing full Unicode support and collapsing excessive newlines 2026-02-19 21:11:06 +01:00
HueByte 9f303b5311 feat: enhance chat message handling by converting emojis to text shortcodes and improving newline sanitization 2026-02-19 20:41:36 +01:00
HueByte 8fc95e8189 fix: resolve crashes and command issues with emoji handling, file paths, and avatar rendering; implement newline spam protection 2026-02-19 20:01:57 +01:00
HueByte 5d51558c5c feat: implement newline sanitization in chat messages to prevent excessive newlines 2026-02-19 20:00:51 +01:00
HueByte 7181748d40 feat: enhance profile view dialog to render ASCII avatars with color tags 2026-02-19 19:48:48 +01:00
HueByte 0dbb02d12a feat: release v0.2.3 with moderation system, private channels, and UI enhancements 2026-02-19 19:37:45 +01:00
HueByte 38b60e5626 feat: implement channel membership management and ensure default channels are public 2026-02-19 19:21:27 +01:00
HueByte 246eb2b0bb feat: add @mention highlighting in chat lines with new SplitMentions method 2026-02-19 18:59:12 +01:00
HueByte d9a66749e7 feat: implement data migration service to convert ANSI escape codes to printable color tags 2026-02-19 18:57:57 +01:00
HueByte 8dfb1a4fb8 feat: add IsPublic property to channels and enhance channel creation with visibility options 2026-02-19 18:54:50 +01:00
HueByte 3a0ea0d321 feat: enhance file upload and URL sending with optional size parameter 2026-02-19 18:27:02 +01:00
HueByte baebc093f5 feat: implement background update check and add version notification in AppOrchestrator 2026-02-19 18:22:33 +01:00
HueByte b4c3ebd254 feat: enhance profile editing with avatar support and UI adjustments
- Added AvatarPath to ProfileEditResult for user profile updates.
- Updated ProfileEditDialog to include avatar selection with a browse button.
- Increased dialog height to accommodate new avatar input fields.
- Implemented avatar file path handling in the profile edit dialog.

feat: introduce moderation features and user roles

- Added ServerRole enum to define user roles (Member, Mod, Admin, Owner).
- Extended User model to include role, mute, and ban status.
- Created ModerationController for user role assignment, kicking, banning, and muting.
- Implemented methods in IChatBroadcaster and SignalRBroadcaster for user moderation actions.
- Updated database schema with new columns for user roles and moderation states.

fix: ensure muted users cannot send messages

- Added mute status checks in ChatService to prevent message sending for muted users.
- Updated user presence and status handling to reflect role changes and moderation actions.

chore: update constants for ASCII art rendering

- Introduced AsciiArtHeightHalfBlock constant for improved ASCII art rendering.
2026-02-19 17:47:10 +01:00
HueByte 257ac34224 feat: Add heartbeat handling to ServerDirectoryService for connection health checks 2026-02-19 15:17:19 +01:00
78 changed files with 5806 additions and 225 deletions
+1
View File
@@ -8,3 +8,4 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
- Theme system and customization - Theme system and customization
- Command system reference - Command system reference
- Configuration management - Configuration management
- [Notification sounds](../../articles/notification-sounds.md)
+3 -3
View File
@@ -3,19 +3,19 @@
- name: API Reference - name: API Reference
href: ../_api_meta/client/toc.yml href: ../_api_meta/client/toc.yml
- name: Articles - name: Articles
href: client-articles/ href: client-articles/toc.yml
- name: Core - name: Core
items: items:
- name: API Reference - name: API Reference
href: ../_api_meta/core/toc.yml href: ../_api_meta/core/toc.yml
- name: Articles - name: Articles
href: core-articles/ href: core-articles/toc.yml
- name: Server - name: Server
items: items:
- name: API Reference - name: API Reference
href: ../_api_meta/server/toc.yml href: ../_api_meta/server/toc.yml
- name: Articles - name: Articles
href: server-articles/ href: server-articles/toc.yml
- name: Server.Irc - name: Server.Irc
items: items:
- name: API Reference - name: API Reference
+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.
+43
View File
@@ -0,0 +1,43 @@
# Notification Sounds
EchoHub can play a notification sound when someone @mentions you. This is **disabled by default** and must be enabled in your profile settings.
## Enabling Notifications
Open your profile (`/profile`) and check the **"Notification sound on @mention"** checkbox, then save. You can also adjust the **Volume** (0-100, default 30). All settings are persisted in `~/.echohub/config.json`.
## Customizing the Sound
The client ships with a default `Notification.mp3` in the `Assets` folder. To use your own notification sound, replace the file at:
```text
<app-directory>/Assets/Notification.mp3
```
The file must be a valid `.mp3` or `.wav` audio file. The replacement takes effect on the next app launch.
Alternatively, set a custom path in `~/.echohub/config.json`:
```json
{
"notifications": {
"enabled": true,
"volume": 30,
"soundFile": "/path/to/your/sound.mp3"
}
}
```
When `soundFile` is set, EchoHub uses that file instead of the bundled default.
## Disabling Notifications
Uncheck the option in your profile, or edit the config directly:
```json
{
"notifications": {
"enabled": false
}
}
```
+4
View File
@@ -2,3 +2,7 @@
href: getting-started.md href: getting-started.md
- name: Architecture - name: Architecture
href: architecture.md href: architecture.md
- name: Encryption
href: encryption.md
- name: Notification Sounds
href: notification-sounds.md
+1
View File
@@ -4,6 +4,7 @@ Release history for EchoHub.
## Releases ## Releases
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
- [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes - [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
- [v0.2.0](v0.2.0.md) - IRC Gateway - [v0.2.0](v0.2.0.md) - IRC Gateway
+4
View File
@@ -1,5 +1,9 @@
- name: Overview - name: Overview
href: index.md href: index.md
- name: v0.2.4
href: v0.2.4.md
- name: v0.2.3
href: v0.2.3.md
- name: v0.2.2 - name: v0.2.2
href: v0.2.2.md href: v0.2.2.md
- name: v0.2.1 - name: v0.2.1
+95
View File
@@ -0,0 +1,95 @@
# v0.2.3 - Moderation, Embeds & UI Overhaul
## Features
### Moderation System
- Added server roles: Owner, Admin, Mod, Member — first registered user is automatically Owner
- New `/kick`, `/ban`, `/unban`, `/mute`, `/unmute`, `/role`, `/nuke` commands for moderators and admins
- `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking
- Mutes support optional duration (auto-expire) and blocked users cannot log in
- Role claim included in JWT tokens; role badges shown in the online users panel
- Kicked and banned users are forcibly disconnected in real time — server cleans up presence, broadcasts departures, and signals client disconnect
- Works for both SignalR and IRC connections; client shows an error dialog with the reason
### Private Channels
- Channels can be created as public or private via a checkbox in the Create Channel dialog
- Public channels are visible to all users; private channels only appear for members who joined them
- Persistent channel membership tracked in the database (`ChannelMembership` table)
- `GET /api/channels` returns the combined list: public channels + user's joined private channels
- Channel creators are automatically added as members
### OpenGraph Link Embeds
- Messages containing URLs now show a rich preview below the message text
- Multiple URLs per message supported (up to 3) — each gets its own embed
- Server-side fetching: detects URLs in a message, fetches each page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:site_name`)
- Embeds are persisted in the database as a JSON array and included in channel history
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray; text word-wraps at actual viewport width
- IRC gateway receives a text-only embed preview (site name, title, description)
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
- 5-second fetch timeout ensures message delivery is never significantly delayed
- SSRF protection rejects private/loopback IP addresses before fetching
### Notification Sounds
- Incoming messages play a notification sound when the terminal is not focused
- Embedded MP3 asset with cross-platform playback support
### Online Users Panel
- Collapsible right-side panel showing online users in the current channel (toggle with F2)
- Users displayed with status indicators, role badges, and their custom nickname colors
- Panel updates on join, leave, and status change events
### @mention Highlighting
- `@username` text rendered in orange accent color in all messages
- Messages mentioning the current user get a full-line amber background highlight
- Works across multi-line messages and continuation lines
### ASCII Art Improvements
- Half-block character rendering (`▀`/`█`) with separate foreground + background colors for 2x vertical resolution
- Switched from ANSI escape codes to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`) — no control bytes in stored content
- Optional size parameter for `/send` command: `-s` (40x40), `-m` (80x80, default), `-l` (120x120)
- IRC gateway converts color tags back to ANSI for IRC client compatibility
### Client UI
- Version number shown in the status bar
- Custom colored rendering for channel list (active indicator, unread count badges)
- Avatar upload field added to the profile edit dialog (file path or URL)
- Profile avatar now renders with full color tag support in the profile view dialog
- Update check notification on connect — shows a system message if a newer GitHub release exists
- Chat messages no longer show selection/focus highlight
- Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy
- Default history increased from 50 to 100 messages on channel join
## Fixes
- Fixed `#general` channel not visible after adding the `IsPublic` column — migration default changed to `true` and startup service ensures it
- Fixed channels disappearing when creating a new channel — replaced full re-fetch with incremental updates
- Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time
- Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors
- Full Unicode/emoji support — renderers use Terminal.Gui v2 grapheme cluster API (`GraphemeHelper`, `AddStr`) for proper wide character handling
- Emoji-to-text shortcode conversion for consistent cross-platform rendering
- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted
- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
- Fixed OG tag regex truncating descriptions containing apostrophes (e.g. `"HueByte's portfolio"` was cut to `"HueByte"`) — switched to backreference-based quote pairing
## Infrastructure
- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via compiled regex
- `EmbedDto` record added to shared Core DTOs; `MessageDto.Embeds` list for multiple embeds per message
- `EmbedJson` nullable column on the `Message` table stores serialized embed data as JSON array (max 8KB); `DataMigrationService` auto-migrates old single-object format
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
- `PresenceTracker.ForceRemoveUser()` for atomic user cleanup on kick/ban
- `IChatBroadcaster.ForceDisconnectUserAsync()` and `IEchoHubClient.ForceDisconnect` for force-disconnect signaling
- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds
- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records
- `EmojiHelper` utility for emoji-to-shortcode conversion
- Heartbeat handling in `ServerDirectoryService` for connection health checks
- Four new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership`, `AddMessageEmbed`
- `ChannelMembership` table with cascade delete on both channel and user removal
+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 (87 total)
- Documentation article: `docs/articles/encryption.md`
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.2.2</Version> <Version>0.2.4</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup> </PropertyGroup>
+286 -9
View File
@@ -21,9 +21,11 @@ public sealed class AppOrchestrator : IDisposable
private readonly IApplication _app; private readonly IApplication _app;
private readonly MainWindow _mainWindow; private readonly MainWindow _mainWindow;
private readonly CommandHandler _commandHandler; private readonly CommandHandler _commandHandler;
private readonly NotificationSoundService _notificationSound;
private EchoHubConnection? _connection; private EchoHubConnection? _connection;
private ApiClient? _apiClient; private ApiClient? _apiClient;
private readonly ClientEncryptionService _encryption = new();
private ClientConfig _config; private ClientConfig _config;
private UserStatus _currentStatus = UserStatus.Online; private UserStatus _currentStatus = UserStatus.Online;
private string? _currentStatusMessage; private string? _currentStatusMessage;
@@ -41,6 +43,7 @@ public sealed class AppOrchestrator : IDisposable
_config = config; _config = config;
_mainWindow = new MainWindow(app); _mainWindow = new MainWindow(app);
_commandHandler = new CommandHandler(); _commandHandler = new CommandHandler();
_notificationSound = new NotificationSoundService(config.Notifications);
WireMainWindowEvents(); WireMainWindowEvents();
WireCommandHandlerEvents(); WireCommandHandlerEvents();
@@ -76,6 +79,7 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnThemeSelected += HandleThemeSelected; _mainWindow.OnThemeSelected += HandleThemeSelected;
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested; _mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested; _mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
} }
// ── Command Handler Wiring ───────────────────────────────────────────── // ── Command Handler Wiring ─────────────────────────────────────────────
@@ -116,7 +120,7 @@ public sealed class AppOrchestrator : IDisposable
return Task.CompletedTask; return Task.CompletedTask;
}; };
_commandHandler.OnSendFile += async (target) => _commandHandler.OnSendFile += async (target, size) =>
{ {
if (!IsAuthenticated || !IsConnected) return; if (!IsAuthenticated || !IsConnected) return;
@@ -128,13 +132,13 @@ public sealed class AppOrchestrator : IDisposable
if (Uri.TryCreate(target, UriKind.Absolute, out var uri) if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https")) && (uri.Scheme == "http" || uri.Scheme == "https"))
{ {
await _apiClient!.SendUrlAsync(channel, target); await _apiClient!.SendUrlAsync(channel, target, size);
} }
else else
{ {
await using var stream = File.OpenRead(target); await using var stream = File.OpenRead(target);
var fileName = Path.GetFileName(target); var fileName = Path.GetFileName(target);
await _apiClient!.UploadFileAsync(channel, stream, fileName); await _apiClient!.UploadFileAsync(channel, stream, fileName, size);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -211,6 +215,8 @@ public sealed class AppOrchestrator : IDisposable
var history = await _connection!.JoinChannelAsync(channelName); var history = await _connection!.JoinChannelAsync(channelName);
InvokeUI(() => InvokeUI(() =>
{ {
// Add to channel list if not already there (e.g. private channels)
_mainWindow.EnsureChannelInList(channelName);
_mainWindow.SwitchToChannel(channelName); _mainWindow.SwitchToChannel(channelName);
if (history.Count > 0) if (history.Count > 0)
_mainWindow.LoadHistory(channelName, history); _mainWindow.LoadHistory(channelName, history);
@@ -292,6 +298,61 @@ public sealed class AppOrchestrator : IDisposable
} }
}; };
_commandHandler.OnKickUser += async (username, reason) =>
{
if (!IsAuthenticated) return;
await _apiClient!.KickUserAsync(username, reason);
};
_commandHandler.OnBanUser += async (username, reason) =>
{
if (!IsAuthenticated) return;
await _apiClient!.BanUserAsync(username, reason);
};
_commandHandler.OnUnbanUser += async (username) =>
{
if (!IsAuthenticated) return;
await _apiClient!.UnbanUserAsync(username);
};
_commandHandler.OnMuteUser += async (username, duration) =>
{
if (!IsAuthenticated) return;
await _apiClient!.MuteUserAsync(username, duration);
};
_commandHandler.OnUnmuteUser += async (username) =>
{
if (!IsAuthenticated) return;
await _apiClient!.UnmuteUserAsync(username);
};
_commandHandler.OnAssignRole += async (username, roleStr) =>
{
if (!IsAuthenticated) return;
var role = roleStr switch
{
"admin" => ServerRole.Admin,
"mod" => ServerRole.Mod,
_ => ServerRole.Member,
};
await _apiClient!.AssignRoleAsync(username, role);
};
_commandHandler.OnNukeChannel += async () =>
{
if (!IsAuthenticated) return;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return;
await _apiClient!.NukeChannelAsync(channel);
};
_commandHandler.OnTestSound += async () =>
{
await _notificationSound.PlayTestAsync();
};
_commandHandler.OnQuit += () => _commandHandler.OnQuit += () =>
{ {
InvokeUI(() => _app.RequestStop()); InvokeUI(() => _app.RequestStop());
@@ -322,6 +383,19 @@ public sealed class AppOrchestrator : IDisposable
_currentUsername = loginResponse.Username; _currentUsername = loginResponse.Username;
// 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(() => InvokeUI(() =>
{ {
_mainWindow.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username); _mainWindow.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username);
@@ -331,7 +405,7 @@ public sealed class AppOrchestrator : IDisposable
if (_connection is not null) if (_connection is not null)
await _connection.DisposeAsync(); await _connection.DisposeAsync();
_connection = new EchoHubConnection(result.ServerUrl, _apiClient); _connection = new EchoHubConnection(result.ServerUrl, _apiClient, _encryption);
WireConnectionEvents(_connection); WireConnectionEvents(_connection);
await _connection.ConnectAsync(); await _connection.ConnectAsync();
@@ -361,7 +435,20 @@ public sealed class AppOrchestrator : IDisposable
// History might not be available // History might not be available
} }
FetchAndUpdateOnlineUsers();
SaveServerToConfig(result); 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"); }, "Connection failed", "Connect");
} }
@@ -440,6 +527,8 @@ public sealed class AppOrchestrator : IDisposable
{ {
// History might not be available // History might not be available
} }
FetchAndUpdateOnlineUsers();
}, "Failed to join channel"); }, "Failed to join channel");
} }
@@ -504,7 +593,9 @@ public sealed class AppOrchestrator : IDisposable
var editResult = ProfileEditDialog.Show(_app, var editResult = ProfileEditDialog.Show(_app,
currentProfile?.DisplayName, currentProfile?.DisplayName,
currentProfile?.Bio, currentProfile?.Bio,
currentProfile?.NicknameColor); currentProfile?.NicknameColor,
_config.Notifications.Enabled,
_config.Notifications.Volume);
if (editResult is null) return; if (editResult is null) return;
@@ -526,6 +617,57 @@ public sealed class AppOrchestrator : IDisposable
}); });
} }
// Upload avatar if specified
if (editResult.AvatarPath is not null)
{
try
{
Stream stream;
string fileName;
if (Uri.TryCreate(editResult.AvatarPath, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https"))
{
using var http = new HttpClient();
var bytes = await http.GetByteArrayAsync(uri);
stream = new MemoryStream(bytes);
fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
fileName = "avatar.png";
}
else
{
stream = File.OpenRead(editResult.AvatarPath);
fileName = Path.GetFileName(editResult.AvatarPath);
}
await using (stream)
{
await _apiClient!.UploadAvatarAsync(stream, fileName);
var channel = _mainWindow.CurrentChannel;
if (!string.IsNullOrEmpty(channel))
InvokeUI(() => _mainWindow.AddSystemMessage(channel, "Avatar updated."));
}
}
catch (Exception ex)
{
Log.Error(ex, "Avatar upload failed for {Target}", editResult.AvatarPath);
InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}"));
}
}
if (editResult.NotificationSoundEnabled.HasValue)
{
_config.Notifications.Enabled = editResult.NotificationSoundEnabled.Value;
_notificationSound.SetEnabled(editResult.NotificationSoundEnabled.Value);
}
if (editResult.NotificationVolume.HasValue)
{
_config.Notifications.Volume = editResult.NotificationVolume.Value;
_notificationSound.SetVolume(editResult.NotificationVolume.Value);
}
_config.DefaultPreset = new AccountPreset _config.DefaultPreset = new AccountPreset
{ {
DisplayName = editResult.DisplayName, DisplayName = editResult.DisplayName,
@@ -599,17 +741,16 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () => RunAsync(async () =>
{ {
var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic); var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic);
if (channel is null) return; if (channel is null) return;
_joinedChannels.Add(channel.Name); _joinedChannels.Add(channel.Name);
var history = await _connection!.JoinChannelAsync(channel.Name); var history = await _connection!.JoinChannelAsync(channel.Name);
// Refresh the channel list
var channels = await _apiClient.GetChannelsAsync();
InvokeUI(() => InvokeUI(() =>
{ {
_mainWindow.SetChannels(channels); _mainWindow.EnsureChannelInList(channel.Name);
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
_mainWindow.SwitchToChannel(channel.Name); _mainWindow.SwitchToChannel(channel.Name);
if (history.Count > 0) if (history.Count > 0)
_mainWindow.LoadHistory(channel.Name, history); _mainWindow.LoadHistory(channel.Name, history);
@@ -617,18 +758,74 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to create channel"); }, "Failed to create channel");
} }
private void HandleDeleteChannelRequested()
{
if (!IsAuthenticated || !IsConnected)
{
_mainWindow.ShowError("Not connected to a server.");
return;
}
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel))
{
_mainWindow.ShowError("No channel selected.");
return;
}
if (channel == HubConstants.DefaultChannel)
{
_mainWindow.ShowError($"The #{HubConstants.DefaultChannel} channel cannot be deleted.");
return;
}
var confirm = MessageBox.Query(_app, "Delete Channel",
$"Are you sure you want to delete #{channel}?\nThis will remove all messages permanently.", "Delete", "Cancel");
if (confirm != 0) return;
RunAsync(async () =>
{
await _apiClient!.DeleteChannelAsync(channel);
_joinedChannels.Remove(channel);
InvokeUI(() =>
{
_mainWindow.RemoveChannel(channel);
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
_mainWindow.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted.");
});
}, "Failed to delete channel");
}
// ── Connection Event Wiring ──────────────────────────────────────────── // ── Connection Event Wiring ────────────────────────────────────────────
private void WireConnectionEvents(EchoHubConnection connection) private void WireConnectionEvents(EchoHubConnection connection)
{ {
connection.OnMessageReceived += message => connection.OnMessageReceived += message =>
{
InvokeUI(() => _mainWindow.AddMessage(message)); InvokeUI(() => _mainWindow.AddMessage(message));
if (!string.IsNullOrEmpty(_currentUsername)
&& message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase))
{
_ = _notificationSound.PlayAsync();
}
};
connection.OnUserJoined += (channelName, username) => connection.OnUserJoined += (channelName, username) =>
{
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel")); InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel"));
if (channelName == _mainWindow.CurrentChannel)
FetchAndUpdateOnlineUsers();
};
connection.OnUserLeft += (channelName, username) => connection.OnUserLeft += (channelName, username) =>
{
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel")); InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel"));
if (channelName == _mainWindow.CurrentChannel)
FetchAndUpdateOnlineUsers();
};
connection.OnUserStatusChanged += presence => connection.OnUserStatusChanged += presence =>
{ {
@@ -642,6 +839,67 @@ public sealed class AppOrchestrator : IDisposable
foreach (var channelName in _mainWindow.GetChannelNames()) foreach (var channelName in _mainWindow.GetChannelNames())
_mainWindow.AddStatusMessage(channelName, displayName, statusText); _mainWindow.AddStatusMessage(channelName, displayName, statusText);
}); });
FetchAndUpdateOnlineUsers();
};
connection.OnUserKicked += (channelName, username, reason) =>
{
var reasonText = reason is not null ? $" ({reason})" : "";
InvokeUI(() =>
{
_mainWindow.AddSystemMessage(channelName, $"{username} was kicked{reasonText}");
});
};
connection.OnUserBanned += (username, reason) =>
{
var reasonText = reason is not null ? $" ({reason})" : "";
InvokeUI(() =>
{
// Show ban notification for other users in the channel
if (!username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase))
{
var channel = _mainWindow.CurrentChannel;
if (!string.IsNullOrEmpty(channel))
_mainWindow.AddSystemMessage(channel, $"{username} was banned{reasonText}");
}
});
};
connection.OnForceDisconnect += reason =>
{
InvokeUI(() =>
{
_mainWindow.ShowError(reason);
HandleDisconnect();
});
};
connection.OnMessageDeleted += (channelName, messageId) =>
{
InvokeUI(() =>
{
_mainWindow.RemoveMessage(channelName, messageId);
});
};
connection.OnChannelNuked += channelName =>
{
InvokeUI(() =>
{
_mainWindow.ClearChannelMessages(channelName);
_mainWindow.AddSystemMessage(channelName, "Channel history has been cleared by a moderator.");
});
};
connection.OnChannelUpdated += channel =>
{
InvokeUI(() =>
{
if (channel.IsPublic)
_mainWindow.EnsureChannelInList(channel.Name);
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
});
}; };
connection.OnError += errorMessage => connection.OnError += errorMessage =>
@@ -673,6 +931,25 @@ public sealed class AppOrchestrator : IDisposable
// ── Private Helpers ──────────────────────────────────────────────────── // ── Private Helpers ────────────────────────────────────────────────────
private void FetchAndUpdateOnlineUsers()
{
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel) || !IsConnected) return;
Task.Run(async () =>
{
try
{
var users = await _connection!.GetOnlineUsersAsync(channel);
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
}
catch (Exception ex)
{
Log.Debug(ex, "Failed to fetch online users for {Channel}", channel);
}
});
}
private void SaveServerToConfig(ConnectDialogResult result) private void SaveServerToConfig(ConnectDialogResult result)
{ {
var savedServer = new SavedServer var savedServer = new SavedServer
Binary file not shown.
+178 -8
View File
@@ -10,7 +10,7 @@ public class CommandHandler
public event Func<string, Task>? OnSetNick; public event Func<string, Task>? OnSetNick;
public event Func<string, Task>? OnSetColor; public event Func<string, Task>? OnSetColor;
public event Func<string, Task>? OnSetTheme; public event Func<string, Task>? OnSetTheme;
public event Func<string, Task>? OnSendFile; public event Func<string, string?, Task>? OnSendFile;
public event Func<string?, Task>? OnOpenProfile; public event Func<string?, Task>? OnOpenProfile;
public event Func<Task>? OnOpenServers; public event Func<Task>? OnOpenServers;
public event Func<string, Task>? OnJoinChannel; public event Func<string, Task>? OnJoinChannel;
@@ -18,6 +18,14 @@ public class CommandHandler
public event Func<string, Task>? OnSetTopic; public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers; public event Func<Task>? OnListUsers;
public event Func<string, Task>? OnSetAvatar; public event Func<string, Task>? OnSetAvatar;
public event Func<string, string?, Task>? OnKickUser;
public event Func<string, string?, Task>? OnBanUser;
public event Func<string, Task>? OnUnbanUser;
public event Func<string, int?, Task>? OnMuteUser;
public event Func<string, Task>? OnUnmuteUser;
public event Func<string, string, Task>? OnAssignRole;
public event Func<Task>? OnNukeChannel;
public event Func<Task>? OnTestSound;
public event Func<Task>? OnQuit; public event Func<Task>? OnQuit;
public event Func<Task>? OnHelp; public event Func<Task>? OnHelp;
@@ -46,6 +54,14 @@ public class CommandHandler
"leave" => await HandleLeave(), "leave" => await HandleLeave(),
"topic" => await HandleTopic(args), "topic" => await HandleTopic(args),
"users" => await HandleUsers(), "users" => await HandleUsers(),
"kick" => await HandleKick(args),
"ban" => await HandleBan(args),
"unban" => await HandleUnban(args),
"mute" => await HandleMute(args),
"unmute" => await HandleUnmute(args),
"role" => await HandleRole(args),
"nuke" => await HandleNuke(),
"test-sound" => await HandleTestSound(),
"quit" or "exit" => await HandleQuit(), "quit" or "exit" => await HandleQuit(),
"help" or "?" => await HandleHelp(), "help" or "?" => await HandleHelp(),
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true), _ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
@@ -120,15 +136,19 @@ public class CommandHandler
private async Task<CommandResult> HandleSend(string args) private async Task<CommandResult> HandleSend(string args)
{ {
if (string.IsNullOrWhiteSpace(args)) if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /send <filepath or URL>", IsError: true); return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
var target = args.Trim().Trim('"'); // Extract optional size flag from end or start, respecting quoted paths
var (target, size) = ParsePathAndSizeFlag(args);
if (string.IsNullOrWhiteSpace(target))
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
if (Uri.TryCreate(target, UriKind.Absolute, out var uri) if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https")) && (uri.Scheme == "http" || uri.Scheme == "https"))
{ {
if (OnSendFile is not null) if (OnSendFile is not null)
await OnSendFile(target); await OnSendFile(target, size);
var fileName = Path.GetFileName(uri.LocalPath); var fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName)) if (string.IsNullOrWhiteSpace(fileName))
fileName = "image"; fileName = "image";
@@ -139,7 +159,7 @@ public class CommandHandler
return new CommandResult(true, $"File not found: {target}", IsError: true); return new CommandResult(true, $"File not found: {target}", IsError: true);
if (OnSendFile is not null) if (OnSendFile is not null)
await OnSendFile(target); await OnSendFile(target, size);
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}..."); return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
} }
@@ -156,7 +176,7 @@ public class CommandHandler
if (string.IsNullOrWhiteSpace(args)) if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true); return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true);
var target = args.Trim().Trim('"'); var target = StripQuotes(args.Trim());
if (OnSetAvatar is not null) if (OnSetAvatar is not null)
await OnSetAvatar(target); await OnSetAvatar(target);
@@ -212,6 +232,102 @@ public class CommandHandler
return new CommandResult(true); return new CommandResult(true);
} }
private async Task<CommandResult> HandleKick(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /kick <username> [reason]", IsError: true);
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
var username = parts[0];
var reason = parts.Length > 1 ? parts[1] : null;
if (OnKickUser is not null)
await OnKickUser(username, reason);
return new CommandResult(true, $"Kicking {username}...");
}
private async Task<CommandResult> HandleBan(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /ban <username> [reason]", IsError: true);
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
var username = parts[0];
var reason = parts.Length > 1 ? parts[1] : null;
if (OnBanUser is not null)
await OnBanUser(username, reason);
return new CommandResult(true, $"Banning {username}...");
}
private async Task<CommandResult> HandleUnban(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /unban <username>", IsError: true);
if (OnUnbanUser is not null)
await OnUnbanUser(args.Trim());
return new CommandResult(true, $"Unbanning {args.Trim()}...");
}
private async Task<CommandResult> HandleMute(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /mute <username> [duration_minutes]", IsError: true);
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
var username = parts[0];
int? duration = parts.Length > 1 && int.TryParse(parts[1], out var d) ? d : null;
if (OnMuteUser is not null)
await OnMuteUser(username, duration);
return new CommandResult(true, $"Muting {username}...");
}
private async Task<CommandResult> HandleUnmute(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /unmute <username>", IsError: true);
if (OnUnmuteUser is not null)
await OnUnmuteUser(args.Trim());
return new CommandResult(true, $"Unmuting {args.Trim()}...");
}
private async Task<CommandResult> HandleRole(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
if (parts.Length < 2)
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
var username = parts[0];
var role = parts[1].ToLowerInvariant();
if (role is not ("admin" or "mod" or "member"))
return new CommandResult(true, "Invalid role. Use: admin, mod, or member", IsError: true);
if (OnAssignRole is not null)
await OnAssignRole(username, role);
return new CommandResult(true, $"Setting {username} to {role}...");
}
private async Task<CommandResult> HandleNuke()
{
if (OnNukeChannel is not null)
await OnNukeChannel();
return new CommandResult(true, "Nuking channel history...");
}
private async Task<CommandResult> HandleTestSound()
{
if (OnTestSound is not null)
await OnTestSound();
return new CommandResult(true, "Playing notification sound...");
}
private async Task<CommandResult> HandleHelp() private async Task<CommandResult> HandleHelp()
{ {
if (OnHelp is not null) if (OnHelp is not null)
@@ -223,18 +339,72 @@ public class CommandHandler
/nick <name> - Set display name /nick <name> - Set display name
/color <#hex> - Set nickname color /color <#hex> - Set nickname color
/theme <name> - Switch theme /theme <name> - Switch theme
/send <filepath or URL> - Send a file or image /send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large)
/avatar <URL or filepath> - Set your avatar /avatar <URL or filepath> - Set your avatar
/profile [username] - View a profile (yours if no name given) /profile [username] - View a profile
/servers - Open saved servers /servers - Open saved servers
/join <channel> - Join a channel /join <channel> - Join a channel
/leave - Leave current channel /leave - Leave current channel
/topic <text> - Set channel topic /topic <text> - Set channel topic
/users - List online users /users - List online users
Moderation:
/kick <user> [reason] - Kick a user (Mod+)
/ban <user> [reason] - Ban a user (Admin+)
/unban <user> - Unban a user (Admin+)
/mute <user> [minutes] - Mute a user (Mod+)
/unmute <user> - Unmute a user (Mod+)
/role <user> <admin|mod|member> - Assign role (Admin+)
/nuke - Clear channel history (Mod+)
/test-sound - Play notification sound
/quit - Exit the app /quit - Exit the app
"""); """);
} }
/// <summary>
/// Extract a file path (possibly quoted) and an optional size flag (-s, -m, -l).
/// The flag can appear before or after the path.
/// </summary>
private static (string Path, string? Size) ParsePathAndSizeFlag(string args)
{
var trimmed = args.Trim();
string? size = null;
// Check for flag at the end: "path" -m or path -m
if (trimmed.Length > 3)
{
var suffix = trimmed[^2..];
if (suffix is "-s" or "-m" or "-l" && trimmed[^3] == ' ')
{
size = suffix[1..];
trimmed = trimmed[..^3].TrimEnd();
}
}
// Check for flag at the start: -m "path" or -m path
if (size is null && trimmed.Length > 3)
{
var prefix = trimmed[..2];
if (prefix is "-s" or "-m" or "-l" && trimmed[2] == ' ')
{
size = prefix[1..];
trimmed = trimmed[3..].TrimStart();
}
}
return (StripQuotes(trimmed), size);
}
/// <summary>
/// Remove matching surrounding quotes (double or single) from a string.
/// </summary>
private static string StripQuotes(string s)
{
if (s.Length >= 2 &&
((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\'')))
return s[1..^1];
return s;
}
private static bool IsValidHex(string s) => private static bool IsValidHex(string s) =>
s.All(c => char.IsAsciiHexDigit(c)); s.All(c => char.IsAsciiHexDigit(c));
} }
@@ -5,6 +5,14 @@ public class ClientConfig
public List<SavedServer> SavedServers { get; set; } = []; public List<SavedServer> SavedServers { get; set; } = [];
public AccountPreset DefaultPreset { get; set; } = new(); public AccountPreset DefaultPreset { get; set; } = new();
public string ActiveTheme { get; set; } = "Default"; public string ActiveTheme { get; set; } = "Default";
public NotificationConfig Notifications { get; set; } = new();
}
public class NotificationConfig
{
public bool Enabled { get; set; } = true;
public byte Volume { get; set; } = 30;
public string? SoundFile { get; set; }
} }
public class SavedServer public class SavedServer
+4
View File
@@ -7,6 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" 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" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
@@ -17,6 +18,9 @@
<Content Include="appsettings.json" Condition="Exists('appsettings.json')"> <Content Include="appsettings.json" Condition="Exists('appsettings.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Assets\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<EmbeddedResource Include="appsettings.example.json"> <EmbeddedResource Include="appsettings.example.json">
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName> <LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
</EmbeddedResource> </EmbeddedResource>
+2
View File
@@ -4,6 +4,8 @@ using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Serilog; using Serilog;
using Terminal.Gui.App; using Terminal.Gui.App;
using Terminal.Gui.Drawing;
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(appSettingsPath)) if (!File.Exists(appSettingsPath))
+85 -6
View File
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
namespace EchoHub.Client.Services; namespace EchoHub.Client.Services;
@@ -127,6 +128,16 @@ public sealed class ApiClient : IDisposable
return info; 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) public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
@@ -159,7 +170,7 @@ public sealed class ApiClient : IDisposable
return result?.AvatarAscii; return result?.AvatarAscii;
} }
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName) public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
using var content = new MultipartFormDataContent(); using var content = new MultipartFormDataContent();
@@ -167,26 +178,28 @@ public sealed class ApiClient : IDisposable
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName); content.Add(streamContent, "file", fileName);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() => var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content)); _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
await EnsureSuccessAsync(response); await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>(); return await response.Content.ReadFromJsonAsync<MessageDto>();
} }
public async Task<MessageDto?> SendUrlAsync(string channelName, string url) public async Task<MessageDto?> SendUrlAsync(string channelName, string url, string? size = null)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
var request = new SendUrlRequest(url); var request = new SendUrlRequest(url);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() => var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url", request)); _http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
await EnsureSuccessAsync(response); await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>(); return await response.Content.ReadFromJsonAsync<MessageDto>();
} }
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null) public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
var request = new CreateChannelRequest(name, topic); var request = new CreateChannelRequest(name, topic, isPublic);
var response = await AuthenticatedRequestAsync(() => var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/channels", request)); _http.PostAsJsonAsync("/api/channels", request));
await EnsureSuccessAsync(response); await EnsureSuccessAsync(response);
@@ -211,6 +224,72 @@ public sealed class ApiClient : IDisposable
await EnsureSuccessAsync(response); await EnsureSuccessAsync(response);
} }
// ── Moderation ────────────────────────────────────────────────────────
public async Task AssignRoleAsync(string username, ServerRole role)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
await EnsureSuccessAsync(response);
}
public async Task KickUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
await EnsureSuccessAsync(response);
}
public async Task BanUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
await EnsureSuccessAsync(response);
}
public async Task UnbanUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
await EnsureSuccessAsync(response);
}
public async Task UnmuteUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
public async Task DeleteMessageAsync(Guid messageId)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
await EnsureSuccessAsync(response);
}
public async Task NukeChannelAsync(string channelName)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
await EnsureSuccessAsync(response);
}
private void SetTokens(LoginResponse result) private void SetTokens(LoginResponse result)
{ {
_accessToken = result.Token; _accessToken = result.Token;
@@ -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,20 +8,27 @@ namespace EchoHub.Client.Services;
public sealed class EchoHubConnection : IAsyncDisposable public sealed class EchoHubConnection : IAsyncDisposable
{ {
private readonly HubConnection _connection; private readonly HubConnection _connection;
private readonly ClientEncryptionService _encryption;
public event Action<MessageDto>? OnMessageReceived; public event Action<MessageDto>? OnMessageReceived;
public event Action<string, string>? OnUserJoined; public event Action<string, string>? OnUserJoined;
public event Action<string, string>? OnUserLeft; public event Action<string, string>? OnUserLeft;
public event Action<ChannelDto>? OnChannelUpdated; public event Action<ChannelDto>? OnChannelUpdated;
public event Action<UserPresenceDto>? OnUserStatusChanged; public event Action<UserPresenceDto>? OnUserStatusChanged;
public event Action<string, string, string?>? OnUserKicked;
public event Action<string, string?>? OnUserBanned;
public event Action<string, Guid>? OnMessageDeleted;
public event Action<string>? OnChannelNuked;
public event Action<string>? OnForceDisconnect;
public event Action<string>? OnError; public event Action<string>? OnError;
public event Action<string>? OnConnectionStateChanged; public event Action<string>? OnConnectionStateChanged;
public event Action? OnReconnected; public event Action? OnReconnected;
public bool IsConnected => _connection.State == HubConnectionState.Connected; 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; var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
_connection = new HubConnectionBuilder() _connection = new HubConnectionBuilder()
@@ -58,7 +65,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
{ {
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message => _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) => _connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
@@ -81,6 +90,31 @@ public sealed class EchoHubConnection : IAsyncDisposable
OnUserStatusChanged?.Invoke(presence); OnUserStatusChanged?.Invoke(presence);
}); });
_connection.On<string, string, string?>(nameof(Core.Contracts.IEchoHubClient.UserKicked), (channelName, username, reason) =>
{
OnUserKicked?.Invoke(channelName, username, reason);
});
_connection.On<string, string?>(nameof(Core.Contracts.IEchoHubClient.UserBanned), (username, reason) =>
{
OnUserBanned?.Invoke(username, reason);
});
_connection.On<string, Guid>(nameof(Core.Contracts.IEchoHubClient.MessageDeleted), (channelName, messageId) =>
{
OnMessageDeleted?.Invoke(channelName, messageId);
});
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
{
OnChannelNuked?.Invoke(channelName);
});
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ForceDisconnect), reason =>
{
OnForceDisconnect?.Invoke(reason);
});
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message => _connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
{ {
OnError?.Invoke(message); OnError?.Invoke(message);
@@ -102,7 +136,8 @@ public sealed class EchoHubConnection : IAsyncDisposable
public async Task<List<MessageDto>> JoinChannelAsync(string channelName) 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) public async Task LeaveChannelAsync(string channelName)
@@ -112,12 +147,15 @@ public sealed class EchoHubConnection : IAsyncDisposable
public async Task SendMessageAsync(string channelName, string content) 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) 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) public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
@@ -130,6 +168,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName); 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() public async ValueTask DisposeAsync()
{ {
await _connection.DisposeAsync(); await _connection.DisposeAsync();
@@ -0,0 +1,85 @@
using EchoHub.Client.Config;
using NetCoreAudio;
using Serilog;
namespace EchoHub.Client.Services;
public class NotificationSoundService
{
private readonly Player _player = new();
private readonly NotificationConfig _config;
private string? _resolvedSoundPath;
public NotificationSoundService(NotificationConfig config)
{
_config = config;
ResolveSoundPath();
}
public void SetEnabled(bool enabled) => _config.Enabled = enabled;
public void SetVolume(byte volume) => _config.Volume = Math.Min(volume, (byte)100);
public async Task PlayAsync()
{
if (!_config.Enabled || _resolvedSoundPath is null)
return;
await PlayInternal();
}
/// <summary>
/// Plays the notification sound regardless of the Enabled setting (for /test-sound).
/// </summary>
public async Task PlayTestAsync()
{
if (_resolvedSoundPath is null)
return;
await PlayInternal();
}
private async Task PlayInternal()
{
try
{
if (_player.Playing)
await _player.Stop();
await _player.SetVolume(_config.Volume);
await _player.Play(_resolvedSoundPath!);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to play notification sound");
}
}
private void ResolveSoundPath()
{
// 1. Explicit path from config (~/.echohub/config.json)
if (!string.IsNullOrWhiteSpace(_config.SoundFile))
{
if (File.Exists(_config.SoundFile))
{
_resolvedSoundPath = Path.GetFullPath(_config.SoundFile);
Log.Debug("Notification sound: {Path} (from config)", _resolvedSoundPath);
return;
}
Log.Warning("Configured sound file not found: {Path}", _config.SoundFile);
}
// 2. Default: Notification.mp3 bundled next to the executable
var defaultPath = Path.Combine(AppContext.BaseDirectory, "Assets", "Notification.mp3");
if (File.Exists(defaultPath))
{
_resolvedSoundPath = defaultPath;
Log.Debug("Notification sound: {Path} (default)", _resolvedSoundPath);
return;
}
Log.Information("No notification sound file found — notifications will be silent");
}
}
@@ -0,0 +1,47 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace EchoHub.Client.Services;
public static class UpdateChecker
{
private static readonly Uri ReleaseUrl =
new("https://api.github.com/repos/HueByte/EchoHub/releases/latest");
/// <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()
{
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client");
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;
}
}
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
}
}
+299 -56
View File
@@ -1,8 +1,8 @@
using System.Collections; using System.Collections;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Terminal.Gui.Drawing; using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views; using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute; using Attribute = Terminal.Gui.Drawing.Attribute;
@@ -20,17 +20,19 @@ public partial class ChatLine
{ {
public List<ChatSegment> Segments { get; } public List<ChatSegment> Segments { get; }
public int TextLength { get; } public int TextLength { get; }
public Guid? MessageId { get; set; }
public bool IsMention { get; set; }
public ChatLine(string plainText) public ChatLine(string plainText)
{ {
Segments = [new ChatSegment(plainText, null)]; Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.Length; TextLength = plainText.GetColumns();
} }
public ChatLine(List<ChatSegment> segments) public ChatLine(List<ChatSegment> segments)
{ {
Segments = segments; Segments = segments;
TextLength = segments.Sum(s => s.Text.Length); TextLength = segments.Sum(s => s.Text.GetColumns());
} }
public override string ToString() => string.Concat(Segments.Select(s => s.Text)); public override string ToString() => string.Concat(Segments.Select(s => s.Text));
@@ -50,17 +52,22 @@ public partial class ChatLine
foreach (var segment in Segments) foreach (var segment in Segments)
{ {
int segPos = 0; var text = segment.Text;
while (segPos < segment.Text.Length) int chunkStart = 0;
int charPos = 0;
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
{ {
int remaining = width - col; var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
if (remaining <= 0)
if (col + graphemeCols > width)
{ {
// Emit current line and start a new one if (charPos > chunkStart)
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
results.Add(new ChatLine(currentSegments)); results.Add(new ChatLine(currentSegments));
currentSegments = []; currentSegments = [];
// Add indent for continuation
if (continuationIndent > 0) if (continuationIndent > 0)
{ {
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null)); currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
@@ -71,14 +78,15 @@ public partial class ChatLine
col = 0; col = 0;
} }
remaining = width - col; chunkStart = charPos;
} }
int take = Math.Min(segment.Text.Length - segPos, remaining); col += graphemeCols;
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color)); charPos += grapheme.Length;
col += take;
segPos += take;
} }
if (chunkStart < text.Length)
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
} }
if (currentSegments.Count > 0) if (currentSegments.Count > 0)
@@ -88,62 +96,83 @@ public partial class ChatLine
} }
/// <summary> /// <summary>
/// Parse a string containing ANSI 24-bit color escape codes into colored segments. /// Returns true if a line contains printable color tags.
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
/// </summary> /// </summary>
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null) public static bool HasColorTags(string text) =>
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
/// <summary>
/// Remove all color tags from text, returning only the visible characters.
/// </summary>
public static string StripColorTags(string text) =>
ColorTagRegex().Replace(text, "");
/// <summary>
/// Parse a string containing printable color tags into colored segments.
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
/// </summary>
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
{ {
var segments = new List<ChatSegment>(); var segments = new List<ChatSegment>();
var regex = AnsiColorRegex();
int lastIndex = 0; int lastIndex = 0;
Attribute? currentColor = defaultAttr; Color? currentFg = null;
Color? currentBg = null;
var defaultFg = defaultAttr?.Foreground;
var defaultBg = defaultAttr?.Background ?? Color.Black;
foreach (Match match in regex.Matches(ansiText)) Attribute? BuildAttr()
{
if (currentFg is null && currentBg is null) return defaultAttr;
var fg = currentFg ?? defaultFg ?? Color.White;
var bg = currentBg ?? defaultBg;
return new Attribute(fg, bg);
}
foreach (Match match in ColorTagRegex().Matches(text))
{ {
// Add any text before this escape sequence
if (match.Index > lastIndex) if (match.Index > lastIndex)
{ {
var text = ansiText[lastIndex..match.Index]; var t = text[lastIndex..match.Index];
if (text.Length > 0) if (t.Length > 0)
segments.Add(new ChatSegment(text, currentColor)); segments.Add(new ChatSegment(t, BuildAttr()));
} }
// Parse the escape sequence if (match.Groups[1].Success)
if (match.Groups[1].Value == "0")
{ {
// Reset currentFg = null;
currentColor = defaultAttr; currentBg = null;
} }
else if (match.Groups[2].Success) else if (match.Groups[2].Success)
{ {
// 38;2;R;G;B — 24-bit foreground color var hex = match.Groups[3].Value;
var r = int.Parse(match.Groups[3].Value); var r = Convert.ToInt32(hex[..2], 16);
var g = int.Parse(match.Groups[4].Value); var g = Convert.ToInt32(hex[2..4], 16);
var b = int.Parse(match.Groups[5].Value); var b = Convert.ToInt32(hex[4..6], 16);
currentColor = new Attribute(new Color(r, g, b), Color.Black); if (match.Groups[2].Value == "F")
currentFg = new Color(r, g, b);
else
currentBg = new Color(r, g, b);
} }
lastIndex = match.Index + match.Length; lastIndex = match.Index + match.Length;
} }
// Add remaining text if (lastIndex < text.Length)
if (lastIndex < ansiText.Length)
{ {
var text = ansiText[lastIndex..]; var t = text[lastIndex..];
if (text.Length > 0) if (t.Length > 0)
segments.Add(new ChatSegment(text, currentColor)); segments.Add(new ChatSegment(t, BuildAttr()));
} }
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
} }
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground) [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex ColorTagRegex();
private static partial Regex AnsiColorRegex();
} }
/// <summary> /// <summary>
/// Custom list data source for chat messages with per-character coloring. /// Custom list data source for chat messages with per-segment coloring.
/// </summary> /// </summary>
public class ChatListSource : IListDataSource public class ChatListSource : IListDataSource
{ {
@@ -197,7 +226,8 @@ public class ChatListSource : IListDataSource
listView.Move(Math.Max(col - viewportX, 0), row); listView.Move(Math.Max(col - viewportX, 0), row);
var chatLine = _lines[item]; var chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
int charPos = 0; int charPos = 0;
int drawnChars = 0; int drawnChars = 0;
@@ -205,26 +235,26 @@ public class ChatListSource : IListDataSource
foreach (var segment in chatLine.Segments) foreach (var segment in chatLine.Segments)
{ {
var attr = segment.Color ?? normalAttr; var attr = segment.Color ?? normalAttr;
if (mentionBg.HasValue)
attr = new Attribute(attr.Foreground, mentionBg.Value);
listView.SetAttribute(attr); listView.SetAttribute(attr);
foreach (var ch in segment.Text) foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
{ {
if (charPos >= viewportX && drawnChars < width) var cols = Math.Max(grapheme.GetColumns(), 1);
if (charPos >= viewportX && drawnChars + cols <= width)
{ {
listView.AddRune(new Rune(ch)); listView.AddStr(grapheme);
drawnChars++; drawnChars += cols;
} }
charPos++; charPos += cols;
} }
} }
// Fill remaining width with spaces using default colors var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
listView.SetAttribute(normalAttr); listView.SetAttribute(fillAttr);
while (drawnChars < width) for (int i = drawnChars; i < width; i++)
{ listView.AddStr(" ");
listView.AddRune(new Rune(' '));
drawnChars++;
}
} }
private void UpdateMaxLength(ChatLine line) private void UpdateMaxLength(ChatLine line)
@@ -242,13 +272,226 @@ public class ChatListSource : IListDataSource
public void Dispose() { } public void Dispose() { }
} }
/// <summary>
/// Custom list data source for colored channel list rendering.
/// Active channel gets a > indicator, unread channels are bright with a count badge.
/// </summary>
public class ChannelListSource : IListDataSource
{
private readonly List<string> _channelNames = [];
private readonly Dictionary<string, int> _unreadCounts = [];
private string _activeChannel = string.Empty;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _channelNames.Count;
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);
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
{
_channelNames.Clear();
_channelNames.AddRange(channels);
_unreadCounts.Clear();
foreach (var kv in unread)
_unreadCounts[kv.Key] = kv.Value;
_activeChannel = activeChannel;
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var name = _channelNames[item];
var isActive = name == _activeChannel;
_unreadCounts.TryGetValue(name, out var unread);
var hasUnread = unread > 0;
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
var prefix = isActive ? "> " : " ";
var channelText = $"#{name}";
var badge = hasUnread ? $" ({unread})" : "";
int drawnChars = 0;
if (selected)
{
listView.SetAttribute(focusAttr);
drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
}
else
{
listView.SetAttribute(isActive ? ActiveAttr : NormalAttr);
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
listView.SetAttribute(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr);
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
if (hasUnread)
{
listView.SetAttribute(BadgeAttr);
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
}
}
var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal);
listView.SetAttribute(fillAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
}
public void Dispose() { }
}
/// <summary>
/// Custom list data source for the online users panel with per-user nickname colors.
/// </summary>
public class UserListSource : IListDataSource
{
private readonly List<(string Text, Attribute? NameColor)> _users = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _users.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Update(List<(string Text, Attribute? NameColor)> users)
{
_users.Clear();
_users.AddRange(users);
MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _users.Select(u => u.Text).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var (text, nameColor) = _users[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
// Find where the name starts (after status icon + space + optional role badge)
// Format: "● ★Username" or "● Username"
var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
int nameStart = 0;
while (nameStart < graphemes.Count)
{
var g = graphemes[nameStart];
if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
break;
nameStart++;
}
int drawnChars = 0;
// Draw prefix (status icon + role badge) in normal color
listView.SetAttribute(normalAttr);
for (int i = 0; i < nameStart; i++)
{
var cols = Math.Max(graphemes[i].GetColumns(), 1);
if (drawnChars + cols > width) break;
listView.AddStr(graphemes[i]);
drawnChars += cols;
}
// Draw name in nickname color
var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
listView.SetAttribute(userAttr);
for (int i = nameStart; i < graphemes.Count; i++)
{
var cols = Math.Max(graphemes[i].GetColumns(), 1);
if (drawnChars + cols > width) break;
listView.AddStr(graphemes[i]);
drawnChars += cols;
}
// Fill rest
listView.SetAttribute(normalAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
}
public void Dispose() { }
}
/// <summary>
/// Shared rendering helpers for IListDataSource implementations.
/// </summary>
static class RenderHelpers
{
/// <summary>
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
/// Returns the updated drawn-columns count.
/// </summary>
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
{
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
{
var cols = Math.Max(grapheme.GetColumns(), 1);
if (drawn + cols > maxWidth) break;
lv.AddStr(grapheme);
drawn += cols;
}
return drawn;
}
}
/// <summary> /// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages). /// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary> /// </summary>
public static class ChatColors public static partial class ChatColors
{ {
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black); 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 SystemAttr = new(new Color(0, 180, 180), Color.Black);
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);
/// <summary>
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
/// Non-mention text uses the provided default color.
/// </summary>
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
{
var segments = new List<ChatSegment>();
int lastIndex = 0;
foreach (Match match in MentionRegex().Matches(text))
{
if (match.Index > lastIndex)
segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
segments.Add(new ChatSegment(match.Value, MentionTextAttr));
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
return segments;
}
[GeneratedRegex(@"@[\w-]+")]
private static partial Regex MentionRegex();
} }
/// <summary> /// <summary>
+16 -7
View File
@@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI;
public record CreateChannelResult(string Name, string? Topic); public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
public sealed class CreateChannelDialog public sealed class CreateChannelDialog
{ {
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
{ {
CreateChannelResult? result = null; CreateChannelResult? result = null;
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 }; var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 };
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 }; var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) }; var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
@@ -20,11 +20,19 @@ public sealed class CreateChannelDialog
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 }; var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) }; var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
var publicCheckbox = new CheckBox
{
Text = "Public (visible to all users)",
X = 1,
Y = 5,
Value = CheckState.Checked
};
var hintLabel = new Label var hintLabel = new Label
{ {
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)", Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
X = 1, X = 1,
Y = 5, Y = 7,
}; };
var createButton = new Button var createButton = new Button
@@ -32,14 +40,14 @@ public sealed class CreateChannelDialog
Text = "Create", Text = "Create",
IsDefault = true, IsDefault = true,
X = Pos.Center() - 10, X = Pos.Center() - 10,
Y = 7 Y = 9
}; };
var cancelButton = new Button var cancelButton = new Button
{ {
Text = "Cancel", Text = "Cancel",
X = Pos.Center() + 5, X = Pos.Center() + 5,
Y = 7 Y = 9
}; };
createButton.Accepting += (s, e) => createButton.Accepting += (s, e) =>
@@ -55,7 +63,8 @@ public sealed class CreateChannelDialog
if (string.IsNullOrWhiteSpace(topic)) if (string.IsNullOrWhiteSpace(topic))
topic = null; topic = null;
result = new CreateChannelResult(name, topic); var isPublic = publicCheckbox.Value == CheckState.Checked;
result = new CreateChannelResult(name, topic, isPublic);
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -67,7 +76,7 @@ public sealed class CreateChannelDialog
app.RequestStop(); app.RequestStop();
}; };
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton); dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
nameField.SetFocus(); nameField.SetFocus();
app.Run(dialog); app.Run(dialog);
+333
View File
@@ -0,0 +1,333 @@
using System.Globalization;
using System.Text;
namespace EchoHub.Client.UI;
/// <summary>
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
/// Terminal width calculations for emoji are unreliable across different terminals,
/// so we replace them with fixed-width ASCII shortcodes for display only.
/// </summary>
public static class EmojiHelper
{
/// <summary>
/// Replace emoji graphemes in the text with :shortcode: equivalents.
/// Non-emoji text passes through unchanged.
/// </summary>
public static string ReplaceEmoji(string text)
{
if (string.IsNullOrEmpty(text))
return text;
// Quick check: if no characters above BMP or supplementary emoji ranges, skip processing
bool hasEmoji = false;
foreach (var rune in text.EnumerateRunes())
{
if (IsEmojiRune(rune))
{
hasEmoji = true;
break;
}
}
if (!hasEmoji)
return text;
var sb = new StringBuilder(text.Length);
var enumerator = StringInfo.GetTextElementEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.GetTextElement();
// Check if this grapheme contains emoji runes
bool graphemeHasEmoji = false;
foreach (var rune in grapheme.EnumerateRunes())
{
if (IsEmojiRune(rune))
{
graphemeHasEmoji = true;
break;
}
}
if (graphemeHasEmoji)
{
// Try to find a shortcode for the whole grapheme first
if (EmojiShortcodes.TryGetValue(grapheme, out var shortcode))
{
sb.Append(shortcode);
}
else
{
// Try the base emoji (first rune only, stripping modifiers/ZWJ)
var baseRune = GetBaseEmoji(grapheme);
if (baseRune is not null && EmojiShortcodes.TryGetValue(baseRune, out shortcode))
{
sb.Append(shortcode);
}
else
{
// Unknown emoji — use generic placeholder
sb.Append("[emoji]");
}
}
}
else
{
sb.Append(grapheme);
}
}
return sb.ToString();
}
private static bool IsEmojiRune(Rune rune)
{
var value = rune.Value;
// Common emoji ranges
if (value >= 0x1F600 && value <= 0x1F64F) return true; // Emoticons
if (value >= 0x1F300 && value <= 0x1F5FF) return true; // Misc Symbols & Pictographs
if (value >= 0x1F680 && value <= 0x1F6FF) return true; // Transport & Map
if (value >= 0x1F900 && value <= 0x1F9FF) return true; // Supplemental Symbols
if (value >= 0x1FA00 && value <= 0x1FA6F) return true; // Chess Symbols
if (value >= 0x1FA70 && value <= 0x1FAFF) return true; // Symbols Extended-A
if (value >= 0x2600 && value <= 0x26FF) return true; // Misc Symbols
if (value >= 0x2700 && value <= 0x27BF) return true; // Dingbats
if (value >= 0xFE00 && value <= 0xFE0F) return true; // Variation Selectors
if (value >= 0x200D && value <= 0x200D) return true; // ZWJ
if (value >= 0x1F1E0 && value <= 0x1F1FF) return true; // Regional Indicators (flags)
if (value >= 0x231A && value <= 0x23F3) return true; // Misc Technical (watch, hourglass)
if (value >= 0x2934 && value <= 0x2935) return true; // Arrows
if (value >= 0x25AA && value <= 0x25FE) return true; // Geometric Shapes
if (value >= 0x2B05 && value <= 0x2B55) return true; // Misc Symbols & Arrows
if (value >= 0x3030 && value <= 0x303D) return true; // CJK Symbols
if (value == 0x00A9 || value == 0x00AE) return true; // © ®
if (value == 0x2122) return true; // ™
if (value >= 0x1F000 && value <= 0x1F02F) return true; // Mahjong & Dominos
return false;
}
/// <summary>
/// Extract the base emoji string (first non-modifier, non-ZWJ rune) for lookup.
/// </summary>
private static string? GetBaseEmoji(string grapheme)
{
foreach (var rune in grapheme.EnumerateRunes())
{
// Skip ZWJ, variation selectors, skin tone modifiers
if (rune.Value == 0x200D) continue;
if (rune.Value >= 0xFE00 && rune.Value <= 0xFE0F) continue;
if (rune.Value >= 0x1F3FB && rune.Value <= 0x1F3FF) continue;
if (IsEmojiRune(rune))
return rune.ToString();
}
return null;
}
// Common emoji → shortcode mapping (display-only, covers most frequently used emoji)
private static readonly Dictionary<string, string> EmojiShortcodes = new()
{
// Smileys & Emotion
["\U0001F600"] = ":grinning:",
["\U0001F601"] = ":grin:",
["\U0001F602"] = ":joy:",
["\U0001F603"] = ":smiley:",
["\U0001F604"] = ":smile:",
["\U0001F605"] = ":sweat_smile:",
["\U0001F606"] = ":laughing:",
["\U0001F607"] = ":innocent:",
["\U0001F608"] = ":smiling_imp:",
["\U0001F609"] = ":wink:",
["\U0001F60A"] = ":blush:",
["\U0001F60B"] = ":yum:",
["\U0001F60C"] = ":relieved:",
["\U0001F60D"] = ":heart_eyes:",
["\U0001F60E"] = ":sunglasses:",
["\U0001F60F"] = ":smirk:",
["\U0001F610"] = ":neutral_face:",
["\U0001F611"] = ":expressionless:",
["\U0001F612"] = ":unamused:",
["\U0001F613"] = ":sweat:",
["\U0001F614"] = ":pensive:",
["\U0001F615"] = ":confused:",
["\U0001F616"] = ":confounded:",
["\U0001F617"] = ":kissing:",
["\U0001F618"] = ":kissing_heart:",
["\U0001F619"] = ":kissing_smiling_eyes:",
["\U0001F61A"] = ":kissing_closed_eyes:",
["\U0001F61B"] = ":stuck_out_tongue:",
["\U0001F61C"] = ":stuck_out_tongue_winking_eye:",
["\U0001F61D"] = ":stuck_out_tongue_closed_eyes:",
["\U0001F61E"] = ":disappointed:",
["\U0001F61F"] = ":worried:",
["\U0001F620"] = ":angry:",
["\U0001F621"] = ":rage:",
["\U0001F622"] = ":cry:",
["\U0001F623"] = ":persevere:",
["\U0001F624"] = ":triumph:",
["\U0001F625"] = ":disappointed_relieved:",
["\U0001F626"] = ":frowning:",
["\U0001F627"] = ":anguished:",
["\U0001F628"] = ":fearful:",
["\U0001F629"] = ":weary:",
["\U0001F62A"] = ":sleepy:",
["\U0001F62B"] = ":tired_face:",
["\U0001F62C"] = ":grimacing:",
["\U0001F62D"] = ":sob:",
["\U0001F62E"] = ":open_mouth:",
["\U0001F62F"] = ":hushed:",
["\U0001F630"] = ":cold_sweat:",
["\U0001F631"] = ":scream:",
["\U0001F632"] = ":astonished:",
["\U0001F633"] = ":flushed:",
["\U0001F634"] = ":sleeping:",
["\U0001F635"] = ":dizzy_face:",
["\U0001F636"] = ":no_mouth:",
["\U0001F637"] = ":mask:",
["\U0001F641"] = ":slightly_frowning_face:",
["\U0001F642"] = ":slightly_smiling_face:",
["\U0001F643"] = ":upside_down_face:",
["\U0001F644"] = ":roll_eyes:",
["\U0001F910"] = ":zipper_mouth:",
["\U0001F911"] = ":money_mouth:",
["\U0001F912"] = ":thermometer_face:",
["\U0001F913"] = ":nerd:",
["\U0001F914"] = ":thinking:",
["\U0001F915"] = ":head_bandage:",
["\U0001F920"] = ":cowboy:",
["\U0001F921"] = ":clown:",
["\U0001F922"] = ":nauseated:",
["\U0001F923"] = ":rofl:",
["\U0001F924"] = ":drooling:",
["\U0001F925"] = ":lying:",
["\U0001F929"] = ":star_struck:",
["\U0001F92A"] = ":zany:",
["\U0001F92B"] = ":shushing:",
["\U0001F92C"] = ":cursing:",
["\U0001F92D"] = ":hand_over_mouth:",
["\U0001F92E"] = ":vomiting:",
["\U0001F92F"] = ":exploding_head:",
["\U0001F970"] = ":smiling_face_with_hearts:",
["\U0001F971"] = ":yawning:",
["\U0001F972"] = ":smiling_with_tear:",
["\U0001F973"] = ":partying:",
["\U0001F974"] = ":woozy:",
["\U0001F975"] = ":hot_face:",
["\U0001F976"] = ":cold_face:",
["\U0001F979"] = ":holding_back_tears:",
["\U0001F97A"] = ":pleading:",
["\U0001FAE0"] = ":melting:",
["\U0001FAE1"] = ":saluting:",
["\U0001FAE2"] = ":face_with_open_eyes_hand_over_mouth:",
["\U0001FAE3"] = ":face_with_peeking_eye:",
["\U0001FAE4"] = ":face_with_diagonal_mouth:",
// Gestures
["\U0001F44D"] = ":+1:",
["\U0001F44E"] = ":-1:",
["\U0001F44B"] = ":wave:",
["\U0001F44C"] = ":ok_hand:",
["\U0001F44F"] = ":clap:",
["\U0001F44A"] = ":fist:",
["\U0001F91D"] = ":handshake:",
["\U0001F91E"] = ":crossed_fingers:",
["\U0001F91F"] = ":love_you:",
["\U0001F918"] = ":metal:",
["\U0001F919"] = ":call_me:",
["\U0001F590"] = ":raised_hand:",
["\U0001F4AA"] = ":muscle:",
["\U0001F926"] = ":facepalm:",
["\U0001F937"] = ":shrug:",
["\U0001F64F"] = ":pray:",
["\U0001F64C"] = ":raised_hands:",
["\U0001F64B"] = ":raising_hand:",
// Hearts & Symbols
["\u2764"] = "<3",
["\U0001F494"] = "</3",
["\U0001F495"] = ":two_hearts:",
["\U0001F496"] = ":sparkling_heart:",
["\U0001F497"] = ":heartpulse:",
["\U0001F498"] = ":cupid:",
["\U0001F499"] = ":blue_heart:",
["\U0001F49A"] = ":green_heart:",
["\U0001F49B"] = ":yellow_heart:",
["\U0001F49C"] = ":purple_heart:",
["\U0001F49D"] = ":gift_heart:",
["\U0001F49E"] = ":revolving_hearts:",
["\U0001F49F"] = ":heart_decoration:",
["\U0001F90D"] = ":white_heart:",
["\U0001F90E"] = ":brown_heart:",
["\U0001F5A4"] = ":black_heart:",
["\U0001F9E1"] = ":orange_heart:",
// Objects & Nature
["\U0001F525"] = ":fire:",
["\U0001F4A9"] = ":poop:",
["\U0001F480"] = ":skull:",
["\U0001F47B"] = ":ghost:",
["\U0001F47D"] = ":alien:",
["\U0001F916"] = ":robot:",
["\U0001F4AF"] = ":100:",
["\U0001F4A5"] = ":boom:",
["\U0001F4A4"] = ":zzz:",
["\U0001F4A2"] = ":anger:",
["\U0001F4AC"] = ":speech_balloon:",
["\U0001F440"] = ":eyes:",
["\U0001F3B5"] = ":musical_note:",
["\U0001F3B6"] = ":notes:",
["\U0001F389"] = ":tada:",
["\U0001F38A"] = ":confetti:",
["\U0001F381"] = ":gift:",
["\U0001F3C6"] = ":trophy:",
["\U0001F4B0"] = ":money_bag:",
["\U0001F4BB"] = ":computer:",
["\U0001F4F1"] = ":phone:",
["\U0001F4E7"] = ":email:",
["\U0001F511"] = ":key:",
["\U0001F512"] = ":lock:",
["\U0001F513"] = ":unlock:",
["\U0001F6A8"] = ":rotating_light:",
["\U0001F6AB"] = ":no_entry:",
// Animals
["\U0001F436"] = ":dog:",
["\U0001F431"] = ":cat:",
["\U0001F42D"] = ":mouse:",
["\U0001F430"] = ":rabbit:",
["\U0001F43B"] = ":bear:",
["\U0001F427"] = ":penguin:",
["\U0001F41D"] = ":bee:",
["\U0001F40D"] = ":snake:",
["\U0001F422"] = ":turtle:",
// Food & Drink
["\U0001F355"] = ":pizza:",
["\U0001F354"] = ":hamburger:",
["\U0001F37A"] = ":beer:",
["\U0001F377"] = ":wine:",
["\U0001F370"] = ":cake:",
["\u2615"] = ":coffee:",
["\U0001F382"] = ":birthday:",
// Misc symbols (BMP)
["\u2705"] = ":white_check_mark:",
["\u274C"] = ":x:",
["\u274E"] = ":negative_squared_cross_mark:",
["\u2714"] = ":heavy_check_mark:",
["\u2716"] = ":heavy_multiplication_x:",
["\u26A0"] = ":warning:",
["\u2B50"] = ":star:",
["\u2728"] = ":sparkles:",
["\u267B"] = ":recycle:",
["\u2611"] = ":ballot_box_with_check:",
["\u23F0"] = ":alarm_clock:",
["\u231A"] = ":watch:",
["\u231B"] = ":hourglass:",
};
}
+361 -46
View File
@@ -1,10 +1,12 @@
using System.Collections.ObjectModel; using System.Text.RegularExpressions;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using Terminal.Gui.App; using Terminal.Gui.App;
using Terminal.Gui.Configuration; using Terminal.Gui.Configuration;
using Terminal.Gui.Drawing;
using Terminal.Gui.Input; using Terminal.Gui.Input;
using Terminal.Gui.Text;
using Terminal.Gui.ViewBase; using Terminal.Gui.ViewBase;
using Terminal.Gui.Views; using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute; using Attribute = Terminal.Gui.Drawing.Attribute;
@@ -21,14 +23,26 @@ public sealed class MainWindow : Runnable
private readonly ListView _messageList; private readonly ListView _messageList;
private readonly TextView _inputField; private readonly TextView _inputField;
private readonly FrameView _chatFrame; private readonly FrameView _chatFrame;
private readonly FrameView _inputFrame;
private readonly Label _statusLabel; private readonly Label _statusLabel;
private readonly Label _topicLabel; private readonly Label _topicLabel;
private MenuBar _menuBar; private MenuBar _menuBar;
// Online users panel
private readonly FrameView _usersFrame;
private readonly ListView _usersList;
private readonly UserListSource _usersListSource;
private bool _usersPanelVisible = true;
private const int UsersPanelWidth = 22;
private static readonly Key F2Key = Key.F2;
internal static readonly string AppVersion =
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled) // Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
private static readonly Key EnterKey = Key.Enter; private static readonly Key EnterKey = Key.Enter;
private static readonly Key NewlineKey = Key.N.WithCtrl; private static readonly Key NewlineKey = Key.N.WithCtrl;
private static readonly Key CtrlCKey = Key.C.WithCtrl; private static readonly Key AltQKey = Key.Q.WithAlt;
private static readonly Key TabKey = Key.Tab; private static readonly Key TabKey = Key.Tab;
// Available slash commands for Tab autocomplete // Available slash commands for Tab autocomplete
@@ -36,13 +50,15 @@ public sealed class MainWindow : Runnable
[ [
"/status", "/nick", "/color", "/theme", "/send", "/status", "/nick", "/color", "/theme", "/send",
"/avatar", "/profile", "/servers", "/join", "/leave", "/avatar", "/profile", "/servers", "/join", "/leave",
"/topic", "/users", "/quit", "/help" "/topic", "/users", "/kick", "/ban", "/unban",
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
]; ];
private readonly List<string> _channelNames = []; private readonly List<string> _channelNames = [];
private readonly Dictionary<string, List<ChatLine>> _channelMessages = []; private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = []; private readonly Dictionary<string, int> _channelUnread = [];
private readonly Dictionary<string, string?> _channelTopics = []; private readonly Dictionary<string, string?> _channelTopics = [];
private readonly ChannelListSource _channelListSource;
private string _currentChannel = string.Empty; private string _currentChannel = string.Empty;
private string _currentUser = string.Empty; private string _currentUser = string.Empty;
private int _lastChatWidth; private int _lastChatWidth;
@@ -92,6 +108,11 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnCreateChannelRequested; public event Action? OnCreateChannelRequested;
/// <summary>
/// Fired when the user requests to delete the current channel.
/// </summary>
public event Action? OnDeleteChannelRequested;
public MainWindow(IApplication app) public MainWindow(IApplication app)
{ {
_app = app; _app = app;
@@ -107,7 +128,7 @@ public sealed class MainWindow : Runnable
Title = "Channels", Title = "Channels",
X = 0, X = 0,
Y = 1, // below menu bar Y = 1, // below menu bar
Width = 25, Width = 22,
Height = Dim.Fill(1) // leave room for status bar Height = Dim.Fill(1) // leave room for status bar
}; };
@@ -118,7 +139,8 @@ public sealed class MainWindow : Runnable
Width = Dim.Fill(), Width = Dim.Fill(),
Height = Dim.Fill() Height = Dim.Fill()
}; };
_channelList.SetSource(new ObservableCollection<string>(_channelNames)); _channelListSource = new ChannelListSource();
_channelList.Source = _channelListSource;
_channelList.ValueChanged += OnChannelListSelectionChanged; _channelList.ValueChanged += OnChannelListSelectionChanged;
channelsFrame.Add(_channelList); channelsFrame.Add(_channelList);
Add(channelsFrame); Add(channelsFrame);
@@ -127,9 +149,9 @@ public sealed class MainWindow : Runnable
_topicLabel = new Label _topicLabel = new Label
{ {
Text = "", Text = "",
X = 25, X = 22,
Y = 1, Y = 1,
Width = Dim.Fill(), Width = Dim.Fill(UsersPanelWidth),
Height = 1, Height = 1,
Visible = false Visible = false
}; };
@@ -139,9 +161,9 @@ public sealed class MainWindow : Runnable
_chatFrame = new FrameView _chatFrame = new FrameView
{ {
Title = "Chat", Title = "Chat",
X = 25, X = 22,
Y = 1, // below menu bar (shifts to 2 when topic is visible) Y = 1, // below menu bar (shifts to 2 when topic is visible)
Width = Dim.Fill(), Width = Dim.Fill(UsersPanelWidth),
Height = Dim.Fill(6) // leave room for input area and status bar Height = Dim.Fill(6) // leave room for input area and status bar
}; };
@@ -157,12 +179,12 @@ public sealed class MainWindow : Runnable
Add(_chatFrame); Add(_chatFrame);
// Bottom input area // Bottom input area
var inputFrame = new FrameView _inputFrame = new FrameView
{ {
Title = "Message (Enter=send, Ctrl+N=newline, Tab=autocomplete)", Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
X = 25, X = 22,
Y = Pos.Bottom(_chatFrame), Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(), Width = Dim.Fill(UsersPanelWidth),
Height = 5 Height = 5
}; };
@@ -175,8 +197,31 @@ public sealed class MainWindow : Runnable
WordWrap = true WordWrap = true
}; };
_inputField.KeyDown += OnInputKeyDown; _inputField.KeyDown += OnInputKeyDown;
inputFrame.Add(_inputField); _inputField.ContentsChanged += OnInputContentsChanged;
Add(inputFrame); _inputFrame.Add(_inputField);
Add(_inputFrame);
// Right panel - online users
_usersFrame = new FrameView
{
Title = "Users",
X = Pos.AnchorEnd(UsersPanelWidth),
Y = 1,
Width = UsersPanelWidth,
Height = Dim.Fill(1)
};
_usersList = new ListView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
_usersListSource = new UserListSource();
_usersList.Source = _usersListSource;
_usersFrame.Add(_usersList);
Add(_usersFrame);
// Status bar at the very bottom // Status bar at the very bottom
_statusLabel = new Label _statusLabel = new Label
@@ -198,7 +243,7 @@ public sealed class MainWindow : Runnable
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged(); _messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged(); _chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
// Window-level key handling for Ctrl+C (quit) // Window-level key handling for Alt+Q (quit), F2 (toggle users panel)
KeyDown += OnWindowKeyDown; KeyDown += OnWindowKeyDown;
} }
@@ -265,7 +310,11 @@ public sealed class MainWindow : Runnable
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty), new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
new Line(), new Line(),
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty), new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty) new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
new Line(),
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty),
new Line(),
new MenuItem("Toggle _Users Panel", "Toggle online users (F2)", () => ToggleUsersPanel(), Key.Empty)
}), }),
new MenuBarItem("_User", allUserItems) new MenuBarItem("_User", allUserItems)
]); ]);
@@ -333,13 +382,38 @@ public sealed class MainWindow : Runnable
} }
e.Handled = true; e.Handled = true;
} }
else if (e.KeyCode == CtrlCKey.KeyCode) else if (e.KeyCode == AltQKey.KeyCode)
{ {
_app.RequestStop(); _app.RequestStop();
e.Handled = true; e.Handled = true;
} }
} }
private bool _suppressEmojiReplace;
private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e)
{
if (_suppressEmojiReplace)
return;
var text = _inputField.Text;
if (string.IsNullOrEmpty(text))
return;
var replaced = EmojiHelper.ReplaceEmoji(text);
if (replaced == text)
return;
// Calculate where cursor should land after replacement
var lengthDelta = replaced.Length - text.Length;
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
_suppressEmojiReplace = true;
_inputField.Text = replaced;
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
_suppressEmojiReplace = false;
}
/// <summary> /// <summary>
/// Tab-complete slash commands in the input field. /// Tab-complete slash commands in the input field.
/// </summary> /// </summary>
@@ -386,12 +460,16 @@ public sealed class MainWindow : Runnable
private void OnWindowKeyDown(object? sender, Key e) private void OnWindowKeyDown(object? sender, Key e)
{ {
// Ctrl+C quits from anywhere if (e.KeyCode == AltQKey.KeyCode)
if (e.KeyCode == CtrlCKey.KeyCode)
{ {
_app.RequestStop(); _app.RequestStop();
e.Handled = true; e.Handled = true;
} }
else if (e.KeyCode == F2Key.KeyCode)
{
ToggleUsersPanel();
e.Handled = true;
}
} }
/// <summary> /// <summary>
@@ -430,19 +508,33 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public void AddSystemMessage(string channelName, string text) public void AddSystemMessage(string channelName, string text)
{ {
var time = DateTimeOffset.Now.ToString("HH:mm");
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {text}", ChatColors.SystemAttr)
};
if (!_channelMessages.TryGetValue(channelName, out var messages)) if (!_channelMessages.TryGetValue(channelName, out var messages))
{ {
messages = []; messages = [];
_channelMessages[channelName] = messages; _channelMessages[channelName] = messages;
} }
messages.Add(new ChatLine(segments));
var time = DateTimeOffset.Now.ToString("HH:mm");
var textLines = text.Split('\n');
// First line gets timestamp prefix
messages.Add(new ChatLine(
[
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
]));
// Continuation lines are indented to align
var indent = new string(' ', $"[{time}] ** ".Length);
for (int i = 1; i < textLines.Length; i++)
{
var line = textLines[i].TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) continue;
messages.Add(new ChatLine(
[
new($"{indent}{line}", ChatColors.SystemAttr)
]));
}
if (channelName == _currentChannel) if (channelName == _currentChannel)
{ {
@@ -475,6 +567,32 @@ public sealed class MainWindow : Runnable
} }
} }
/// <summary>
/// Remove all lines associated with a specific message ID.
/// </summary>
public void RemoveMessage(string channelName, Guid messageId)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.RemoveAll(l => l.MessageId == messageId);
if (channelName == _currentChannel)
RefreshMessages();
}
}
/// <summary>
/// Clear all messages from a specific channel.
/// </summary>
public void ClearChannelMessages(string channelName)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.Clear();
if (channelName == _currentChannel)
RefreshMessages();
}
}
/// <summary> /// <summary>
/// Set the list of available channels, storing topics, and refresh the channel list view. /// Set the list of available channels, storing topics, and refresh the channel list view.
/// </summary> /// </summary>
@@ -492,6 +610,30 @@ public sealed class MainWindow : Runnable
RefreshChannelList(); RefreshChannelList();
} }
/// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary>
public void EnsureChannelInList(string channelName)
{
if (_channelNames.Contains(channelName))
return;
_channelNames.Add(channelName);
if (!_channelMessages.ContainsKey(channelName))
_channelMessages[channelName] = [];
RefreshChannelList();
}
/// <summary>
/// Remove a channel from the left panel list.
/// </summary>
public void RemoveChannel(string channelName)
{
_channelNames.Remove(channelName);
_channelTopics.Remove(channelName);
RefreshChannelList();
}
/// <summary> /// <summary>
/// Update the topic for a specific channel. /// Update the topic for a specific channel.
/// </summary> /// </summary>
@@ -515,9 +657,9 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public void UpdateStatusBar(string status) public void UpdateStatusBar(string status)
{ {
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}"; var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}";
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}"; var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}";
_statusLabel.Text = $" {status}{userPart}{channelPart}"; _statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}";
} }
/// <summary> /// <summary>
@@ -585,10 +727,14 @@ public sealed class MainWindow : Runnable
_channelTopics.Clear(); _channelTopics.Clear();
_currentChannel = string.Empty; _currentChannel = string.Empty;
_currentUser = string.Empty; _currentUser = string.Empty;
_channelList.SetSource(new ObservableCollection<string>(_channelNames)); _channelListSource.Update([], [], string.Empty);
_channelList.Source = _channelListSource;
_chatFrame.Title = "Chat"; _chatFrame.Title = "Chat";
_topicLabel.Visible = false; _topicLabel.Visible = false;
_chatFrame.Y = 1; _chatFrame.Y = 1;
_usersListSource.Update([]);
_usersList.Source = _usersListSource;
_usersFrame.Title = "Users";
RefreshMessages(); RefreshMessages();
} }
@@ -640,13 +786,8 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
private void RefreshChannelList() private void RefreshChannelList()
{ {
var displayNames = _channelNames.Select(name => _channelListSource.Update(_channelNames, _channelUnread, _currentChannel);
{ _channelList.Source = _channelListSource;
_channelUnread.TryGetValue(name, out var unread);
return unread > 0 ? $"#{name} ({unread})" : $"#{name}";
}).ToList();
_channelList.SetSource(new ObservableCollection<string>(displayNames));
// Restore selection to current channel // Restore selection to current channel
var idx = _channelNames.IndexOf(_currentChannel); var idx = _channelNames.IndexOf(_currentChannel);
@@ -673,11 +814,66 @@ public sealed class MainWindow : Runnable
} }
} }
/// <summary>
/// Adjusts widths of chat, topic, and input frames based on users panel visibility.
/// </summary>
private void UpdateLayout()
{
var rightMargin = _usersPanelVisible ? UsersPanelWidth : 0;
_chatFrame.Width = Dim.Fill(rightMargin);
_topicLabel.Width = Dim.Fill(rightMargin);
_inputFrame.Width = Dim.Fill(rightMargin);
_usersFrame.Visible = _usersPanelVisible;
SetNeedsDraw();
}
/// <summary>
/// Toggle the online users panel visibility (F2).
/// </summary>
public void ToggleUsersPanel()
{
_usersPanelVisible = !_usersPanelVisible;
UpdateLayout();
}
/// <summary>
/// Update the online users list display.
/// </summary>
public void UpdateOnlineUsers(List<UserPresenceDto> users)
{
var displayItems = users.Select(u =>
{
var statusIcon = u.Status switch
{
UserStatus.Online => "\u25cf", // ●
UserStatus.Away => "\u25cb", // ○
UserStatus.DoNotDisturb => "\u25d0", // ◐
UserStatus.Invisible => "\u25cc", // ◌
_ => " "
};
var name = u.DisplayName ?? u.Username;
var roleTag = u.Role switch
{
ServerRole.Owner => "\u2605", // ★
ServerRole.Admin => "\u2666", // ♦
ServerRole.Mod => "\u2740", // ❀
_ => ""
};
var text = $"{statusIcon} {roleTag}{name}";
var nameColor = ColorHelper.ParseHexColor(u.NicknameColor);
return (text, nameColor);
}).ToList();
_usersListSource.Update(displayItems);
_usersList.Source = _usersListSource;
_usersFrame.Title = $"Users ({users.Count})";
}
/// <summary> /// <summary>
/// Format a message DTO into one or more display lines based on its MessageType. /// Format a message DTO into one or more display lines based on its MessageType.
/// Timestamps are dimmed and sender names are colored. /// Timestamps are dimmed and sender names are colored.
/// </summary> /// </summary>
private static List<ChatLine> FormatMessage(MessageDto message) private List<ChatLine> FormatMessage(MessageDto message)
{ {
var time = message.SentAt.ToLocalTime().ToString("HH:mm"); var time = message.SentAt.ToLocalTime().ToString("HH:mm");
var senderName = message.SenderUsername + ":"; var senderName = message.SenderUsername + ":";
@@ -694,10 +890,10 @@ public sealed class MainWindow : Runnable
{ {
foreach (var artLine in message.Content.Split('\n')) foreach (var artLine in message.Content.Split('\n'))
{ {
// Parse ANSI color codes from colored ASCII art // Parse color tags from colored ASCII art
var trimmed = artLine.TrimEnd('\r'); var trimmed = artLine.TrimEnd('\r');
if (trimmed.Contains('\x1b')) if (ChatLine.HasColorTags(trimmed))
lines.Add(ChatLine.FromAnsi(" " + trimmed)); lines.Add(ChatLine.FromColoredText(" " + trimmed));
else else
lines.Add(new ChatLine($" {trimmed}")); lines.Add(new ChatLine($" {trimmed}"));
} }
@@ -712,18 +908,43 @@ public sealed class MainWindow : Runnable
case MessageType.Text: case MessageType.Text:
default: default:
var contentLines = message.Content.Split('\n'); var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
var contentLines = displayContent.Split('\n');
var firstLine = contentLines[0].TrimEnd('\r'); var firstLine = contentLines[0].TrimEnd('\r');
lines.Add(BuildChatLine(time, senderName, senderColor, $" {firstLine}")); lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
// Continuation lines indented to align with first line's content // Continuation lines indented to align with first line's content
var indent = new string(' ', $"[{time}] {senderName} ".Length); var indent = new string(' ', $"[{time}] {senderName} ".Length);
for (int i = 1; i < contentLines.Length; i++) for (int i = 1; i < contentLines.Length; i++)
{ {
lines.Add(new ChatLine($"{indent}{contentLines[i].TrimEnd('\r')}")); var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
}
// Render link embeds if present
if (message.Embeds is { Count: > 0 })
{
var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80;
foreach (var embed in message.Embeds)
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
} }
break; break;
} }
// Tag all lines with the message ID for deletion support
foreach (var line in lines)
line.MessageId = message.Id;
// Check for @mention of current user
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
{
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
{
foreach (var line in lines)
line.IsMention = true;
}
}
return lines; return lines;
} }
@@ -740,4 +961,98 @@ public sealed class MainWindow : Runnable
}; };
return new ChatLine(segments); return new ChatLine(segments);
} }
/// <summary>
/// Build a chat line with @mention highlighting in the suffix text.
/// </summary>
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
};
segments.AddRange(ChatColors.SplitMentions(suffix));
return new ChatLine(segments);
}
/// <summary>
/// Format a link embed as indented chat lines with a left border bar,
/// text at full width, and optional icon below (preview image).
/// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout.
/// </summary>
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
{
var lines = new List<ChatLine>();
const string border = "\u258f "; // ▏ + space
const int borderCols = 2;
int indentCols = indent.GetColumns();
int textWidth = chatWidth - indentCols - borderCols;
if (textWidth < 20) textWidth = 20;
// Helper: create a bordered text line
void AddTextLine(string text, Attribute? color)
{
lines.Add(new ChatLine(
[
new ChatSegment(indent, null),
new ChatSegment(border, ChatColors.EmbedBorderAttr),
new ChatSegment(text, color)
]));
}
// Site name
if (!string.IsNullOrWhiteSpace(embed.SiteName))
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
// Title
if (!string.IsNullOrWhiteSpace(embed.Title))
{
foreach (var wrapped in WordWrap(embed.Title, textWidth))
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
}
// Description (word-wrapped at full available width)
if (!string.IsNullOrWhiteSpace(embed.Description))
{
foreach (var wrapped in WordWrap(embed.Description, textWidth))
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
}
return lines;
}
/// <summary>
/// Simple word-wrap: splits text into lines that fit within maxCols display columns.
/// </summary>
private static List<string> WordWrap(string text, int maxCols)
{
if (maxCols <= 0)
return [text];
var result = new List<string>();
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var currentLine = "";
foreach (var word in words)
{
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
if (candidate.GetColumns() <= maxCols)
{
currentLine = candidate;
}
else
{
if (currentLine.Length > 0)
result.Add(currentLine);
// If a single word exceeds maxCols, just add it as-is
currentLine = word;
}
}
if (currentLine.Length > 0)
result.Add(currentLine);
return result;
}
} }
+91 -7
View File
@@ -9,7 +9,7 @@ namespace EchoHub.Client.UI;
/// <summary> /// <summary>
/// Result returned from the profile edit dialog. /// Result returned from the profile edit dialog.
/// </summary> /// </summary>
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor); public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath, bool? NotificationSoundEnabled, byte? NotificationVolume);
/// <summary> /// <summary>
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color). /// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
@@ -19,11 +19,11 @@ public sealed class ProfileEditDialog
/// <summary> /// <summary>
/// Shows the profile edit dialog and returns the result, or null if cancelled. /// Shows the profile edit dialog and returns the result, or null if cancelled.
/// </summary> /// </summary>
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor) public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor, bool notificationSoundEnabled = false, byte notificationVolume = 30)
{ {
ProfileEditResult? result = null; ProfileEditResult? result = null;
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 }; var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 26 };
// Display Name // Display Name
var nameLabel = new Label var nameLabel = new Label
@@ -102,20 +102,99 @@ public sealed class ProfileEditDialog
UpdateColorPreview(colorPreview, colorField.Text); UpdateColorPreview(colorPreview, colorField.Text);
}; };
// Avatar
var avatarLabel = new Label
{
Text = "Avatar:",
X = 1,
Y = 10
};
var avatarField = new TextField
{
Text = "",
X = 17,
Y = 10,
Width = Dim.Fill(12)
};
var browseButton = new Button
{
Text = "Browse",
X = Pos.AnchorEnd(10),
Y = 10
};
var avatarHintLabel = new Label
{
Text = "(file path or URL)",
X = 17,
Y = 11
};
avatarHintLabel.SetScheme(new Scheme
{
Normal = new Attribute(Color.DarkGray, Color.Blue)
});
browseButton.Accepting += (s, e) =>
{
e.Handled = true;
var openDialog = new OpenDialog
{
Title = "Select Avatar Image",
OpenMode = OpenMode.File,
};
app.Run(openDialog);
if (openDialog.FilePaths.Count > 0)
{
avatarField.Text = openDialog.FilePaths[0];
}
};
// Notification Sound
var notifCheckbox = new CheckBox
{
Text = "Notification sound on @mention",
X = 1,
Y = 13,
Value = notificationSoundEnabled ? CheckState.Checked : CheckState.UnChecked
};
var volumeLabel = new Label
{
Text = "Volume:",
X = 1,
Y = 15
};
var volumeField = new TextField
{
Text = notificationVolume.ToString(),
X = 17,
Y = 15,
Width = 6
};
var volumeHintLabel = new Label
{
Text = "(0-100)",
X = 24,
Y = 15
};
volumeHintLabel.SetScheme(new Scheme
{
Normal = new Attribute(Color.DarkGray, Color.Blue)
});
// Buttons // Buttons
var saveButton = new Button var saveButton = new Button
{ {
Text = "Save", Text = "Save",
IsDefault = true, IsDefault = true,
X = Pos.Center() - 10, X = Pos.Center() - 10,
Y = 10 Y = 18
}; };
var cancelButton = new Button var cancelButton = new Button
{ {
Text = "Cancel", Text = "Cancel",
X = Pos.Center() + 5, X = Pos.Center() + 5,
Y = 10 Y = 18
}; };
saveButton.Accepting += (s, e) => saveButton.Accepting += (s, e) =>
@@ -123,8 +202,10 @@ public sealed class ProfileEditDialog
var displayName = NullIfEmpty(nameField.Text?.Trim()); var displayName = NullIfEmpty(nameField.Text?.Trim());
var bio = NullIfEmpty(bioField.Text?.Trim()); var bio = NullIfEmpty(bioField.Text?.Trim());
var nicknameColor = NullIfEmpty(colorField.Text?.Trim()); var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
var avatarPath = NullIfEmpty(avatarField.Text?.Trim());
result = new ProfileEditResult(displayName, bio, nicknameColor); byte? volume = byte.TryParse(volumeField.Text, out var v) ? Math.Min(v, (byte)100) : null;
result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath, notifCheckbox.Value == CheckState.Checked, volume);
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -137,7 +218,10 @@ public sealed class ProfileEditDialog
}; };
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField, dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton); colorHintLabel, previewLabel, colorPreview,
avatarLabel, avatarField, browseButton, avatarHintLabel,
notifCheckbox, volumeLabel, volumeField, volumeHintLabel,
saveButton, cancelButton);
nameField.SetFocus(); nameField.SetFocus();
app.Run(dialog); app.Run(dialog);
+26 -5
View File
@@ -140,12 +140,20 @@ public sealed class ProfileViewDialog
dialog.Add(bioView); dialog.Add(bioView);
row += 3; row += 3;
// ASCII Avatar // ASCII Avatar — render with color tags
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii)) if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
{ {
row++; row++;
var avatarLines = profile.AvatarAscii.Split('\n').Length; var rawLines = profile.AvatarAscii.Split('\n');
var avatarHeight = Math.Min(avatarLines + 2, 6); var avatarSource = new ChatListSource();
foreach (var line in rawLines)
{
avatarSource.Add(ChatLine.HasColorTags(line)
? ChatLine.FromColoredText(line)
: new ChatLine(line));
}
var avatarHeight = Math.Min(rawLines.Length + 2, 24);
var avatarFrame = new FrameView var avatarFrame = new FrameView
{ {
Title = "Avatar", Title = "Avatar",
@@ -154,9 +162,22 @@ public sealed class ProfileViewDialog
Width = Dim.Fill(2), Width = Dim.Fill(2),
Height = avatarHeight Height = avatarHeight
}; };
avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 }); var avatarList = new ListView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill(),
Source = avatarSource
};
avatarFrame.Add(avatarList);
dialog.Add(avatarFrame); dialog.Add(avatarFrame);
// Grow dialog to fit avatar
// Grow dialog to fit avatar + widen for art
var artWidth = rawLines.Max(l => ChatLine.HasColorTags(l)
? ChatLine.FromColoredText(l).TextLength
: l.Length);
dialog.Width = Math.Max(50, artWidth + 6);
dialog.Height = row + avatarHeight + 4; dialog.Height = row + avatarHeight + 4;
} }
+10 -1
View File
@@ -4,10 +4,19 @@ public static class HubConstants
{ {
public const string ChatHubPath = "/hubs/chat"; public const string ChatHubPath = "/hubs/chat";
public const string DefaultChannel = "general"; public const string DefaultChannel = "general";
public const int DefaultHistoryCount = 50; public const int DefaultHistoryCount = 100;
public const int MaxMessageLength = 2000; public const int MaxMessageLength = 2000;
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
public const int MaxMessageNewlines = 30;
public const int MaxConsecutiveNewlines = 1;
public const int AsciiArtWidth = 80; public const int AsciiArtWidth = 80;
public const int AsciiArtHeight = 40; public const int AsciiArtHeight = 40;
public const int AsciiArtHeightHalfBlock = 80;
// Link embed constants
public const int EmbedMaxDescriptionLength = 500;
public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB
public const int EmbedFetchTimeoutSeconds = 5;
public const int EmbedMaxUrlsPerMessage = 3;
} }
@@ -9,5 +9,10 @@ public interface IChatBroadcaster
Task SendUserLeftAsync(string channelName, string username); Task SendUserLeftAsync(string channelName, string username);
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null); Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence); Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
Task SendUserKickedAsync(string channelName, string username, string? reason);
Task SendUserBannedAsync(string username, string? reason);
Task SendMessageDeletedAsync(string channelName, Guid messageId);
Task SendChannelNukedAsync(string channelName);
Task SendErrorAsync(string connectionId, string message); Task SendErrorAsync(string connectionId, string message);
Task ForceDisconnectUserAsync(List<string> connectionIds, string reason);
} }
@@ -12,5 +12,10 @@ public interface IEchoHubClient
Task UserLeft(string channelName, string username); Task UserLeft(string channelName, string username);
Task ChannelUpdated(ChannelDto channel); Task ChannelUpdated(ChannelDto channel);
Task UserStatusChanged(UserPresenceDto presence); Task UserStatusChanged(UserPresenceDto presence);
Task UserKicked(string channelName, string username, string? reason);
Task UserBanned(string username, string? reason);
Task MessageDeleted(string channelName, Guid messageId);
Task ChannelNuked(string channelName);
Task ForceDisconnect(string reason);
Task Error(string message); Task Error(string message);
} }
@@ -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);
}
+11 -2
View File
@@ -11,12 +11,14 @@ public record MessageDto(
MessageType Type, MessageType Type,
string? AttachmentUrl, string? AttachmentUrl,
string? AttachmentFileName, string? AttachmentFileName,
DateTimeOffset SentAt); DateTimeOffset SentAt,
List<EmbedDto>? Embeds = null);
public record ChannelDto( public record ChannelDto(
Guid Id, Guid Id,
string Name, string Name,
string? Topic, string? Topic,
bool IsPublic,
int MessageCount, int MessageCount,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);
@@ -30,8 +32,15 @@ public record UserDto(
public record SendMessageRequest(string ChannelName, string Content); public record SendMessageRequest(string ChannelName, string Content);
public record CreateChannelRequest(string Name, string? Topic = null); public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true);
public record UpdateTopicRequest(string? Topic); public record UpdateTopicRequest(string? Topic);
public record SendUrlRequest(string Url); public record SendUrlRequest(string Url);
public record EmbedDto(
string? SiteName,
string? Title,
string? Description,
string? ImageAscii,
string Url);
+8
View File
@@ -0,0 +1,8 @@
using EchoHub.Core.Models;
namespace EchoHub.Core.DTOs;
public record AssignRoleRequest(string Username, ServerRole Role);
public record MuteRequest(string? Reason = null, int? DurationMinutes = null);
public record BanRequest(string? Reason = null);
public record KickRequest(string? Reason = null);
+3 -1
View File
@@ -11,6 +11,7 @@ public record UserProfileDto(
string? AvatarAscii, string? AvatarAscii,
UserStatus Status, UserStatus Status,
string? StatusMessage, string? StatusMessage,
ServerRole Role,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset LastSeenAt); DateTimeOffset LastSeenAt);
@@ -28,6 +29,7 @@ public record UserPresenceDto(
string? DisplayName, string? DisplayName,
string? NicknameColor, string? NicknameColor,
UserStatus Status, UserStatus Status,
string? StatusMessage); string? StatusMessage,
ServerRole Role);
public record AvatarUploadResponse(string AvatarAscii); public record AvatarUploadResponse(string AvatarAscii);
+2
View File
@@ -1,3 +1,5 @@
namespace EchoHub.Core.DTOs; namespace EchoHub.Core.DTOs;
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels); public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
public record EncryptionKeyResponse(string Key);
+1
View File
@@ -5,6 +5,7 @@ public class Channel
public Guid Id { get; set; } public Guid Id { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public string? Topic { get; set; } public string? Topic { get; set; }
public bool IsPublic { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public Guid CreatedByUserId { get; set; } public Guid CreatedByUserId { get; set; }
@@ -0,0 +1,8 @@
namespace EchoHub.Core.Models;
public class ChannelMembership
{
public Guid UserId { get; set; }
public Guid ChannelId { get; set; }
public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow;
}
+1
View File
@@ -7,6 +7,7 @@ public class Message
public MessageType Type { get; set; } = MessageType.Text; public MessageType Type { get; set; } = MessageType.Text;
public string? AttachmentUrl { get; set; } public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; } public string? AttachmentFileName { get; set; }
public string? EmbedJson { get; set; }
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
public Guid ChannelId { get; set; } public Guid ChannelId { get; set; }
+9
View File
@@ -0,0 +1,9 @@
namespace EchoHub.Core.Models;
public enum ServerRole
{
Member = 0,
Mod = 1,
Admin = 2,
Owner = 3
}
+4
View File
@@ -11,6 +11,10 @@ public class User
public string? AvatarAscii { get; set; } public string? AvatarAscii { get; set; }
public UserStatus Status { get; set; } = UserStatus.Online; public UserStatus Status { get; set; } = UserStatus.Online;
public string? StatusMessage { get; set; } public string? StatusMessage { get; set; }
public ServerRole Role { get; set; } = ServerRole.Member;
public bool IsMuted { get; set; }
public DateTimeOffset? MutedUntil { get; set; }
public bool IsBanned { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
} }
+59 -2
View File
@@ -6,15 +6,19 @@ namespace EchoHub.Server.Irc;
public class IrcBroadcaster : IChatBroadcaster public class IrcBroadcaster : IChatBroadcaster
{ {
private readonly IrcGatewayService _gateway; private readonly IrcGatewayService _gateway;
private readonly IMessageEncryptionService _encryption;
public IrcBroadcaster(IrcGatewayService gateway) public IrcBroadcaster(IrcGatewayService gateway, IMessageEncryptionService encryption)
{ {
_gateway = gateway; _gateway = gateway;
_encryption = encryption;
} }
public async Task SendMessageToChannelAsync(string channelName, MessageDto message) 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)) foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{ {
@@ -62,6 +66,41 @@ public class IrcBroadcaster : IChatBroadcaster
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task SendUserKickedAsync(string channelName, string username, string? reason)
{
var reasonText = reason is not null ? $" :{reason}" : "";
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
await conn.SendAsync($":{_gateway.Options.ServerName} KICK #{channelName} {username}{reasonText}");
}
}
public async Task SendUserBannedAsync(string username, string? reason)
{
var reasonText = reason ?? "You have been banned.";
foreach (var conn in _gateway.GetAllConnections())
{
if (conn.Nickname == username)
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {username} :You have been banned: {reasonText}");
}
}
public async Task SendMessageDeletedAsync(string channelName, Guid messageId)
{
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :Message {messageId} was deleted in #{channelName}");
}
}
public async Task SendChannelNukedAsync(string channelName)
{
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :All messages in #{channelName} have been cleared");
}
}
public async Task SendErrorAsync(string connectionId, string message) public async Task SendErrorAsync(string connectionId, string message)
{ {
if (!connectionId.StartsWith("irc-")) return; if (!connectionId.StartsWith("irc-")) return;
@@ -71,4 +110,22 @@ public class IrcBroadcaster : IChatBroadcaster
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}"); await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}");
} }
} }
public async Task ForceDisconnectUserAsync(List<string> connectionIds, string reason)
{
foreach (var connId in connectionIds)
{
if (!connId.StartsWith("irc-")) continue;
if (_gateway.Connections.TryGetValue(connId, out var conn))
{
try
{
await conn.SendAsync($"ERROR :Closing Link: {reason}");
await conn.DisposeAsync();
}
catch { /* connection may already be closed */ }
}
}
}
} }
@@ -37,6 +37,11 @@ public sealed class IrcGatewayService : BackgroundService
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName)); .Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
} }
public IEnumerable<IrcClientConnection> GetAllConnections()
{
return _connections.Values.Where(c => c.IsAuthenticated);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
await Task.Yield(); await Task.Yield();
+66 -2
View File
@@ -1,10 +1,11 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
namespace EchoHub.Server.Irc; namespace EchoHub.Server.Irc;
public static class IrcMessageFormatter public static partial class IrcMessageFormatter
{ {
private const int MaxIrcLineContentBytes = 400; private const int MaxIrcLineContentBytes = 400;
@@ -22,6 +23,13 @@ public static class IrcMessageFormatter
case MessageType.Text: case MessageType.Text:
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes)) foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
// Append embed previews if present
if (message.Embeds is { Count: > 0 })
{
foreach (var embed in message.Embeds)
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
}
break; break;
case MessageType.Image: case MessageType.Image:
@@ -33,7 +41,7 @@ public static class IrcMessageFormatter
{ {
var trimmed = line.TrimEnd('\r'); var trimmed = line.TrimEnd('\r');
if (trimmed.Length > 0) if (trimmed.Length > 0)
lines.Add($"{prefix} PRIVMSG {ircChannel} :{trimmed}"); lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
} }
break; break;
@@ -45,6 +53,62 @@ public static class IrcMessageFormatter
return lines; return lines;
} }
/// <summary>
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
/// </summary>
private static List<string> FormatEmbed(string prefix, string ircChannel, EmbedDto embed)
{
var lines = new List<string>();
var header = new List<string>();
if (!string.IsNullOrWhiteSpace(embed.SiteName))
header.Add(embed.SiteName);
if (!string.IsNullOrWhiteSpace(embed.Title))
header.Add(embed.Title);
if (header.Count > 0)
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {string.Join(" \u2014 ", header)}");
if (!string.IsNullOrWhiteSpace(embed.Description))
{
var desc = embed.Description.Length > 200
? embed.Description[..197] + "..."
: embed.Description;
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {desc}");
}
return lines;
}
/// <summary>
/// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients.
/// Also passes through content that already uses ANSI codes unchanged.
/// </summary>
public static string ColorTagsToAnsi(string text)
{
if (!text.Contains('{'))
return text;
return ColorTagRegex().Replace(text, match =>
{
if (match.Groups[1].Success) // {X} reset
return "\x1b[0m";
if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB}
{
var hex = match.Groups[3].Value;
var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16);
var code = match.Groups[2].Value == "F" ? "38" : "48";
return $"\x1b[{code};2;{r};{g};{b}m";
}
return match.Value;
});
}
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
private static partial Regex ColorTagRegex();
/// <summary> /// <summary>
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
/// </summary> /// </summary>
@@ -37,6 +37,7 @@ public class JwtTokenService
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new("username", user.Username), new("username", user.Username),
new("display_name", user.DisplayName ?? user.Username), new("display_name", user.DisplayName ?? user.Username),
new("role", user.Role.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
]; ];
@@ -22,6 +22,7 @@ public class AuthController : ControllerBase
_db = db; _db = db;
_jwt = jwt; _jwt = jwt;
} }
[HttpPost("register")] [HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request) public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{ {
@@ -42,12 +43,16 @@ public class AuthController : ControllerBase
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername)) if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
return Conflict(new ErrorResponse("Username is already taken.")); return Conflict(new ErrorResponse("Username is already taken."));
// First registered user on the server becomes the Owner
var isFirstUser = !await _db.Users.AnyAsync();
var user = new User var user = new User
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Username = normalizedUsername, Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
DisplayName = request.DisplayName?.Trim(), DisplayName = request.DisplayName?.Trim(),
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
}; };
_db.Users.Add(user); _db.Users.Add(user);
@@ -80,6 +85,9 @@ public class AuthController : ControllerBase
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ErrorResponse("Invalid username or password.")); return Unauthorized(new ErrorResponse("Invalid username or password."));
if (user.IsBanned)
return Unauthorized(new ErrorResponse("Your account has been banned."));
user.LastSeenAt = DateTimeOffset.UtcNow; user.LastSeenAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -23,29 +23,41 @@ public class ChannelsController : ControllerBase
private readonly ImageToAsciiService _asciiService; private readonly ImageToAsciiService _asciiService;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IChatService _chatService; private readonly IChatService _chatService;
private readonly IMessageEncryptionService _encryption;
public ChannelsController( public ChannelsController(
EchoHubDbContext db, EchoHubDbContext db,
FileStorageService fileStorage, FileStorageService fileStorage,
ImageToAsciiService asciiService, ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IChatService chatService) IChatService chatService,
IMessageEncryptionService encryption)
{ {
_db = db; _db = db;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_asciiService = asciiService; _asciiService = asciiService;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_chatService = chatService; _chatService = chatService;
_encryption = encryption;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
offset = Math.Max(0, offset); offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100); limit = Math.Clamp(limit, 1, 100);
var total = await _db.Channels.CountAsync(); // 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 _db.Channels var channels = await query
.OrderBy(c => c.Name) .OrderBy(c => c.Name)
.Skip(offset) .Skip(offset)
.Take(limit) .Take(limit)
@@ -53,6 +65,7 @@ public class ChannelsController : ControllerBase
c.Id, c.Id,
c.Name, c.Name,
c.Topic, c.Topic,
c.IsPublic,
c.Messages.Count, c.Messages.Count,
c.CreatedAt)) c.CreatedAt))
.ToListAsync(); .ToListAsync();
@@ -83,14 +96,24 @@ public class ChannelsController : ControllerBase
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = channelName, Name = channelName,
Topic = request.Topic?.Trim(), Topic = request.Topic?.Trim(),
IsPublic = request.IsPublic,
CreatedByUserId = Guid.Parse(userIdClaim), CreatedByUserId = Guid.Parse(userIdClaim),
}; };
_db.Channels.Add(channel); _db.Channels.Add(channel);
// Creator automatically becomes a member
_db.ChannelMemberships.Add(new ChannelMembership
{
UserId = Guid.Parse(userIdClaim),
ChannelId = channel.Id,
});
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
await _chatService.BroadcastChannelUpdatedAsync(dto); if (channel.IsPublic)
await _chatService.BroadcastChannelUpdatedAsync(dto);
return Created($"/api/channels/{channelName}", dto); return Created($"/api/channels/{channelName}", dto);
} }
@@ -118,7 +141,7 @@ public class ChannelsController : ControllerBase
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt); var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
await _chatService.BroadcastChannelUpdatedAsync(dto, channelName); await _chatService.BroadcastChannelUpdatedAsync(dto, channelName);
return Ok(dto); return Ok(dto);
@@ -141,8 +164,10 @@ public class ChannelsController : ControllerBase
if (dbChannel is null) if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) var userId = Guid.Parse(userIdClaim);
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel.")); 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); _db.Channels.Remove(dbChannel);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@@ -152,7 +177,7 @@ public class ChannelsController : ControllerBase
[HttpPost("{channel}/upload")] [HttpPost("{channel}/upload")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
public async Task<IActionResult> Upload(string channel) public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username"); var usernameClaim = User.FindFirstValue("username");
@@ -188,8 +213,9 @@ public class ChannelsController : ControllerBase
if (isImage) if (isImage)
{ {
var (w, h) = ImageToAsciiService.GetDimensions(size);
using var imageStream = System.IO.File.OpenRead(filePath); using var imageStream = System.IO.File.OpenRead(filePath);
content = _asciiService.ConvertToAscii(imageStream); content = _asciiService.ConvertToAscii(imageStream, w, h);
} }
else else
{ {
@@ -198,11 +224,12 @@ public class ChannelsController : ControllerBase
var attachmentUrl = $"/api/files/{fileId}"; var attachmentUrl = $"/api/files/{fileId}";
var sender = await _db.Users.FindAsync(userId); var sender = await _db.Users.FindAsync(userId);
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
var message = new Message var message = new Message
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Content = content, Content = dbContent,
Type = messageType, Type = messageType,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName, AttachmentFileName = file.FileName,
@@ -215,9 +242,10 @@ public class ChannelsController : ControllerBase
_db.Messages.Add(message); _db.Messages.Add(message);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
// Encrypt for transport — clients decrypt
var messageDto = new MessageDto( var messageDto = new MessageDto(
message.Id, message.Id,
message.Content, _encryption.Encrypt(content),
message.SenderUsername, message.SenderUsername,
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
@@ -233,7 +261,7 @@ public class ChannelsController : ControllerBase
[HttpPost("{channel}/send-url")] [HttpPost("{channel}/send-url")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request) public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username"); var usernameClaim = User.FindFirstValue("username");
@@ -308,18 +336,20 @@ public class ChannelsController : ControllerBase
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName); var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
string content; string content;
var (w, h) = ImageToAsciiService.GetDimensions(size);
using (var imageStream = System.IO.File.OpenRead(filePath)) using (var imageStream = System.IO.File.OpenRead(filePath))
{ {
content = _asciiService.ConvertToAscii(imageStream); content = _asciiService.ConvertToAscii(imageStream, w, h);
} }
var attachmentUrl = $"/api/files/{fileId}"; var attachmentUrl = $"/api/files/{fileId}";
var sender = await _db.Users.FindAsync(userId); var sender = await _db.Users.FindAsync(userId);
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
var message = new Message var message = new Message
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Content = content, Content = dbContent,
Type = MessageType.Image, Type = MessageType.Image,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName, AttachmentFileName = fileName,
@@ -332,9 +362,10 @@ public class ChannelsController : ControllerBase
_db.Messages.Add(message); _db.Messages.Add(message);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
// Encrypt for transport — clients decrypt
var messageDto = new MessageDto( var messageDto = new MessageDto(
message.Id, message.Id,
message.Content, _encryption.Encrypt(content),
message.SenderUsername, message.SenderUsername,
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
@@ -18,6 +18,7 @@ public class FilesController : ControllerBase
{ {
_fileStorage = fileStorage; _fileStorage = fileStorage;
} }
[HttpGet("{fileId}")] [HttpGet("{fileId}")]
public IActionResult GetFile(string fileId) public IActionResult GetFile(string fileId)
{ {
@@ -0,0 +1,266 @@
using System.Security.Claims;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/moderation")]
[Authorize]
[EnableRateLimiting("general")]
public class ModerationController : ControllerBase
{
private readonly EchoHubDbContext _db;
private readonly IChatService _chatService;
private readonly PresenceTracker _presenceTracker;
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
public ModerationController(
EchoHubDbContext db,
IChatService chatService,
PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters)
{
_db = db;
_chatService = chatService;
_presenceTracker = presenceTracker;
_broadcasters = broadcasters;
}
[HttpPost("role")]
public async Task<IActionResult> AssignRole([FromBody] AssignRoleRequest request)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
if (request.Role == ServerRole.Owner)
return BadRequest(new ErrorResponse("Cannot assign the Owner role."));
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{request.Username}' not found."));
if (target.Role == ServerRole.Owner)
return BadRequest(new ErrorResponse("Cannot change the server owner's role."));
if (request.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
target.Role = request.Role;
await _db.SaveChangesAsync();
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
}
[HttpPost("kick/{username}")]
public async Task<IActionResult> KickUser(string username, [FromBody] KickRequest? request = null)
{
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher role."));
// Broadcast kick to all channels the user is in, then clean up presence
var channels = _presenceTracker.GetChannelsForUser(target.Username);
foreach (var channel in channels)
{
await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason));
}
// Remove from presence tracker and force disconnect all connections
var reason = request?.Reason ?? "You have been kicked from the server.";
await ForceDisconnectAndCleanupAsync(target.Username, reason);
return Ok(new { Message = $"{target.Username} has been kicked." });
}
[HttpPost("ban/{username}")]
public async Task<IActionResult> BanUser(string username, [FromBody] BanRequest? request = null)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot ban a user with equal or higher role."));
target.IsBanned = true;
await _db.SaveChangesAsync();
// Broadcast ban notification, then force disconnect
await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason));
var reason = request?.Reason ?? "You have been banned from this server.";
await ForceDisconnectAndCleanupAsync(target.Username, reason);
return Ok(new { Message = $"{target.Username} has been banned." });
}
[HttpPost("unban/{username}")]
public async Task<IActionResult> UnbanUser(string username)
{
var (_, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
target.IsBanned = false;
await _db.SaveChangesAsync();
return Ok(new { Message = $"{target.Username} has been unbanned." });
}
[HttpPost("mute/{username}")]
public async Task<IActionResult> MuteUser(string username, [FromBody] MuteRequest? request = null)
{
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot mute a user with equal or higher role."));
target.IsMuted = true;
target.MutedUntil = request?.DurationMinutes is > 0
? DateTimeOffset.UtcNow.AddMinutes(request.DurationMinutes.Value)
: null;
await _db.SaveChangesAsync();
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
}
[HttpPost("unmute/{username}")]
public async Task<IActionResult> UnmuteUser(string username)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
target.IsMuted = false;
target.MutedUntil = null;
await _db.SaveChangesAsync();
return Ok(new { Message = $"{target.Username} has been unmuted." });
}
[HttpDelete("messages/{messageId:guid}")]
public async Task<IActionResult> DeleteMessage(Guid messageId)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var message = await _db.Messages
.Include(m => m.Channel)
.FirstOrDefaultAsync(m => m.Id == messageId);
if (message is null)
return NotFound(new ErrorResponse("Message not found."));
var channelName = message.Channel!.Name;
_db.Messages.Remove(message);
await _db.SaveChangesAsync();
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
return Ok(new { Message = "Message deleted." });
}
[HttpDelete("channels/{channel}/nuke")]
public async Task<IActionResult> NukeChannel(string channel)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var channelName = channel.ToLowerInvariant().Trim();
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync();
_db.Messages.RemoveRange(messages);
await _db.SaveChangesAsync();
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
}
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
if (caller is null)
return (null, Unauthorized(new ErrorResponse("User not found.")));
if (caller.Role < minimumRole)
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
return (caller, null);
}
/// <summary>
/// Remove user from presence tracking, broadcast their departure from all channels,
/// send a ForceDisconnect signal, and update their DB status.
/// </summary>
private async Task ForceDisconnectAndCleanupAsync(string username, string reason)
{
var (connectionIds, channels) = _presenceTracker.ForceRemoveUser(username);
// Notify remaining users that this person left each channel
foreach (var channel in channels)
{
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channel, username));
}
// Signal the user's clients to disconnect
if (connectionIds.Count > 0)
{
await BroadcastToAllAsync(b => b.ForceDisconnectUserAsync(connectionIds, reason));
}
// Mark user offline in DB
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is not null)
{
user.Status = UserStatus.Invisible;
user.LastSeenAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync();
}
}
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
{
foreach (var broadcaster in _broadcasters)
{
try { await action(broadcaster); }
catch { /* logged by broadcaster */ }
}
}
}
@@ -1,6 +1,8 @@
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
@@ -17,6 +19,7 @@ public class ServerController : ControllerBase
_db = db; _db = db;
_config = config; _config = config;
} }
[HttpGet("info")] [HttpGet("info")]
public async Task<IActionResult> GetInfo() public async Task<IActionResult> GetInfo()
{ {
@@ -31,4 +34,17 @@ public class ServerController : ControllerBase
return Ok(status); 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));
}
} }
@@ -24,6 +24,7 @@ public class UsersController : ControllerBase
_db = db; _db = db;
_asciiService = asciiService; _asciiService = asciiService;
} }
[HttpGet("{username}/profile")] [HttpGet("{username}/profile")]
public async Task<IActionResult> GetProfile(string username) public async Task<IActionResult> GetProfile(string username)
{ {
@@ -120,6 +121,7 @@ public class UsersController : ControllerBase
user.AvatarAscii, user.AvatarAscii,
user.Status, user.Status,
user.StatusMessage, user.StatusMessage,
user.Role,
user.CreatedAt, user.CreatedAt,
user.LastSeenAt); user.LastSeenAt);
} }
+21 -1
View File
@@ -11,6 +11,7 @@ public class EchoHubDbContext : DbContext
public DbSet<Channel> Channels => Set<Channel>(); public DbSet<Channel> Channels => Set<Channel>();
public DbSet<Message> Messages => Set<Message>(); public DbSet<Message> Messages => Set<Message>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -34,6 +35,7 @@ public class EchoHubDbContext : DbContext
entity.Property(u => u.NicknameColor).HasMaxLength(7); entity.Property(u => u.NicknameColor).HasMaxLength(7);
entity.Property(u => u.AvatarAscii).HasMaxLength(10000); entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
entity.Property(u => u.StatusMessage).HasMaxLength(100); entity.Property(u => u.StatusMessage).HasMaxLength(100);
entity.Property(u => u.Role).HasConversion<int>();
}); });
modelBuilder.Entity<Channel>(entity => modelBuilder.Entity<Channel>(entity =>
@@ -53,10 +55,28 @@ public class EchoHubDbContext : DbContext
{ {
entity.HasKey(m => m.Id); entity.HasKey(m => m.Id);
entity.HasIndex(m => m.SentAt); 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.SenderUsername).IsRequired().HasMaxLength(50);
entity.Property(m => m.AttachmentUrl).HasMaxLength(500); entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
entity.Property(m => m.AttachmentFileName).HasMaxLength(255); entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON
});
modelBuilder.Entity<ChannelMembership>(entity =>
{
entity.HasKey(cm => new { cm.UserId, cm.ChannelId });
entity.HasIndex(cm => cm.UserId);
entity.HasIndex(cm => cm.ChannelId);
entity.HasOne<Channel>()
.WithMany()
.HasForeignKey(cm => cm.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<User>()
.WithMany()
.HasForeignKey(cm => cm.UserId)
.OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<RefreshToken>(entity => modelBuilder.Entity<RefreshToken>(entity =>
@@ -0,0 +1,222 @@
// <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("20260219162414_AddModerationRoles")]
partial class AddModerationRoles
{
/// <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<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.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(2000)
.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.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,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddModerationRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBanned",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsMuted",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<long>(
name: "MutedUntil",
table: "Users",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Role",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBanned",
table: "Users");
migrationBuilder.DropColumn(
name: "IsMuted",
table: "Users");
migrationBuilder.DropColumn(
name: "MutedUntil",
table: "Users");
migrationBuilder.DropColumn(
name: "Role",
table: "Users");
}
}
}
@@ -0,0 +1,225 @@
// <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("20260219172834_AddChannelIsPublic")]
partial class AddChannelIsPublic
{
/// <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.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(2000)
.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.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,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddChannelIsPublic : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsPublic",
table: "Channels",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsPublic",
table: "Channels");
}
}
}
@@ -0,0 +1,260 @@
// <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("20260219181720_AddChannelMembership")]
partial class AddChannelMembership
{
/// <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(2000)
.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,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddChannelMembership : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ChannelMemberships",
columns: table => new
{
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
ChannelId = table.Column<Guid>(type: "TEXT", nullable: false),
JoinedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelMemberships", x => new { x.UserId, x.ChannelId });
table.ForeignKey(
name: "FK_ChannelMemberships_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChannelMemberships_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChannelMemberships_ChannelId",
table: "ChannelMemberships",
column: "ChannelId");
migrationBuilder.CreateIndex(
name: "IX_ChannelMemberships_UserId",
table: "ChannelMemberships",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelMemberships");
}
}
}
@@ -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("20260219201704_AddMessageEmbed")]
partial class AddMessageEmbed
{
/// <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(2000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(8000)
.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,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddMessageEmbed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "EmbedJson",
table: "Messages",
type: "TEXT",
maxLength: 8000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EmbedJson",
table: "Messages");
}
}
}
@@ -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)
{
}
}
}
@@ -29,6 +29,9 @@ namespace EchoHub.Server.Data.Migrations
b.Property<Guid>("CreatedByUserId") b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -46,6 +49,26 @@ namespace EchoHub.Server.Data.Migrations
b.ToTable("Channels"); 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 => modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -65,7 +88,11 @@ namespace EchoHub.Server.Data.Migrations
b.Property<string>("Content") b.Property<string>("Content")
.IsRequired() .IsRequired()
.HasMaxLength(2000) .HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("SenderUserId") b.Property<Guid>("SenderUserId")
@@ -144,9 +171,18 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt") b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor") b.Property<string>("NicknameColor")
.HasMaxLength(7) .HasMaxLength(7)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -155,6 +191,9 @@ namespace EchoHub.Server.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -175,6 +214,21 @@ namespace EchoHub.Server.Data.Migrations
b.ToTable("Users"); 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 => modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{ {
b.HasOne("EchoHub.Core.Models.Channel", "Channel") b.HasOne("EchoHub.Core.Models.Channel", "Channel")
+11
View File
@@ -106,8 +106,12 @@ while (true)
builder.Services.AddSingleton<PresenceTracker>(); builder.Services.AddSingleton<PresenceTracker>();
builder.Services.AddSingleton<ImageToAsciiService>(); builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>(); builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddSingleton<LinkEmbedService>();
builder.Services.AddHostedService<ServerDirectoryService>(); builder.Services.AddHostedService<ServerDirectoryService>();
// ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
// ── Chat Service + Broadcasters ───────────────────────────────────── // ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>(); builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IChatService, ChatService>(); builder.Services.AddSingleton<IChatService, ChatService>();
@@ -121,6 +125,13 @@ while (true)
client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB
}); });
builder.Services.AddHttpClient("OgFetch", client =>
{
client.Timeout = TimeSpan.FromSeconds(5);
client.MaxResponseContentBufferSize = 256 * 1024; // 256 KB
client.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub/1.0 (Link Preview Bot)");
});
// ── Rate Limiting ──────────────────────────────────────────────────── // ── Rate Limiting ────────────────────────────────────────────────────
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
+143 -23
View File
@@ -1,3 +1,4 @@
using System.Text.Json;
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.Contracts; using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
@@ -14,17 +15,23 @@ public class ChatService : IChatService
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presenceTracker; private readonly PresenceTracker _presenceTracker;
private readonly IEnumerable<IChatBroadcaster> _broadcasters; private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly LinkEmbedService _embedService;
private readonly IMessageEncryptionService _encryption;
private readonly ILogger<ChatService> _logger; private readonly ILogger<ChatService> _logger;
public ChatService( public ChatService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker, PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters, IEnumerable<IChatBroadcaster> broadcasters,
LinkEmbedService embedService,
IMessageEncryptionService encryption,
ILogger<ChatService> logger) ILogger<ChatService> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_presenceTracker = presenceTracker; _presenceTracker = presenceTracker;
_broadcasters = broadcasters; _broadcasters = broadcasters;
_embedService = embedService;
_encryption = encryption;
_logger = logger; _logger = logger;
} }
@@ -72,7 +79,8 @@ public class ChatService : IChatService
user.DisplayName, user.DisplayName,
user.NicknameColor, user.NicknameColor,
UserStatus.Invisible, UserStatus.Invisible,
user.StatusMessage); user.StatusMessage,
user.Role);
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence)); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
} }
@@ -97,6 +105,19 @@ public class ChatService : IChatService
if (channel is null) if (channel is null)
return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); 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();
}
var isNewJoin = _presenceTracker.JoinChannel(username, channelName); var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
if (isNewJoin) if (isNewJoin)
@@ -124,12 +145,22 @@ public class ChatService : IChatService
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return "Invalid channel name."; 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."; 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."; return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
// Sanitize on plaintext: collapse excessive newlines
plaintext = SanitizeNewlines(plaintext);
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
@@ -139,30 +170,65 @@ public class ChatService : IChatService
var sender = await db.Users.FindAsync(userId); var sender = await db.Users.FindAsync(userId);
// Check mute status
if (sender is not null && sender.IsMuted)
{
if (sender.MutedUntil.HasValue && sender.MutedUntil.Value <= DateTimeOffset.UtcNow)
{
sender.IsMuted = false;
sender.MutedUntil = null;
await db.SaveChangesAsync();
}
else
{
return "You are muted and cannot send messages.";
}
}
// Attempt to fetch link embeds for URLs in the plaintext message
List<EmbedDto>? embeds = null;
try
{
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 var message = new Message
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Content = content, Content = dbContent,
Type = MessageType.Text, Type = MessageType.Text,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = channel.Id, ChannelId = channel.Id,
SenderUserId = userId, SenderUserId = userId,
SenderUsername = username, SenderUsername = username,
EmbedJson = dbEmbedJson,
}; };
db.Messages.Add(message); db.Messages.Add(message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Broadcast encrypted for SignalR clients; IRC broadcaster gets plaintext
var encryptedContent = _encryption.Encrypt(plaintext);
var messageDto = new MessageDto( var messageDto = new MessageDto(
message.Id, message.Id,
message.Content, encryptedContent,
message.SenderUsername, message.SenderUsername,
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
MessageType.Text, MessageType.Text,
null, null,
null, null,
message.SentAt); message.SentAt,
embeds);
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
@@ -203,7 +269,8 @@ public class ChatService : IChatService
user.DisplayName, user.DisplayName,
user.NicknameColor, user.NicknameColor,
status, status,
statusMessage); statusMessage,
user.Role);
var channels = _presenceTracker.GetChannelsForUser(username); var channels = _presenceTracker.GetChannelsForUser(username);
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
@@ -226,7 +293,8 @@ public class ChatService : IChatService
u.DisplayName, u.DisplayName,
u.NicknameColor, u.NicknameColor,
u.Status, u.Status,
u.StatusMessage)) u.StatusMessage,
u.Role))
.ToListAsync(); .ToListAsync();
} }
@@ -264,7 +332,7 @@ public class ChatService : IChatService
return new UserProfileDto( return new UserProfileDto(
user.Id, user.Username, user.DisplayName, user.Bio, user.Id, user.Username, user.DisplayName, user.Bio,
user.NicknameColor, user.AvatarAscii, user.Status, user.NicknameColor, user.AvatarAscii, user.Status,
user.StatusMessage, user.CreatedAt, user.LastSeenAt); user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
} }
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
@@ -312,32 +380,84 @@ public class ChatService : IChatService
return (user.Id, user.Username); return (user.Id, user.Username);
} }
private static async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) /// <summary>
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
/// </summary>
private static string SanitizeNewlines(string content)
{
// Normalize \r\n → \n
content = content.Replace("\r\n", "\n").Replace('\r', '\n');
// Collapse consecutive blank/whitespace-only lines into max 1 blank line
var lines = content.Split('\n');
var result = new List<string>(lines.Length);
int consecutiveBlanks = 0;
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
consecutiveBlanks++;
if (consecutiveBlanks <= HubConstants.MaxConsecutiveNewlines)
result.Add(line);
}
else
{
consecutiveBlanks = 0;
result.Add(line);
}
}
// Cap total lines
if (result.Count > HubConstants.MaxMessageNewlines)
result = result.Take(HubConstants.MaxMessageNewlines).ToList();
return string.Join('\n', result);
}
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
{ {
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) if (channel is null)
return []; return [];
var messages = await db.Messages var raw = await db.Messages
.Where(m => m.ChannelId == channel.Id) .Where(m => m.ChannelId == channel.Id)
.OrderByDescending(m => m.SentAt) .OrderByDescending(m => m.SentAt)
.Take(count) .Take(count)
.Join(db.Users, .Join(db.Users,
m => m.SenderUserId, m => m.SenderUserId,
u => u.Id, u => u.Id,
(m, u) => new MessageDto( (m, u) => new { m, u.NicknameColor })
m.Id,
m.Content,
m.SenderUsername,
u.NicknameColor,
channelName,
m.Type,
m.AttachmentUrl,
m.AttachmentFileName,
m.SentAt))
.ToListAsync(); .ToListAsync();
messages.Reverse(); raw.Reverse();
return messages;
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 (embedJsonPlain is not null)
{
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(embedJsonPlain); }
catch { /* ignore malformed JSON */ }
}
// Encrypt for transport — client decrypts
return new MessageDto(
x.m.Id,
_encryption.Encrypt(plaintext),
x.m.SenderUsername,
x.NicknameColor,
channelName,
x.m.Type,
x.m.AttachmentUrl,
x.m.AttachmentFileName,
x.m.SentAt,
embeds);
}).ToList();
} }
} }
@@ -8,47 +8,81 @@ namespace EchoHub.Server.Services;
public class ImageToAsciiService public class ImageToAsciiService
{ {
private static readonly char[] AsciiChars = " .:-=+*#%@".ToCharArray(); /// <summary>
/// Returns (width, height) dimensions for the given size code.
/// s = small (40x40), m = medium/default (80x80), l = large (120x120).
/// </summary>
public static (int Width, int Height) GetDimensions(string? size) => size?.ToLowerInvariant() switch
{
"s" => (40, 40),
"l" => (120, 120),
_ => (HubConstants.AsciiArtWidth, HubConstants.AsciiArtHeightHalfBlock),
};
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeight) /// <summary>
/// Converts an image to ASCII art using half-block characters (▀▄█) with
/// printable color tags for 2x vertical resolution.
/// Each character cell represents two vertical pixels.
/// Format: {F:RRGGBB} foreground, {B:RRGGBB} background, {X} reset.
/// Uses only printable ASCII — no terminal escape bytes.
/// </summary>
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock)
{ {
using var image = Image.Load<Rgba32>(imageStream); using var image = Image.Load<Rgba32>(imageStream);
// Ensure height is even for pair processing
if (height % 2 != 0) height++;
image.Mutate(x => x.Resize(width, height)); image.Mutate(x => x.Resize(width, height));
var sb = new StringBuilder(); var sb = new StringBuilder();
byte lastR = 0, lastG = 0, lastB = 0; for (int y = 0; y < image.Height; y += 2)
bool hasLastColor = false;
for (int y = 0; y < image.Height; y++)
{ {
byte lastFgR = 0, lastFgG = 0, lastFgB = 0;
byte lastBgR = 0, lastBgG = 0, lastBgB = 0;
bool hasLastColor = false;
for (int x = 0; x < image.Width; x++) for (int x = 0; x < image.Width; x++)
{ {
var pixel = image[x, y]; var topPixel = image[x, y];
var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B; var bottomPixel = (y + 1 < image.Height) ? image[x, y + 1] : topPixel;
// Map brightness (0-255) to ASCII char index byte fgR, fgG, fgB, bgR, bgG, bgB;
var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1)); char blockChar;
// Emit ANSI 24-bit color only when it changes if (topPixel.R == bottomPixel.R && topPixel.G == bottomPixel.G && topPixel.B == bottomPixel.B)
if (!hasLastColor || pixel.R != lastR || pixel.G != lastG || pixel.B != lastB)
{ {
sb.Append($"\x1b[38;2;{pixel.R};{pixel.G};{pixel.B}m"); fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
lastR = pixel.R; bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B;
lastG = pixel.G; blockChar = '\u2588'; // █
lastB = pixel.B; }
hasLastColor = true; else
{
fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B;
blockChar = '\u2580'; // ▀
} }
sb.Append(AsciiChars[index]); bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB;
bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB;
if (fgChanged)
sb.Append($"{{F:{fgR:X2}{fgG:X2}{fgB:X2}}}");
if (bgChanged)
sb.Append($"{{B:{bgR:X2}{bgG:X2}{bgB:X2}}}");
sb.Append(blockChar);
lastFgR = fgR; lastFgG = fgG; lastFgB = fgB;
lastBgR = bgR; lastBgG = bgG; lastBgB = bgB;
hasLastColor = true;
} }
// Reset color at end of line sb.Append("{X}");
sb.Append("\x1b[0m");
hasLastColor = false; hasLastColor = false;
if (y < image.Height - 1) if (y + 2 < image.Height)
{ {
sb.AppendLine(); sb.AppendLine();
} }
@@ -0,0 +1,216 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using Microsoft.Extensions.Logging;
namespace EchoHub.Server.Services;
public partial class LinkEmbedService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<LinkEmbedService> _logger;
public LinkEmbedService(
IHttpClientFactory httpClientFactory,
ILogger<LinkEmbedService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <summary>
/// Detect all URLs in message content and attempt to fetch OG embed data for each.
/// Returns null if no URLs found or all fetches fail.
/// Never throws — all errors are caught internally.
/// </summary>
public async Task<List<EmbedDto>?> TryGetEmbedsAsync(string content)
{
var urls = ExtractUrls(content);
if (urls.Count == 0)
return null;
var embeds = new List<EmbedDto>();
foreach (var url in urls)
{
try
{
var embed = await FetchEmbedForUrlAsync(url);
if (embed is not null)
embeds.Add(embed);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to fetch embed for {Url}", url);
}
}
return embeds.Count > 0 ? embeds : null;
}
private async Task<EmbedDto?> FetchEmbedForUrlAsync(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return null;
if (uri.Scheme is not ("http" or "https"))
return null;
if (IsPrivateHost(uri))
return null;
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds));
var client = _httpClientFactory.CreateClient("OgFetch");
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
using var response = await client.SendAsync(request,
HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (!response.IsSuccessStatusCode)
return null;
var contentType = response.Content.Headers.ContentType?.MediaType;
if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase))
return null;
var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token);
if (string.IsNullOrWhiteSpace(html))
return null;
var ogTags = ParseOgTags(html);
// Try og:title, fallback to <title> tag
var title = ogTags.GetValueOrDefault("title");
if (string.IsNullOrWhiteSpace(title))
{
var titleMatch = TitleTagRegex().Match(html);
if (titleMatch.Success)
title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim());
}
// If no title at all, nothing useful to show
if (string.IsNullOrWhiteSpace(title))
return null;
var siteName = ogTags.GetValueOrDefault("site_name");
var description = ogTags.GetValueOrDefault("description");
// Truncate very long descriptions but keep a generous limit
if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength)
description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "...";
// HTML decode text fields
title = WebUtility.HtmlDecode(title);
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
description = description is not null ? WebUtility.HtmlDecode(description) : null;
return new EmbedDto(siteName, title, description, null, url);
}
private static List<string> ExtractUrls(string content)
{
var urls = new List<string>();
foreach (Match match in UrlRegex().Matches(content))
{
var url = match.Value.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':');
if (!urls.Contains(url))
urls.Add(url);
if (urls.Count >= HubConstants.EmbedMaxUrlsPerMessage)
break;
}
return urls;
}
private static bool IsPrivateHost(Uri uri)
{
if (uri.IsLoopback)
return true;
if (IPAddress.TryParse(uri.Host, out var ip))
{
var bytes = ip.GetAddressBytes();
if (bytes.Length == 4)
{
if (bytes[0] == 10) return true;
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
if (bytes[0] == 192 && bytes[1] == 168) return true;
if (bytes[0] == 127) return true;
if (bytes[0] == 0) return true;
}
}
// Also check hostname-based loopback
if (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private static Dictionary<string, string> ParseOgTags(string html)
{
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Match: <meta property="og:key" content="value" />
// Groups: 1=prop quote, 2=key, 3=content quote, 4=value
foreach (Match match in OgTagRegex().Matches(html))
{
var key = match.Groups[2].Value;
var value = match.Groups[4].Value;
tags.TryAdd(key, value);
}
// Match reversed order: <meta content="value" property="og:key" />
// Groups: 1=content quote, 2=value, 3=prop quote, 4=key
foreach (Match match in OgTagReversedRegex().Matches(html))
{
var value = match.Groups[2].Value;
var key = match.Groups[4].Value;
tags.TryAdd(key, value);
}
return tags;
}
private static async Task<string> ReadLimitedAsync(HttpResponseMessage response, int maxBytes, CancellationToken ct)
{
await using var stream = await response.Content.ReadAsStreamAsync(ct);
var buffer = new byte[maxBytes];
var totalRead = 0;
while (totalRead < maxBytes)
{
var read = await stream.ReadAsync(buffer.AsMemory(totalRead, maxBytes - totalRead), ct);
if (read == 0) break;
totalRead += read;
}
// Try to detect encoding from Content-Type, default to UTF-8
var charset = response.Content.Headers.ContentType?.CharSet;
var encoding = charset is not null
? Encoding.GetEncoding(charset)
: Encoding.UTF8;
return encoding.GetString(buffer, 0, totalRead);
}
[GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex UrlRegex();
[GeneratedRegex(@"<meta\s+[^>]*?property\s*=\s*([""'])og:(\w+)\1[^>]*?content\s*=\s*([""'])(.*?)\3[^>]*/?>",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
private static partial Regex OgTagRegex();
[GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*([""'])(.*?)\1[^>]*?property\s*=\s*([""'])og:(\w+)\3[^>]*/?>",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
private static partial Regex OgTagReversedRegex();
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex TitleTagRegex();
}
@@ -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);
}
@@ -151,4 +151,27 @@ public class PresenceTracker
{ {
return _userConnections.Count; return _userConnections.Count;
} }
/// <summary>
/// Forcibly remove a user from all tracking. Returns their connection IDs and channels
/// so the caller can broadcast departures and force-disconnect connections.
/// </summary>
public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username)
{
lock (_lock)
{
var channels = _userChannels.TryRemove(username, out var ch)
? ch.ToList()
: [];
var connectionIds = _userConnections.TryRemove(username, out var conns)
? conns.ToList()
: [];
foreach (var connId in connectionIds)
_connections.TryRemove(connId, out _);
return (connectionIds, channels);
}
}
} }
@@ -61,6 +61,19 @@ public sealed class ServerDirectoryService : BackgroundService
{ {
var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
connection.On("Ping", async () =>
{
_logger.LogDebug("Received alive check from directory — sending heartbeat");
try
{
await connection.InvokeAsync("Heartbeat");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send heartbeat response");
}
});
connection.Reconnected += async _ => connection.Reconnected += async _ =>
{ {
_logger.LogInformation("Reconnected to directory — re-registering server"); _logger.LogInformation("Reconnected to directory — re-registering server");
@@ -156,8 +169,6 @@ public sealed class ServerDirectoryService : BackgroundService
continue; continue;
var currentCount = _presenceTracker.GetOnlineUserCount(); var currentCount = _presenceTracker.GetOnlineUserCount();
if (currentCount == _lastReportedUserCount)
continue;
try try
{ {
@@ -54,6 +54,18 @@ public class SignalRBroadcaster : IChatBroadcaster
return HubContext.Clients.Clients(connections).UserStatusChanged(presence); return HubContext.Clients.Clients(connections).UserStatusChanged(presence);
} }
public Task SendUserKickedAsync(string channelName, string username, string? reason)
=> HubContext.Clients.Group(channelName).UserKicked(channelName, username, reason);
public Task SendUserBannedAsync(string username, string? reason)
=> HubContext.Clients.All.UserBanned(username, reason);
public Task SendMessageDeletedAsync(string channelName, Guid messageId)
=> HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId);
public Task SendChannelNukedAsync(string channelName)
=> HubContext.Clients.Group(channelName).ChannelNuked(channelName);
public Task SendErrorAsync(string connectionId, string message) public Task SendErrorAsync(string connectionId, string message)
{ {
if (connectionId.StartsWith("irc-")) if (connectionId.StartsWith("irc-"))
@@ -61,4 +73,13 @@ public class SignalRBroadcaster : IChatBroadcaster
return HubContext.Clients.Client(connectionId).Error(message); return HubContext.Clients.Client(connectionId).Error(message);
} }
public Task ForceDisconnectUserAsync(List<string> connectionIds, string reason)
{
var signalRIds = connectionIds.Where(c => !c.StartsWith("irc-")).ToList();
if (signalRIds.Count == 0)
return Task.CompletedTask;
return HubContext.Clients.Clients(signalRIds).ForceDisconnect(reason);
}
} }
@@ -0,0 +1,140 @@
using System.Text.RegularExpressions;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Setup;
public static partial class DataMigrationService
{
public static async Task RunAsync(IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("EchoHub.Server.Setup.DataMigration");
await EnsureDefaultChannelsPublicAsync(db, logger);
await MigrateAnsiMessagesAsync(db, logger);
await MigrateEmbedJsonToArrayAsync(db, logger);
}
/// <summary>
/// Ensure the #general channel (and any pre-existing channels from before the IsPublic column) are public.
/// </summary>
private static async Task EnsureDefaultChannelsPublicAsync(EchoHubDbContext db, ILogger logger)
{
var general = await db.Channels.FirstOrDefaultAsync(c => c.Name == HubConstants.DefaultChannel);
if (general is not null && !general.IsPublic)
{
general.IsPublic = true;
await db.SaveChangesAsync();
logger.LogInformation("Marked #{Channel} as public.", HubConstants.DefaultChannel);
}
}
private static async Task MigrateAnsiMessagesAsync(EchoHubDbContext db, ILogger logger)
{
// Load messages that contain the ESC byte (0x1B) — these have legacy ANSI color codes.
// Filter by Image type first (only images have ANSI art), then check content in memory.
var messages = await db.Messages
.Where(m => m.Type == Core.Models.MessageType.Image)
.ToListAsync();
var toMigrate = messages.Where(m => m.Content.Contains('\x1b')).ToList();
if (toMigrate.Count == 0)
return;
logger.LogInformation("Found {Count} messages with legacy ANSI color codes. Migrating to color tag format...", toMigrate.Count);
var modified = 0;
foreach (var message in toMigrate)
{
var converted = AnsiToColorTags(message.Content);
if (converted != message.Content)
{
message.Content = converted;
modified++;
}
}
if (modified > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Migrated {Count} messages from ANSI escape codes to printable color tags.", modified);
}
}
/// <summary>
/// Convert ANSI escape codes to printable color tags.
/// \x1b[38;2;R;G;Bm → {F:RRGGBB}, \x1b[48;2;R;G;Bm → {B:RRGGBB}, \x1b[0m → {X}
/// </summary>
public static string AnsiToColorTags(string text)
{
return AnsiColorRegex().Replace(text, match =>
{
if (match.Groups[1].Value == "0")
return "{X}";
if (match.Groups[2].Success)
{
var r = int.Parse(match.Groups[3].Value);
var g = int.Parse(match.Groups[4].Value);
var b = int.Parse(match.Groups[5].Value);
var type = match.Groups[2].Value == "38;2" ? "F" : "B";
return $"{{{type}:{r:X2}{g:X2}{b:X2}}}";
}
return match.Value;
});
}
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
/// <summary>
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
/// </summary>
private static async Task MigrateEmbedJsonToArrayAsync(EchoHubDbContext db, ILogger logger)
{
var messages = await db.Messages
.Where(m => m.EmbedJson != null)
.ToListAsync();
var toMigrate = messages
.Where(m => m.EmbedJson!.TrimStart().StartsWith('{'))
.ToList();
if (toMigrate.Count == 0)
return;
logger.LogInformation("Found {Count} messages with legacy single-embed JSON. Migrating to array format...", toMigrate.Count);
var modified = 0;
foreach (var message in toMigrate)
{
try
{
var single = System.Text.Json.JsonSerializer.Deserialize<EmbedDto>(message.EmbedJson!);
if (single is not null)
{
message.EmbedJson = System.Text.Json.JsonSerializer.Serialize(new[] { single });
modified++;
}
}
catch
{
// Skip malformed JSON
}
}
if (modified > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
}
}
}
@@ -16,6 +16,9 @@ public static class DatabaseSetup
await MigrateAsync(db, logger); await MigrateAsync(db, logger);
await SeedDefaultChannelAsync(db, logger); await SeedDefaultChannelAsync(db, logger);
// Run data migrations (e.g. ANSI → color tag format)
await DataMigrationService.RunAsync(services);
} }
private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger) private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger)
+24
View File
@@ -22,6 +22,7 @@ public static class FirstRunSetup
return; return;
EnsureJwtSecret(settingsPath); EnsureJwtSecret(settingsPath);
EnsureEncryptionKey(settingsPath);
} }
private static void EnsureJwtSecret(string settingsPath) private static void EnsureJwtSecret(string settingsPath)
@@ -45,4 +46,27 @@ public static class FirstRunSetup
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions)); File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
Console.WriteLine("Generated new JWT secret in appsettings.json."); 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.");
}
} }
@@ -14,6 +14,10 @@
"PublicServer": false, "PublicServer": false,
"PublicHost": "" "PublicHost": ""
}, },
"Encryption": {
"Key": "",
"EncryptDatabase": false
},
"Irc": { "Irc": {
"Enabled": false, "Enabled": false,
"Port": 6667, "Port": 6667,
@@ -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));
}
}
+1
View File
@@ -18,6 +18,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" />
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" /> <ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" /> <ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
</ItemGroup> </ItemGroup>
@@ -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);
}
}
}
@@ -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);
}
}