diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f74fc77..fc58994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [master, dev] + branches: [master] pull_request: branches: [master, dev] workflow_dispatch: diff --git a/README.md b/README.md index 5c91d9f..0f24a3a 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,19 @@

WhatSetup • + IRC • + DeployCommandsConfigLicense

+

+ Website • + Public Servers • + Documentation +

+

.NET 10 SignalR @@ -40,21 +48,25 @@ Each server is fully independent — no central authority, no account federation ```mermaid graph TD subgraph Server["Server"] + ChatSvc["ChatService"] Hub["SignalR ChatHub"] + IRC["IRC Gateway :6667"] Auth["JWT Auth"] DB["SQLite DB (EF Core)"] Files["File Storage"] end - subgraph Client["Client"] + subgraph Clients["Clients"] TUI["Terminal GUI (TUI)"] - Theme["Theme Engine"] - API["API Client (auto-refresh)"] + IRCClient["IRC Client (irssi, WeeChat, ...)"] end - API -- "WebSocket" --> Hub - API -- "REST" --> Auth - Hub --> DB + TUI -- "WebSocket" --> Hub + TUI -- "REST" --> Auth + IRCClient -- "TCP" --> IRC + Hub --> ChatSvc + IRC --> ChatSvc + ChatSvc --> DB Auth --> DB Files --> DB ``` @@ -65,6 +77,7 @@ graph TD - **Self-hostable** — your server, your rules, your data - **Real-time messaging** via SignalR WebSockets +- **IRC gateway** — native IRC clients connect alongside TUI users, full cross-protocol messaging - **JWT auth** with short-lived access tokens and 30-day refresh tokens - **Channels** — create, set topics, delete (no 47-step permission wizard required) - **File & image uploads** with actual validation (magic bytes, not just trusting the extension) @@ -122,6 +135,66 @@ Connect, register, chat. That's the whole onboarding flow. dotnet build src/EchoHub.slnx ``` +## IRC Gateway + +EchoHub includes a built-in IRC protocol gateway. Any standard IRC client can connect to the same server and chat alongside TUI users — messages flow both ways in real time. + +### Enable It + +In the server's `appsettings.json`: + +```json +{ + "Irc": { + "Enabled": true, + "Port": 6667, + "ServerName": "echohub", + "Motd": "Welcome to EchoHub IRC Gateway!" + } +} +``` + +### Connect + +```bash +# irssi +irssi -c your-server.com -p 6667 -w -n + +# WeeChat +/server add echohub your-server.com/6667 -password= -nicks= +/connect echohub +``` + +IRC users must have an existing EchoHub account (no registration via IRC). Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN. + +### What Works + +| Feature | How it maps to IRC | +| ------- | ------------------ | +| Text messages | Standard `PRIVMSG` (long messages split at ~400 byte chunks) | +| Images | `[Image: filename]` + download URL + ASCII art line-by-line | +| File uploads | `[File: filename] /api/files/{id}` | +| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` | +| Presence | `AWAY`, `WHO`, `WHOIS` | +| Status | Maps to IRC away/here | + +### TLS + +If running behind **nginx** (recommended), let nginx handle TLS -- see [Deployment with nginx](#deployment-with-nginx) below. + +For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself: + +```json +{ + "Irc": { + "TlsEnabled": true, + "TlsPort": 6697, + "TlsCertPath": "/path/to/cert.pfx", + "TlsCertPassword": "your-password" + } +} +``` + ## Client Commands | Command | Description | @@ -172,35 +245,107 @@ dotnet build src/EchoHub.slnx | `Jwt:Secret` | *(auto-generated)* | JWT signing key | | `Server:Name` | `My EchoHub Server` | Server display name | | `Server:Description` | `A self-hosted EchoHub chat server` | Server description | +| `Server:PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) | +| `Server:PublicHost` | *(empty)* | Public hostname for directory listing | +| `Irc:Enabled` | `false` | Enable the IRC gateway | +| `Irc:Port` | `6667` | IRC listen port | +| `Irc:TlsEnabled` | `false` | Enable TLS for IRC | +| `Irc:TlsPort` | `6697` | IRC TLS port | +| `Irc:ServerName` | `echohub` | IRC server name in protocol messages | +| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day | | `Cors:AllowedOrigins` | *(all origins)* | CORS whitelist | -Logging uses Serilog — console + daily rolling files with 14-day retention. Configure it in the `Serilog` section of appsettings. +Logging uses Serilog — console + daily rolling files with 14-day retention. Configure in the `Serilog` section. + +## Deployment with nginx + +Most production deployments run behind nginx. Here's a config that handles both the HTTP/WebSocket server and the IRC gateway: + +```nginx +# HTTP + WebSocket (EchoHub Server API + SignalR) +server { + listen 443 ssl; + server_name echohub.example.com; + + ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Required for SignalR WebSocket + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # Increase max upload size for file sharing + client_max_body_size 10m; +} + +# HTTP → HTTPS redirect +server { + listen 80; + server_name echohub.example.com; + return 301 https://$host$request_uri; +} + +# IRC TLS (port 6697 → plain IRC on 6667) +stream { + upstream irc_backend { + server 127.0.0.1:6667; + } + + server { + listen 6697 ssl; + proxy_pass irc_backend; + + ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem; + } +} +``` + +With this setup, keep the EchoHub IRC gateway's `TlsEnabled` set to `false` — nginx terminates TLS. See the full example at [`examples/nginx.conf`](examples/nginx.conf). ## Project Structure ```text src/ -├── EchoHub.Core/ # Shared models, DTOs, contracts, validation -│ ├── Constants/ # ValidationConstants, HubConstants -│ ├── Contracts/ # IEchoHubClient (SignalR interface) -│ ├── DTOs/ # Record DTOs -│ └── Models/ # Entity models +├── EchoHub.Core/ # Shared models, DTOs, contracts, validation +│ ├── Constants/ # ValidationConstants, HubConstants +│ ├── Contracts/ # IChatService, IChatBroadcaster, IEchoHubClient +│ ├── DTOs/ # Record DTOs +│ └── Models/ # Entity models │ -├── EchoHub.Server/ # ASP.NET Core server -│ ├── Auth/ # JWT token service -│ ├── Controllers/ # REST API endpoints -│ ├── Data/ # EF Core DbContext + migrations -│ ├── Hubs/ # SignalR ChatHub -│ ├── Services/ # Presence, file storage, image processing -│ └── Setup/ # First-run setup, DB initialization +├── EchoHub.Server/ # ASP.NET Core server +│ ├── Auth/ # JWT token service +│ ├── Controllers/ # REST API endpoints +│ ├── Data/ # EF Core DbContext + migrations +│ ├── Hubs/ # SignalR ChatHub +│ ├── Services/ # ChatService, presence, file storage, image processing +│ └── Setup/ # First-run setup, DB initialization │ -├── EchoHub.Client/ # Terminal.Gui TUI client -│ ├── Config/ # Client configuration -│ ├── Services/ # API client, SignalR connection -│ ├── Themes/ # 6 built-in themes -│ └── UI/ # MainWindow, dialogs, chat renderer +├── EchoHub.Server.Irc/ # IRC protocol gateway +│ ├── IrcGatewayService # TCP listener (BackgroundService) +│ ├── IrcCommandHandler # IRC command dispatch (JOIN, PRIVMSG, etc.) +│ ├── IrcBroadcaster # Fans chat events to IRC connections +│ └── IrcMessageFormatter # MessageDto → IRC PRIVMSG lines │ -└── EchoHub.slnx # Solution file +├── EchoHub.Client/ # Terminal.Gui TUI client +│ ├── Config/ # Client configuration +│ ├── Services/ # API client, SignalR connection +│ ├── Themes/ # 13 built-in themes +│ └── UI/ # MainWindow, dialogs, chat renderer +│ +└── EchoHub.slnx # Solution file ``` ## License diff --git a/docs/api/core-articles/index.md b/docs/api/core-articles/index.md index 852da7d..d8545b1 100644 --- a/docs/api/core-articles/index.md +++ b/docs/api/core-articles/index.md @@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library. ## Topics - Data models and DTOs -- SignalR contract interface +- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient) - Validation constants and shared rules diff --git a/docs/api/index.md b/docs/api/index.md index 3978198..b62904c 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -10,8 +10,12 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur ### Core -Shared library -- DTOs, models, constants, and the SignalR client contract. +Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`). ### Server -ASP.NET Core server -- controllers, hubs, authentication, and data access. +ASP.NET Core server -- controllers, hubs, ChatService, SignalRBroadcaster, authentication, and data access. + +### Server.Irc + +IRC protocol gateway -- TCP listener, command handler, IrcBroadcaster, and message formatter. diff --git a/docs/api/server-articles/index.md b/docs/api/server-articles/index.md index cafad01..6cafdf8 100644 --- a/docs/api/server-articles/index.md +++ b/docs/api/server-articles/index.md @@ -6,6 +6,8 @@ Articles related to the EchoHub server built with ASP.NET Core. - Authentication and JWT tokens - SignalR hub and real-time messaging +- IRC gateway and protocol bridging +- ChatService and broadcaster pattern - File upload and validation - Rate limiting configuration - Database schema and migrations diff --git a/docs/api/toc.yml b/docs/api/toc.yml index 78334db..79092df 100644 --- a/docs/api/toc.yml +++ b/docs/api/toc.yml @@ -16,3 +16,7 @@ href: ../_api_meta/server/toc.yml - name: Articles href: server-articles/ +- name: Server.Irc + items: + - name: API Reference + href: ../_api_meta/server-irc/toc.yml diff --git a/docs/articles/architecture.md b/docs/articles/architecture.md index 9b67ffd..bc59652 100644 --- a/docs/articles/architecture.md +++ b/docs/articles/architecture.md @@ -4,6 +4,16 @@ EchoHub follows a decentralized model where each server is fully independent. There is no central authority or account federation. Users create one account per server. +The server exposes two protocol interfaces to the same chat backend: + +```text +IRC Client ──► TCP :6667 ──► IrcGateway ──┐ + ├──► ChatService ──► DB + PresenceTracker +TUI Client ──► WebSocket ──► ChatHub ─────┘ +``` + +Both protocols call into a shared `IChatService` for business logic. Events fan out to all registered `IChatBroadcaster` implementations (SignalR and IRC). + ## Components ### EchoHub.Core @@ -12,7 +22,7 @@ Shared library containing: - **Models**: `User`, `Channel`, `Message`, `RefreshToken` - **DTOs**: Record types for API requests/responses -- **Contracts**: `IEchoHubClient` -- the strongly-typed SignalR client interface +- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface) - **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants` ### EchoHub.Server @@ -20,10 +30,21 @@ Shared library containing: ASP.NET Core web application: - **Controllers**: REST API endpoints for auth, channels, users, files, server info -- **Hubs**: SignalR `ChatHub` for real-time messaging +- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService` - **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens) - **Data**: EF Core with SQLite -- **Services**: Presence tracking, file storage, image-to-ASCII conversion +- **Services**: `ChatService` (core business logic), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion + +### EchoHub.Server.Irc + +IRC protocol gateway (separate project for clean separation of concerns): + +- **IrcGatewayService**: `BackgroundService` with TCP listener on configured port(s), optional TLS +- **IrcCommandHandler**: Per-client IRC command dispatch -- handles `CAP`/`SASL`, `NICK`/`USER`/`PASS`, `JOIN`/`PART`/`PRIVMSG`/`QUIT`, `NAMES`/`TOPIC`/`WHO`/`WHOIS`/`AWAY`/`LIST`/`MODE`/`MOTD` +- **IrcBroadcaster**: `IChatBroadcaster` implementation that formats chat events as IRC protocol lines, with echo suppression (IRC convention) +- **IrcMessageFormatter**: Converts `MessageDto` to IRC `PRIVMSG` lines -- splits long text at word boundaries (~400 byte chunks), sends images as ASCII art line-by-line + +IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` or SASL PLAIN (BCrypt verification against the database). ### EchoHub.Client @@ -31,11 +52,22 @@ Terminal.Gui v2 TUI application: - **UI**: Main window, dialogs, chat renderer with ANSI color support - **Services**: API client with automatic token refresh, SignalR connection wrapper -- **Themes**: 6 built-in color themes +- **Themes**: 13 built-in color themes - **Config**: Client configuration management ## Communication -- REST API for authentication, profile management, channel CRUD, file uploads -- SignalR WebSocket for real-time messaging and presence updates +- **REST API** for authentication, profile management, channel CRUD, file uploads +- **SignalR WebSocket** for real-time messaging and presence updates (TUI client) +- **IRC TCP** for real-time messaging via standard IRC protocol (IRC clients) - JWT tokens passed via query string for SignalR authentication +- IRC authentication via PASS/SASL PLAIN against BCrypt password hashes + +## Broadcaster Pattern + +The `IChatBroadcaster` interface allows multiple protocols to receive chat events: + +- **SignalRBroadcaster**: Wraps `IHubContext`, filters out IRC connections +- **IrcBroadcaster**: Iterates live IRC connections in a channel, formats events as IRC protocol lines + +Both are registered in DI and called by `ChatService` when events occur. This means a message sent from an IRC client appears in the TUI client, and vice versa. diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md index 9f31bf1..2d45cad 100644 --- a/docs/articles/getting-started.md +++ b/docs/articles/getting-started.md @@ -4,6 +4,8 @@ - [.NET 10 SDK](https://dotnet.microsoft.com/download) +Or grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed. + ## Run the Server ```bash @@ -24,6 +26,37 @@ dotnet run --project src/EchoHub.Client Connect to a server, register an account, and start chatting. +## Connect via IRC + +Enable the IRC gateway in the server's `appsettings.json`: + +```json +{ + "Irc": { + "Enabled": true, + "Port": 6667 + } +} +``` + +Then connect with any standard IRC client: + +```bash +irssi -c localhost -p 6667 -w -n +``` + +IRC users must have an existing EchoHub account. Authentication works via `PASS`/`NICK`/`USER` or SASL PLAIN. Messages flow bidirectionally between IRC and TUI clients. + +For TLS, set `TlsEnabled: true`, `TlsPort: 6697`, and provide a PKCS#12 certificate path. + +See the [Architecture](architecture.md) page for details on how the IRC gateway integrates with the chat service. + +## Configuration + +Server configuration is in `appsettings.json` (auto-generated on first run). See the [example config](https://github.com/HueByte/EchoHub/blob/master/src/EchoHub.Server/appsettings.example.json) for all available options. + +To list your server on the [public directory](https://echohub.voidcube.cloud/servers), set `Server:PublicServer` to `true` and `Server:PublicHost` to your server's public address. + ## Build from Source ```bash diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 90c9628..f34555b 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,5 +4,6 @@ Release history for EchoHub. ## Releases +- [v0.2.0](v0.2.0.md) - IRC Gateway - [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing - [v0.1.0](v0.1.0.md) - Initial Release diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 32cbada..b3b5122 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.0 + href: v0.2.0.md - name: v0.1.1 href: v0.1.1.md - name: v0.1.0 diff --git a/docs/changelog/v0.2.0.md b/docs/changelog/v0.2.0.md new file mode 100644 index 0000000..1e9b7cd --- /dev/null +++ b/docs/changelog/v0.2.0.md @@ -0,0 +1,21 @@ +# v0.2.0 - IRC Gateway + +## Features + +- **IRC protocol gateway** -- native IRC clients (irssi, WeeChat, HexChat, etc.) can connect to EchoHub servers +- **Cross-protocol messaging** -- messages flow bidirectionally between IRC and TUI clients in real time +- **SASL PLAIN authentication** -- IRC clients can authenticate via SASL or traditional PASS/NICK/USER +- **Full IRC command support** -- JOIN, PART, PRIVMSG, QUIT, NAMES, TOPIC, WHO, WHOIS, AWAY, LIST, MODE, MOTD +- **TLS support** -- optional encrypted IRC connections on port 6697 +- **Image-to-IRC formatting** -- images appear as ASCII art line-by-line with download URLs +- **Message splitting** -- long messages automatically split at word boundaries (~400 byte chunks) +- **Configurable MOTD** -- server message of the day for IRC clients + +## Architecture Changes + +- Extracted shared business logic from `ChatHub` into protocol-agnostic `IChatService` +- Introduced `IChatBroadcaster` pattern for multi-protocol event fan-out +- `ChatHub` refactored to thin adapter delegating to `IChatService` +- `ChannelsController` updated to use `IChatService` for broadcasts +- New `EchoHub.Server.Irc` project for clean separation of concerns +- `IChatService` and `IChatBroadcaster` interfaces live in `EchoHub.Core/Contracts` diff --git a/docs/docfx.json b/docs/docfx.json index 7e28fca..52df4c8 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -30,6 +30,16 @@ ], "dest": "_api_meta/client", "filter": "filterConfig.yml" + }, + { + "src": [ + { + "src": "../src/EchoHub.Server.Irc/bin/Release/net10.0", + "files": ["EchoHub.Server.Irc.dll"] + } + ], + "dest": "_api_meta/server-irc", + "filter": "filterConfig.yml" } ], "build": { @@ -55,6 +65,12 @@ "dest": "api/client", "files": ["*.yml"], "exclude": ["toc.yml"] + }, + { + "src": "_api_meta/server-irc", + "dest": "api/server-irc", + "files": ["*.yml"], + "exclude": ["toc.yml"] } ], "resource": [ diff --git a/docs/index.md b/docs/index.md index 6c8b7cd..cf75e8f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,11 +4,13 @@ _layout: landing # EchoHub Documentation -Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-like chat application built with .NET 10 and SignalR. +Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-style chat platform. Self-hosted, terminal-first, with a built-in IRC gateway so native IRC clients can connect alongside the TUI client. + +**Website:** [echohub.voidcube.cloud](https://echohub.voidcube.cloud/) | **Public Servers:** [Server Directory](https://echohub.voidcube.cloud/servers) ## Quick Links - [Getting Started](articles/getting-started.md) - Set up and run EchoHub -- [Architecture](articles/architecture.md) - Understand the system design +- [Architecture](articles/architecture.md) - System design and IRC gateway - [API Reference](api/index.md) - Generated C# API documentation - [Changelog](changelog/index.md) - Release history diff --git a/examples/nginx.conf b/examples/nginx.conf new file mode 100644 index 0000000..ca07536 --- /dev/null +++ b/examples/nginx.conf @@ -0,0 +1,94 @@ +# EchoHub nginx configuration example +# +# This config handles: +# - HTTPS reverse proxy for the HTTP API + SignalR WebSocket +# - TLS termination for IRC on port 6697 +# - HTTP → HTTPS redirect +# +# Prerequisites: +# - EchoHub Server running on 127.0.0.1:5000 +# - IRC gateway enabled on port 6667 (Irc:Enabled = true, Irc:TlsEnabled = false) +# - TLS certificate (e.g. from Let's Encrypt) +# +# Usage: +# 1. Copy this file to /etc/nginx/sites-available/echohub +# 2. Replace "echohub.example.com" with your domain +# 3. Update certificate paths +# 4. ln -s /etc/nginx/sites-available/echohub /etc/nginx/sites-enabled/ +# 5. nginx -t && systemctl reload nginx +# +# Note: The "stream" block for IRC TLS must go in the main nginx.conf +# (outside the http block), not in sites-available. See the bottom of +# this file for the stream config. + +# --- Place this in /etc/nginx/sites-available/echohub --- + +# HTTP → HTTPS redirect +server { + listen 80; + listen [::]:80; + server_name echohub.example.com; + + return 301 https://$host$request_uri; +} + +# HTTPS — API + SignalR WebSocket +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name echohub.example.com; + + ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # File upload limit (match EchoHub's MaxFileSizeBytes) + client_max_body_size 10m; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + + # Standard proxy headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Required for SignalR WebSocket upgrade + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + + # Long timeout for WebSocket connections + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + + # Disable buffering for real-time + proxy_buffering off; + } +} + +# --- Place this in /etc/nginx/nginx.conf (outside the http block) --- + +# IRC TLS termination (port 6697 → plain IRC on 6667) +# The stream module handles raw TCP, not HTTP. +# +# stream { +# upstream irc_backend { +# server 127.0.0.1:6667; +# } +# +# server { +# listen 6697 ssl; +# listen [::]:6697 ssl; +# proxy_pass irc_backend; +# +# ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem; +# ssl_protocols TLSv1.2 TLSv1.3; +# +# # Timeout for idle IRC connections (24 hours) +# proxy_timeout 86400s; +# } +# } diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs new file mode 100644 index 0000000..6e2a11c --- /dev/null +++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs @@ -0,0 +1,13 @@ +using EchoHub.Core.DTOs; + +namespace EchoHub.Core.Contracts; + +public interface IChatBroadcaster +{ + Task SendMessageToChannelAsync(string channelName, MessageDto message); + Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null); + Task SendUserLeftAsync(string channelName, string username); + Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null); + Task SendUserStatusChangedAsync(List channelNames, UserPresenceDto presence); + Task SendErrorAsync(string connectionId, string message); +} diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs new file mode 100644 index 0000000..c4102fb --- /dev/null +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -0,0 +1,36 @@ +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; + +namespace EchoHub.Core.Contracts; + +public interface IChatService +{ + // Connection lifecycle + Task UserConnectedAsync(string connectionId, Guid userId, string username); + Task UserDisconnectedAsync(string connectionId); + + // Channel operations + Task<(List History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName); + Task LeaveChannelAsync(string connectionId, string username, string channelName); + + // Messaging + Task SendMessageAsync(Guid userId, string username, string channelName, string content); + Task> GetChannelHistoryAsync(string channelName, int count); + + // Presence + Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage); + Task> GetOnlineUsersAsync(string channelName); + + // Broadcasting (used by controllers and IRC gateway) + Task BroadcastMessageAsync(string channelName, MessageDto message); + Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null); + + // Query operations (used by IRC gateway for WHOIS, TOPIC, LIST, AUTH) + Task GetUserProfileAsync(string username); + Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName); + Task> GetChannelListAsync(); + Task> GetChannelsForUserAsync(string username); + Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); +} + +public record ChannelListItem(string Name, string? Topic, int OnlineCount); diff --git a/src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj b/src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj new file mode 100644 index 0000000..750fb0f --- /dev/null +++ b/src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs new file mode 100644 index 0000000..9f70d3d --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -0,0 +1,67 @@ +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; + +namespace EchoHub.Server.Irc; + +public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster +{ + public async Task SendMessageToChannelAsync(string channelName, MessageDto message) + { + var lines = IrcMessageFormatter.FormatMessage(message); + + foreach (var conn in gateway.GetConnectionsInChannel(channelName)) + { + // IRC convention: don't echo sender's own message + if (conn.Nickname == message.SenderUsername) + continue; + + foreach (var line in lines) + await conn.SendAsync(line); + } + } + + public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null) + { + foreach (var conn in gateway.GetConnectionsInChannel(channelName)) + { + if (conn.ConnectionId == excludeConnectionId) continue; + await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}"); + } + } + + public async Task SendUserLeftAsync(string channelName, string username) + { + foreach (var conn in gateway.GetConnectionsInChannel(channelName)) + { + if (conn.Nickname == username) continue; + await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}"); + } + } + + public async Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null) + { + var target = channelName ?? channel.Name; + if (channel.Topic is null) return; + + foreach (var conn in gateway.GetConnectionsInChannel(target)) + { + await conn.SendAsync($":{gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}"); + } + } + + public Task SendUserStatusChangedAsync(List channelNames, UserPresenceDto presence) + { + // IRC has no active status broadcast. Clients discover away via WHOIS/WHO. + return Task.CompletedTask; + } + + public async Task SendErrorAsync(string connectionId, string message) + { + if (!connectionId.StartsWith("irc-")) return; + + if (gateway.Connections.TryGetValue(connectionId, out var conn)) + { + await conn.SendAsync($":{gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}"); + } + } +} diff --git a/src/EchoHub.Server.Irc/IrcClientConnection.cs b/src/EchoHub.Server.Irc/IrcClientConnection.cs new file mode 100644 index 0000000..8566268 --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcClientConnection.cs @@ -0,0 +1,87 @@ +using System.Net.Sockets; +using System.Text; + +namespace EchoHub.Server.Irc; + +///

+/// Manages a single IRC client TCP connection. +/// +public sealed class IrcClientConnection : IAsyncDisposable +{ + private readonly TcpClient _tcpClient; + private readonly StreamReader _reader; + private readonly StreamWriter _writer; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + // Connection identity + public string ConnectionId { get; } = $"irc-{Guid.NewGuid()}"; + + // Registration state + public string? Nickname { get; set; } + public string? Username { get; set; } + public string? RealName { get; set; } + public string? Password { get; set; } + public Guid? UserId { get; set; } + public bool IsRegistered { get; set; } + public bool IsAuthenticated { get; set; } + public bool IsSasl { get; set; } + public bool CapNegotiating { get; set; } + + // Channel state + public HashSet JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase); + + // Away state + public string? AwayMessage { get; set; } + + public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub"; + + public IrcClientConnection(TcpClient tcpClient, Stream stream) + { + _tcpClient = tcpClient; + _reader = new StreamReader(stream, Encoding.UTF8); + _writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true, NewLine = "\r\n" }; + } + + public async Task ReadLineAsync(CancellationToken ct) + { + try + { + return await _reader.ReadLineAsync(ct); + } + catch + { + return null; + } + } + + public async Task SendAsync(string line) + { + await _writeLock.WaitAsync(); + try + { + await _writer.WriteLineAsync(line); + } + catch + { + // Connection lost — swallow + } + finally + { + _writeLock.Release(); + } + } + + public Task SendNumericAsync(string serverName, string numeric, string target, string text) + => SendAsync($":{serverName} {numeric} {target} {text}"); + + public Task SendNumericAsync(string serverName, string numeric, string text) + => SendNumericAsync(serverName, numeric, Nickname ?? "*", text); + + public async ValueTask DisposeAsync() + { + try { _tcpClient.Close(); } catch { } + _reader.Dispose(); + _writer.Dispose(); + _writeLock.Dispose(); + } +} diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs new file mode 100644 index 0000000..1bf1a6b --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -0,0 +1,641 @@ +using System.Text; +using EchoHub.Core.Constants; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using Microsoft.Extensions.Logging; + +namespace EchoHub.Server.Irc; + +public sealed class IrcCommandHandler +{ + private readonly IrcClientConnection _conn; + private readonly IrcOptions _options; + private readonly IChatService _chatService; + private readonly ILogger _logger; + + private string ServerName => _options.ServerName; + + public IrcCommandHandler( + IrcClientConnection conn, + IrcOptions options, + IChatService chatService, + ILogger logger) + { + _conn = conn; + _options = options; + _chatService = chatService; + _logger = logger; + } + + public async Task RunAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + var line = await _conn.ReadLineAsync(ct); + if (line is null) break; + + line = line.TrimEnd('\r', '\n'); + if (string.IsNullOrWhiteSpace(line)) continue; + + var msg = IrcMessage.Parse(line); + + try + { + await HandleCommandAsync(msg); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling IRC command {Command} for {Nick}", + msg.Command, _conn.Nickname ?? "unregistered"); + } + } + } + + private Task HandleCommandAsync(IrcMessage msg) + { + var command = msg.Command.ToUpperInvariant(); + + return command switch + { + // Pre-registration + "CAP" => HandleCapAsync(msg), + "AUTHENTICATE" => HandleAuthenticateAsync(msg), + "PASS" => HandlePassAsync(msg), + "NICK" => HandleNickAsync(msg), + "USER" => HandleUserAsync(msg), + + // Post-registration + "PING" => HandlePingAsync(msg), + "PONG" => Task.CompletedTask, + "JOIN" => HandleJoinAsync(msg), + "PART" => HandlePartAsync(msg), + "PRIVMSG" => HandlePrivmsgAsync(msg), + "QUIT" => HandleQuitAsync(msg), + "NAMES" => HandleNamesAsync(msg), + "TOPIC" => HandleTopicAsync(msg), + "WHO" => HandleWhoAsync(msg), + "WHOIS" => HandleWhoisAsync(msg), + "AWAY" => HandleAwayAsync(msg), + "LIST" => HandleListAsync(msg), + "MODE" => HandleModeAsync(msg), + "MOTD" => SendMotdAsync(), + "USERHOST" or "LUSERS" => Task.CompletedTask, + + _ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND, + $"{command} :Unknown command"), + }; + } + + // ── Authentication ────────────────────────────────────────────────────── + + private async Task HandleCapAsync(IrcMessage msg) + { + if (msg.Parameters.Count < 1) return; + + switch (msg.Parameters[0].ToUpperInvariant()) + { + case "LS": + await _conn.SendAsync($":{ServerName} CAP * LS :sasl"); + _conn.CapNegotiating = true; + break; + + case "REQ": + if (msg.Parameters.Count >= 2 && + msg.Parameters[1].Trim().Equals("sasl", StringComparison.OrdinalIgnoreCase)) + { + await _conn.SendAsync($":{ServerName} CAP * ACK :sasl"); + _conn.IsSasl = true; + } + else + { + var requested = msg.Parameters.ElementAtOrDefault(1) ?? ""; + await _conn.SendAsync($":{ServerName} CAP * NAK :{requested}"); + } + break; + + case "END": + _conn.CapNegotiating = false; + if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered) + await TryCompleteRegistrationAsync(); + break; + } + } + + private async Task HandleAuthenticateAsync(IrcMessage msg) + { + if (msg.Parameters.Count < 1) return; + + if (msg.Parameters[0].Equals("PLAIN", StringComparison.OrdinalIgnoreCase)) + { + await _conn.SendAsync("AUTHENTICATE +"); + return; + } + + try + { + var decoded = Convert.FromBase64String(msg.Parameters[0]); + var text = Encoding.UTF8.GetString(decoded); + var parts = text.Split('\0'); + + if (parts.Length < 3) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL, + ":SASL authentication failed (malformed payload)"); + return; + } + + var username = (parts[1].Length > 0 ? parts[1] : parts[0]).ToLowerInvariant(); + var password = parts[2]; + + var result = await _chatService.AuthenticateUserAsync(username, password); + + if (result is null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL, + ":SASL authentication failed"); + return; + } + + _conn.Nickname = result.Value.Username; + _conn.UserId = result.Value.UserId; + _conn.IsAuthenticated = true; + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LOGGEDIN, + $"{_conn.Hostmask} {username} :You are now logged in as {username}"); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_SASLSUCCESS, + ":SASL authentication successful"); + } + catch + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL, + ":SASL authentication failed"); + } + } + + private Task HandlePassAsync(IrcMessage msg) + { + if (_conn.IsRegistered) + return _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ALREADYREGISTERED, + ":You may not reregister"); + + if (msg.Parameters.Count >= 1) + _conn.Password = msg.Parameters[0]; + + return Task.CompletedTask; + } + + private async Task HandleNickAsync(IrcMessage msg) + { + if (msg.Parameters.Count < 1) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NONICKNAMEGIVEN, + ":No nickname given"); + return; + } + + var nick = msg.Parameters[0]; + + if (!ValidationConstants.UsernameRegex().IsMatch(nick)) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ERRONEUSNICKNAME, + $"{nick} :Erroneous nickname (must be 3-50 chars: a-z, 0-9, _, -)"); + return; + } + + _conn.Nickname = nick.ToLowerInvariant(); + + if (!_conn.IsRegistered && _conn.Username is not null) + await TryCompleteRegistrationAsync(); + } + + private async Task HandleUserAsync(IrcMessage msg) + { + if (_conn.IsRegistered) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ALREADYREGISTERED, + ":You may not reregister"); + return; + } + + if (msg.Parameters.Count < 4) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS, + "USER :Not enough parameters"); + return; + } + + _conn.Username = msg.Parameters[0]; + _conn.RealName = msg.Parameters[3]; + + if (_conn.Nickname is not null) + await TryCompleteRegistrationAsync(); + } + + private async Task TryCompleteRegistrationAsync() + { + if (_conn.CapNegotiating || _conn.IsRegistered) return; + + // SASL already authenticated + if (_conn.IsAuthenticated && _conn.UserId is not null) + { + _conn.IsRegistered = true; + await _chatService.UserConnectedAsync(_conn.ConnectionId, _conn.UserId.Value, _conn.Nickname!); + await SendWelcomeBurstAsync(); + return; + } + + // PASS-based authentication + if (string.IsNullOrEmpty(_conn.Password)) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH, + ":Password required. Use PASS command or SASL PLAIN."); + await _conn.SendAsync("ERROR :Authentication failed - no password provided"); + return; + } + + var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password); + + if (result is null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH, + ":Password incorrect or account not found. Register via the EchoHub client first."); + await _conn.SendAsync("ERROR :Authentication failed"); + return; + } + + _conn.UserId = result.Value.UserId; + _conn.Nickname = result.Value.Username; + _conn.IsAuthenticated = true; + _conn.IsRegistered = true; + + await _chatService.UserConnectedAsync(_conn.ConnectionId, result.Value.UserId, result.Value.Username); + await SendWelcomeBurstAsync(); + } + + // ── Welcome / MOTD ────────────────────────────────────────────────────── + + private async Task SendWelcomeBurstAsync() + { + var nick = _conn.Nickname!; + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WELCOME, + $":Welcome to the EchoHub IRC Gateway, {nick}!"); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_YOURHOST, + $":Your host is {ServerName}, running EchoHub IRC Gateway"); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CREATED, + $":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}"); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO, + $"{ServerName} EchoHub-IRC o o"); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT, + "CHANTYPES=# NICKLEN=50 CHANNELLEN=100 :are supported by this server"); + + await SendMotdAsync(); + } + + private async Task SendMotdAsync() + { + if (string.IsNullOrWhiteSpace(_options.Motd)) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOMOTD, + ":MOTD File is missing"); + return; + } + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MOTDSTART, + $":- {ServerName} Message of the day - "); + + foreach (var line in _options.Motd.Split('\n')) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MOTD, + $":- {line.TrimEnd('\r')}"); + } + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFMOTD, + ":End of MOTD command"); + } + + // ── Channel Operations ────────────────────────────────────────────────── + + private async Task HandleJoinAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + + if (msg.Parameters.Count < 1) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS, + "JOIN :Not enough parameters"); + return; + } + + var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries); + + foreach (var rawChannel in channels) + { + var channelName = IrcToEchoHubChannel(rawChannel); + if (channelName is null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, + $"{rawChannel} :Invalid channel name"); + continue; + } + + var (history, error) = await _chatService.JoinChannelAsync( + _conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName); + + if (error is not null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL, + $"#{channelName} :{error}"); + continue; + } + + _conn.JoinedChannels.Add(channelName); + + // Confirm JOIN to the client + await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}"); + + // Send topic + await SendChannelTopicAsync(channelName); + + // Send NAMES list + await SendNamesReplyAsync(channelName); + + // Replay history + foreach (var m in history) + { + var lines = IrcMessageFormatter.FormatMessage(m); + foreach (var line in lines) + await _conn.SendAsync(line); + } + } + } + + private async Task HandlePartAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + if (msg.Parameters.Count < 1) return; + + var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries); + var partMessage = msg.Parameters.Count > 1 ? msg.Parameters[1] : null; + + foreach (var rawChannel in channels) + { + var channelName = IrcToEchoHubChannel(rawChannel); + if (channelName is null) continue; + + await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName); + _conn.JoinedChannels.Remove(channelName); + + await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" + + (partMessage is not null ? $" :{partMessage}" : "")); + } + } + + private async Task HandlePrivmsgAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + + if (msg.Parameters.Count < 2) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS, + "PRIVMSG :Not enough parameters"); + return; + } + + var target = msg.Parameters[0]; + var content = msg.Parameters[1]; + + if (!target.StartsWith('#')) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHNICK, + $"{target} :Private messages are not supported. Use channels."); + return; + } + + var channelName = IrcToEchoHubChannel(target); + if (channelName is null) return; + + var error = await _chatService.SendMessageAsync( + _conn.UserId!.Value, _conn.Nickname!, channelName, content); + + if (error is not null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CANNOTSENDTOCHAN, + $"#{channelName} :{error}"); + } + } + + private async Task HandleQuitAsync(IrcMessage msg) + { + var quitMessage = msg.Parameters.Count > 0 ? msg.Parameters[0] : "Client quit"; + await _conn.SendAsync($"ERROR :Closing Link: {_conn.Nickname} ({quitMessage})"); + } + + // ── Query Commands ────────────────────────────────────────────────────── + + private async Task HandleNamesAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + if (msg.Parameters.Count < 1) return; + + var channelName = IrcToEchoHubChannel(msg.Parameters[0]); + if (channelName is null) return; + + await SendNamesReplyAsync(channelName); + } + + private async Task SendNamesReplyAsync(string channelName) + { + var users = await _chatService.GetOnlineUsersAsync(channelName); + var nicks = string.Join(" ", users.Select(u => u.Username)); + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NAMREPLY, + $"= #{channelName} :{nicks}"); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFNAMES, + $"#{channelName} :End of /NAMES list"); + } + + private async Task HandleTopicAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + if (msg.Parameters.Count < 1) return; + + var channelName = IrcToEchoHubChannel(msg.Parameters[0]); + if (channelName is null) return; + + if (msg.Parameters.Count == 1) + { + await SendChannelTopicAsync(channelName); + } + else + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CHANOPRIVSNEEDED, + $"#{channelName} :Topic can only be changed by the channel creator via the API"); + } + } + + private async Task SendChannelTopicAsync(string channelName) + { + var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName); + + if (!exists) return; + + if (topic is not null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_TOPIC, + $"#{channelName} :{topic}"); + } + else + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NOTOPIC, + $"#{channelName} :No topic is set"); + } + } + + private async Task HandleWhoAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + if (msg.Parameters.Count < 1) return; + + var channelName = IrcToEchoHubChannel(msg.Parameters[0]); + if (channelName is null) return; + + var users = await _chatService.GetOnlineUsersAsync(channelName); + + foreach (var u in users) + { + var awayFlag = u.Status == UserStatus.Away ? "G" : "H"; + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOREPLY, + $"#{channelName} {u.Username} echohub {ServerName} {u.Username} {awayFlag} :0 {u.DisplayName ?? u.Username}"); + } + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFWHO, + $"#{channelName} :End of WHO list"); + } + + private async Task HandleWhoisAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + if (msg.Parameters.Count < 1) return; + + var nick = msg.Parameters[^1].ToLowerInvariant(); + var profile = await _chatService.GetUserProfileAsync(nick); + + if (profile is null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHNICK, + $"{nick} :No such nick/channel"); + return; + } + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISUSER, + $"{nick} {nick} echohub * :{profile.DisplayName ?? nick}"); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISSERVER, + $"{nick} {ServerName} :EchoHub IRC Gateway"); + + var channels = await _chatService.GetChannelsForUserAsync(nick); + if (channels.Count > 0) + { + var chanList = string.Join(" ", channels.Select(c => $"#{c}")); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISCHANNELS, + $"{nick} :{chanList}"); + } + + if (profile.Status == UserStatus.Away && profile.StatusMessage is not null) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_AWAY, + $"{nick} :{profile.StatusMessage}"); + } + + var idleSeconds = (long)(DateTimeOffset.UtcNow - profile.LastSeenAt).TotalSeconds; + var signonUnix = profile.CreatedAt.ToUnixTimeSeconds(); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISIDLE, + $"{nick} {idleSeconds} {signonUnix} :seconds idle, signon time"); + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFWHOIS, + $"{nick} :End of WHOIS list"); + } + + private async Task HandleAwayAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + + if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0])) + { + _conn.AwayMessage = msg.Parameters[0]; + await _chatService.UpdateStatusAsync( + _conn.UserId!.Value, _conn.Nickname!, UserStatus.Away, _conn.AwayMessage); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NOWAWAY, + ":You have been marked as being away"); + } + else + { + _conn.AwayMessage = null; + await _chatService.UpdateStatusAsync( + _conn.UserId!.Value, _conn.Nickname!, UserStatus.Online, null); + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UNAWAY, + ":You are no longer marked as being away"); + } + } + + private async Task HandleListAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + + var channels = await _chatService.GetChannelListAsync(); + + foreach (var ch in channels) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LIST, + $"#{ch.Name} {ch.OnlineCount} :{ch.Topic ?? ""}"); + } + + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LISTEND, + ":End of LIST"); + } + + private async Task HandleModeAsync(IrcMessage msg) + { + if (!RequireRegistered()) return; + if (msg.Parameters.Count < 1) return; + + var target = msg.Parameters[0]; + + if (target.StartsWith('#')) + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS, + $"{target} +"); + } + else + { + await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UMODEIS, "+"); + } + } + + private async Task HandlePingAsync(IrcMessage msg) + { + var token = msg.Parameters.Count > 0 ? msg.Parameters[0] : ServerName; + await _conn.SendAsync($":{ServerName} PONG {ServerName} :{token}"); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private bool RequireRegistered() + { + if (_conn.IsRegistered) return true; + + _ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED, + ":You have not registered"); + return false; + } + + private static string? IrcToEchoHubChannel(string ircChannel) + { + if (!ircChannel.StartsWith('#') || ircChannel.Length < 2) + return null; + + var name = ircChannel[1..].ToLowerInvariant().Trim(); + return ValidationConstants.ChannelNameRegex().IsMatch(name) ? name : null; + } +} diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs new file mode 100644 index 0000000..65c02ae --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -0,0 +1,157 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using EchoHub.Core.Contracts; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace EchoHub.Server.Irc; + +public sealed class IrcGatewayService : BackgroundService +{ + private readonly IrcOptions _options; + private readonly IChatService _chatService; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _connections = new(); + + public IrcOptions Options => _options; + public IReadOnlyDictionary Connections => _connections; + + public IrcGatewayService( + IOptions options, + IChatService chatService, + ILogger logger) + { + _options = options.Value; + _chatService = chatService; + _logger = logger; + } + + public IEnumerable GetConnectionsInChannel(string channelName) + { + return _connections.Values + .Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Yield(); + + if (!_options.Enabled) + { + _logger.LogInformation("IRC gateway is disabled"); + return; + } + + var listeners = new List(); + + listeners.Add(RunListenerAsync(_options.Port, useTls: false, stoppingToken)); + + if (_options.TlsEnabled && !string.IsNullOrWhiteSpace(_options.TlsCertPath)) + { + listeners.Add(RunListenerAsync(_options.TlsPort, useTls: true, stoppingToken)); + } + + await Task.WhenAll(listeners); + } + + private async Task RunListenerAsync(int port, bool useTls, CancellationToken ct) + { + var listener = new TcpListener(IPAddress.Any, port); + listener.Start(); + _logger.LogInformation("IRC gateway listening on port {Port} ({Mode})", + port, useTls ? "TLS" : "plain"); + + ct.Register(() => listener.Stop()); + + try + { + while (!ct.IsCancellationRequested) + { + var tcpClient = await listener.AcceptTcpClientAsync(ct); + _ = HandleClientAsync(tcpClient, useTls, ct); + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + finally + { + listener.Stop(); + } + } + + private async Task HandleClientAsync(TcpClient tcpClient, bool useTls, CancellationToken ct) + { + Stream stream = tcpClient.GetStream(); + + if (useTls) + { + try + { + var cert = X509CertificateLoader.LoadPkcs12FromFile(_options.TlsCertPath!, _options.TlsCertPassword); + var sslStream = new SslStream(stream, leaveInnerStreamOpen: false); + await sslStream.AuthenticateAsServerAsync(cert); + stream = sslStream; + } + catch (Exception ex) + { + _logger.LogError(ex, "TLS handshake failed"); + tcpClient.Close(); + return; + } + } + + var connection = new IrcClientConnection(tcpClient, stream); + _connections[connection.ConnectionId] = connection; + + _logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId); + + try + { + var handler = new IrcCommandHandler( + connection, _options, _chatService, _logger); + + await handler.RunAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "IRC client {Id} error", connection.ConnectionId); + } + finally + { + if (connection.IsAuthenticated) + { + foreach (var ch in connection.JoinedChannels.ToList()) + { + await _chatService.LeaveChannelAsync( + connection.ConnectionId, connection.Nickname!, ch); + } + await _chatService.UserDisconnectedAsync(connection.ConnectionId); + } + + _connections.TryRemove(connection.ConnectionId, out _); + await connection.DisposeAsync(); + _logger.LogInformation("IRC client {Id} ({Nick}) disconnected", + connection.ConnectionId, connection.Nickname ?? "unregistered"); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + foreach (var (_, conn) in _connections) + { + try + { + await conn.SendAsync("ERROR :Server shutting down"); + await conn.DisposeAsync(); + } + catch { } + } + _connections.Clear(); + + await base.StopAsync(cancellationToken); + } +} diff --git a/src/EchoHub.Server.Irc/IrcMessage.cs b/src/EchoHub.Server.Irc/IrcMessage.cs new file mode 100644 index 0000000..f84160f --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcMessage.cs @@ -0,0 +1,69 @@ +namespace EchoHub.Server.Irc; + +/// +/// Parsed representation of an IRC protocol line. +/// Format: [:prefix] COMMAND [params...] [:trailing] +/// +public sealed class IrcMessage +{ + public string? Prefix { get; init; } + public string Command { get; init; } = ""; + public List Parameters { get; init; } = []; + + public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null; + + /// + /// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF + /// + public static IrcMessage Parse(string line) + { + var span = line.AsSpan().TrimEnd("\r\n"); + string? prefix = null; + var pos = 0; + + // Parse optional prefix + if (span.Length > 0 && span[0] == ':') + { + var spaceIdx = span.IndexOf(' '); + if (spaceIdx == -1) + return new IrcMessage { Prefix = span[1..].ToString() }; + + prefix = span[1..spaceIdx].ToString(); + pos = spaceIdx + 1; + } + + // Skip whitespace + while (pos < span.Length && span[pos] == ' ') pos++; + + // Parse command + var cmdStart = pos; + while (pos < span.Length && span[pos] != ' ') pos++; + var command = span[cmdStart..pos].ToString(); + + // Parse parameters + var parameters = new List(); + while (pos < span.Length) + { + while (pos < span.Length && span[pos] == ' ') pos++; + if (pos >= span.Length) break; + + if (span[pos] == ':') + { + // Trailing parameter (rest of line) + parameters.Add(span[(pos + 1)..].ToString()); + break; + } + + var paramStart = pos; + while (pos < span.Length && span[pos] != ' ') pos++; + parameters.Add(span[paramStart..pos].ToString()); + } + + return new IrcMessage + { + Prefix = prefix, + Command = command, + Parameters = parameters, + }; + } +} diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs new file mode 100644 index 0000000..a161313 --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -0,0 +1,80 @@ +using System.Text; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; + +namespace EchoHub.Server.Irc; + +public static class IrcMessageFormatter +{ + private const int MaxIrcLineContentBytes = 400; + + /// + /// Format a MessageDto as one or more IRC PRIVMSG lines. + /// + public static List FormatMessage(MessageDto message) + { + var lines = new List(); + var ircChannel = $"#{message.ChannelName}"; + var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub"; + + switch (message.Type) + { + case MessageType.Text: + foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes)) + lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); + break; + + case MessageType.Image: + lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {message.AttachmentFileName}]"); + if (message.AttachmentUrl is not null) + lines.Add($"{prefix} PRIVMSG {ircChannel} :Download: {message.AttachmentUrl}"); + + foreach (var line in message.Content.Split('\n')) + { + var trimmed = line.TrimEnd('\r'); + if (trimmed.Length > 0) + lines.Add($"{prefix} PRIVMSG {ircChannel} :{trimmed}"); + } + break; + + case MessageType.File: + lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}"); + break; + } + + return lines; + } + + /// + /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. + /// + public static List SplitMessage(string content, int maxBytes) + { + if (Encoding.UTF8.GetByteCount(content) <= maxBytes) + return [content]; + + var chunks = new List(); + var current = new StringBuilder(); + var currentBytes = 0; + + foreach (var word in content.Split(' ')) + { + var wordBytes = Encoding.UTF8.GetByteCount(word) + 1; // +1 for space + + if (currentBytes + wordBytes > maxBytes && current.Length > 0) + { + chunks.Add(current.ToString().TrimEnd()); + current.Clear(); + currentBytes = 0; + } + + current.Append(word).Append(' '); + currentBytes += wordBytes; + } + + if (current.Length > 0) + chunks.Add(current.ToString().TrimEnd()); + + return chunks; + } +} diff --git a/src/EchoHub.Server.Irc/IrcNumericReply.cs b/src/EchoHub.Server.Irc/IrcNumericReply.cs new file mode 100644 index 0000000..60fdb21 --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcNumericReply.cs @@ -0,0 +1,65 @@ +namespace EchoHub.Server.Irc; + +public static class IrcNumericReply +{ + // Connection registration + public const string RPL_WELCOME = "001"; + public const string RPL_YOURHOST = "002"; + public const string RPL_CREATED = "003"; + public const string RPL_MYINFO = "004"; + public const string RPL_ISUPPORT = "005"; + + // MOTD + public const string RPL_MOTDSTART = "375"; + public const string RPL_MOTD = "372"; + public const string RPL_ENDOFMOTD = "376"; + public const string ERR_NOMOTD = "422"; + + // Channel operations + public const string RPL_NOTOPIC = "331"; + public const string RPL_TOPIC = "332"; + public const string RPL_NAMREPLY = "353"; + public const string RPL_ENDOFNAMES = "366"; + + // LIST + public const string RPL_LIST = "322"; + public const string RPL_LISTEND = "323"; + + // WHO / WHOIS + public const string RPL_WHOREPLY = "352"; + public const string RPL_ENDOFWHO = "315"; + public const string RPL_WHOISUSER = "311"; + public const string RPL_WHOISSERVER = "312"; + public const string RPL_WHOISIDLE = "317"; + public const string RPL_ENDOFWHOIS = "318"; + public const string RPL_WHOISCHANNELS = "319"; + + // AWAY + public const string RPL_UNAWAY = "305"; + public const string RPL_NOWAWAY = "306"; + public const string RPL_AWAY = "301"; + + // MODE + public const string RPL_CHANNELMODEIS = "324"; + public const string RPL_UMODEIS = "221"; + + // Errors + public const string ERR_NOSUCHNICK = "401"; + public const string ERR_NOSUCHCHANNEL = "403"; + public const string ERR_CANNOTSENDTOCHAN = "404"; + public const string ERR_UNKNOWNCOMMAND = "421"; + public const string ERR_NONICKNAMEGIVEN = "431"; + public const string ERR_ERRONEUSNICKNAME = "432"; + public const string ERR_NICKNAMEINUSE = "433"; + public const string ERR_NOTONCHANNEL = "442"; + public const string ERR_NOTREGISTERED = "451"; + public const string ERR_NEEDMOREPARAMS = "461"; + public const string ERR_ALREADYREGISTERED = "462"; + public const string ERR_PASSWDMISMATCH = "464"; + public const string ERR_CHANOPRIVSNEEDED = "482"; + + // SASL + public const string RPL_LOGGEDIN = "900"; + public const string RPL_SASLSUCCESS = "903"; + public const string ERR_SASLFAIL = "904"; +} diff --git a/src/EchoHub.Server.Irc/IrcOptions.cs b/src/EchoHub.Server.Irc/IrcOptions.cs new file mode 100644 index 0000000..2bfa9bd --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcOptions.cs @@ -0,0 +1,15 @@ +namespace EchoHub.Server.Irc; + +public sealed class IrcOptions +{ + public const string SectionName = "Irc"; + + public bool Enabled { get; set; } + public int Port { get; set; } = 6667; + public bool TlsEnabled { get; set; } + public int TlsPort { get; set; } = 6697; + public string? TlsCertPath { get; set; } + public string? TlsCertPassword { get; set; } + public string ServerName { get; set; } = "echohub"; + public string? Motd { get; set; } +} diff --git a/src/EchoHub.Server.Irc/IrcServiceExtensions.cs b/src/EchoHub.Server.Irc/IrcServiceExtensions.cs new file mode 100644 index 0000000..14c096b --- /dev/null +++ b/src/EchoHub.Server.Irc/IrcServiceExtensions.cs @@ -0,0 +1,26 @@ +using EchoHub.Core.Contracts; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EchoHub.Server.Irc; + +public static class IrcServiceExtensions +{ + public static WebApplicationBuilder AddIrcGateway(this WebApplicationBuilder builder) + { + builder.Services.Configure( + builder.Configuration.GetSection(IrcOptions.SectionName)); + + if (builder.Configuration.GetValue("Irc:Enabled")) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + new IrcBroadcaster(sp.GetRequiredService())); + builder.Services.AddHostedService(sp => + sp.GetRequiredService()); + } + + return builder; + } +} diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 93f4770..5d853d0 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -4,12 +4,10 @@ using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Data; -using EchoHub.Server.Hubs; using EchoHub.Server.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; namespace EchoHub.Server.Controllers; @@ -23,7 +21,7 @@ public class ChannelsController( FileStorageService fileStorage, ImageToAsciiService asciiService, IHttpClientFactory httpClientFactory, - IHubContext hubContext) : ControllerBase + IChatService chatService) : ControllerBase { [HttpGet] public async Task GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) @@ -78,7 +76,7 @@ public class ChannelsController( await db.SaveChangesAsync(); var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); - await hubContext.Clients.All.ChannelUpdated(dto); + await chatService.BroadcastChannelUpdatedAsync(dto); return Created($"/api/channels/{channelName}", dto); } @@ -107,7 +105,7 @@ public class ChannelsController( var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt); - await hubContext.Clients.Group(channelName).ChannelUpdated(dto); + await chatService.BroadcastChannelUpdatedAsync(dto, channelName); return Ok(dto); } @@ -214,7 +212,7 @@ public class ChannelsController( file.FileName, message.SentAt); - await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); + await chatService.BroadcastMessageAsync(channelName, messageDto); return Ok(messageDto); } @@ -331,7 +329,7 @@ public class ChannelsController( fileName, message.SentAt); - await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); + await chatService.BroadcastMessageAsync(channelName, messageDto); return Ok(messageDto); } diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj index 446b65c..93bbe03 100644 --- a/src/EchoHub.Server/EchoHub.Server.csproj +++ b/src/EchoHub.Server/EchoHub.Server.csproj @@ -2,6 +2,7 @@ + diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index c9c42de..eac6401 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -3,16 +3,13 @@ using EchoHub.Core.Constants; 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.SignalR; -using Microsoft.EntityFrameworkCore; namespace EchoHub.Server.Hubs; [Authorize] -public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTracker presenceTracker) : Hub +public class ChatHub(IChatService chatService, ILogger logger) : Hub { private Guid CurrentUserId => Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier) @@ -26,19 +23,8 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack { try { - presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername); - - var user = await db.Users.FindAsync(CurrentUserId); - if (user is not null) - { - user.LastSeenAt = DateTimeOffset.UtcNow; - user.Status = UserStatus.Online; - await db.SaveChangesAsync(); - } - + await chatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername); await base.OnConnectedAsync(); - - logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId); } catch (Exception ex) { @@ -51,39 +37,8 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack { try { - var preDisconnectUsername = Context.User?.FindFirstValue("username"); - var channelsBeforeDisconnect = preDisconnectUsername is not null - ? presenceTracker.GetChannelsForUser(preDisconnectUsername) - : []; - - var username = presenceTracker.UserDisconnected(Context.ConnectionId); - - if (username is not null && !presenceTracker.IsOnline(username)) - { - var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); - if (user is not null) - { - user.LastSeenAt = DateTimeOffset.UtcNow; - user.Status = UserStatus.Invisible; - await db.SaveChangesAsync(); - - var presence = new UserPresenceDto( - username, - user.DisplayName, - user.NicknameColor, - UserStatus.Invisible, - user.StatusMessage); - - foreach (var channel in channelsBeforeDisconnect) - { - await Clients.Group(channel).UserStatusChanged(presence); - } - } - } - + await chatService.UserDisconnectedAsync(Context.ConnectionId); await base.OnDisconnectedAsync(exception); - - logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId); } catch (Exception ex) { @@ -96,32 +51,16 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack { try { - channelName = channelName.ToLowerInvariant().Trim(); + var (history, error) = await chatService.JoinChannelAsync( + Context.ConnectionId, CurrentUserId, CurrentUsername, channelName); - if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + if (error is not null) { - await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); + await Clients.Caller.Error(error); return []; } - var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - - if (channel is null) - { - await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list."); - return []; - } - - var isNewJoin = presenceTracker.JoinChannel(CurrentUsername, channelName); - - if (isNewJoin) - { - await Groups.AddToGroupAsync(Context.ConnectionId, channelName); - await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername); - logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName); - } - - var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount); + await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim()); return history; } catch (Exception ex) @@ -137,13 +76,8 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack try { channelName = channelName.ToLowerInvariant().Trim(); - - presenceTracker.LeaveChannel(CurrentUsername, channelName); - + await chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName); await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName); - await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername); - - logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName); } catch (Exception ex) { @@ -156,64 +90,9 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack { try { - channelName = channelName.ToLowerInvariant().Trim(); - - if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) - { - await Clients.Caller.Error("Invalid channel name."); - return; - } - - if (string.IsNullOrWhiteSpace(content)) - { - await Clients.Caller.Error("Message content cannot be empty."); - return; - } - - if (content.Length > HubConstants.MaxMessageLength) - { - await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."); - return; - } - - var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - - if (channel is null) - { - await Clients.Caller.Error($"Channel '{channelName}' does not exist."); - return; - } - - var sender = await db.Users.FindAsync(CurrentUserId); - - var message = new Message - { - Id = Guid.NewGuid(), - Content = content, - Type = MessageType.Text, - SentAt = DateTimeOffset.UtcNow, - ChannelId = channel.Id, - SenderUserId = CurrentUserId, - SenderUsername = CurrentUsername, - }; - - db.Messages.Add(message); - await db.SaveChangesAsync(); - - var messageDto = new MessageDto( - message.Id, - message.Content, - message.SenderUsername, - sender?.NicknameColor, - channelName, - MessageType.Text, - null, - null, - message.SentAt); - - await Clients.Group(channelName).ReceiveMessage(messageDto); - - logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName); + var error = await chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content); + if (error is not null) + await Clients.Caller.Error(error); } catch (Exception ex) { @@ -226,35 +105,7 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack { try { - channelName = channelName.ToLowerInvariant().Trim(); - count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); - - var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - - if (channel is null) - return []; - - var messages = await db.Messages - .Where(m => m.ChannelId == channel.Id) - .OrderByDescending(m => m.SentAt) - .Take(count) - .Join(db.Users, - m => m.SenderUserId, - u => u.Id, - (m, u) => new MessageDto( - m.Id, - m.Content, - m.SenderUsername, - u.NicknameColor, - channelName, - m.Type, - m.AttachmentUrl, - m.AttachmentFileName, - m.SentAt)) - .ToListAsync(); - - messages.Reverse(); - return messages; + return await chatService.GetChannelHistoryAsync(channelName, count); } catch (Exception ex) { @@ -268,37 +119,9 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack { try { - if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength) - { - await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters."); - return; - } - - var user = await db.Users.FindAsync(CurrentUserId); - - if (user is null) - { - await Clients.Caller.Error("User not found."); - return; - } - - user.Status = status; - user.StatusMessage = statusMessage?.Trim(); - user.LastSeenAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(); - - var presence = new UserPresenceDto( - user.Username, - user.DisplayName, - user.NicknameColor, - status, - statusMessage); - - var channels = presenceTracker.GetChannelsForUser(CurrentUsername); - var connections = presenceTracker.GetConnectionsInChannels(channels); - - if (connections.Count > 0) - await Clients.Clients(connections).UserStatusChanged(presence); + var error = await chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage); + if (error is not null) + await Clients.Caller.Error(error); } catch (Exception ex) { @@ -311,21 +134,7 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack { try { - channelName = channelName.ToLowerInvariant().Trim(); - - var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName); - - var users = await db.Users - .Where(u => onlineUsernames.Contains(u.Username)) - .Select(u => new UserPresenceDto( - u.Username, - u.DisplayName, - u.NicknameColor, - u.Status, - u.StatusMessage)) - .ToListAsync(); - - return users; + return await chatService.GetOnlineUsersAsync(channelName); } catch (Exception ex) { diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index ccbae80..33ca3b8 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -1,10 +1,12 @@ using System.Text; using System.Threading.RateLimiting; using EchoHub.Core.Constants; +using EchoHub.Core.Contracts; using EchoHub.Core.Models; using EchoHub.Server.Auth; using EchoHub.Server.Data; using EchoHub.Server.Hubs; +using EchoHub.Server.Irc; using EchoHub.Server.Services; using EchoHub.Server.Setup; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -101,6 +103,14 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); + + // ── Chat Service + Broadcasters ───────────────────────────────────── + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // ── IRC Gateway (optional) ────────────────────────────────────────── + builder.AddIrcGateway(); + builder.Services.AddHttpClient("ImageDownload", client => { client.Timeout = TimeSpan.FromSeconds(15); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs new file mode 100644 index 0000000..900adcc --- /dev/null +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -0,0 +1,330 @@ +using EchoHub.Core.Constants; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EchoHub.Server.Services; + +public class ChatService( + IServiceScopeFactory scopeFactory, + PresenceTracker presenceTracker, + IEnumerable broadcasters, + ILogger logger) : IChatService +{ + public async Task UserConnectedAsync(string connectionId, Guid userId, string username) + { + presenceTracker.UserConnected(connectionId, userId, username); + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FindAsync(userId); + if (user is not null) + { + user.LastSeenAt = DateTimeOffset.UtcNow; + user.Status = UserStatus.Online; + await db.SaveChangesAsync(); + } + + logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId); + } + + public async Task UserDisconnectedAsync(string connectionId) + { + var preDisconnectUsername = presenceTracker.GetUsernameForConnection(connectionId); + var channelsBeforeDisconnect = preDisconnectUsername is not null + ? presenceTracker.GetChannelsForUser(preDisconnectUsername) + : []; + + var username = presenceTracker.UserDisconnected(connectionId); + + if (username is not null && !presenceTracker.IsOnline(username)) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user is not null) + { + user.LastSeenAt = DateTimeOffset.UtcNow; + user.Status = UserStatus.Invisible; + await db.SaveChangesAsync(); + + var presence = new UserPresenceDto( + username, + user.DisplayName, + user.NicknameColor, + UserStatus.Invisible, + user.StatusMessage); + + await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence)); + } + } + + logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId); + return username; + } + + public async Task<(List History, string? Error)> JoinChannelAsync( + string connectionId, Guid userId, string username, string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (channel is null) + return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); + + var isNewJoin = presenceTracker.JoinChannel(username, channelName); + + if (isNewJoin) + { + await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId)); + logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); + } + + var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount); + return (history, null); + } + + public async Task LeaveChannelAsync(string connectionId, string username, string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + presenceTracker.LeaveChannel(username, channelName); + await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username)); + logger.LogInformation("{User} left channel '{Channel}'", username, channelName); + } + + public async Task SendMessageAsync(Guid userId, string username, string channelName, string content) + { + channelName = channelName.ToLowerInvariant().Trim(); + + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + return "Invalid channel name."; + + if (string.IsNullOrWhiteSpace(content)) + return "Message content cannot be empty."; + + if (content.Length > HubConstants.MaxMessageLength) + return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (channel is null) + return $"Channel '{channelName}' does not exist."; + + var sender = await db.Users.FindAsync(userId); + + var message = new Message + { + Id = Guid.NewGuid(), + Content = content, + Type = MessageType.Text, + SentAt = DateTimeOffset.UtcNow, + ChannelId = channel.Id, + SenderUserId = userId, + SenderUsername = username, + }; + + db.Messages.Add(message); + await db.SaveChangesAsync(); + + var messageDto = new MessageDto( + message.Id, + message.Content, + message.SenderUsername, + sender?.NicknameColor, + channelName, + MessageType.Text, + null, + null, + message.SentAt); + + await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); + + logger.LogDebug("{User} sent message in '{Channel}'", username, channelName); + return null; + } + + public async Task> GetChannelHistoryAsync(string channelName, int count) + { + channelName = channelName.ToLowerInvariant().Trim(); + count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + return await GetChannelHistoryInternalAsync(db, channelName, count); + } + + public async Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) + { + if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength) + return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters."; + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FindAsync(userId); + if (user is null) + return "User not found."; + + user.Status = status; + user.StatusMessage = statusMessage?.Trim(); + user.LastSeenAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + var presence = new UserPresenceDto( + user.Username, + user.DisplayName, + user.NicknameColor, + status, + statusMessage); + + var channels = presenceTracker.GetChannelsForUser(username); + await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); + + return null; + } + + public async Task> GetOnlineUsersAsync(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName); + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + return await db.Users + .Where(u => onlineUsernames.Contains(u.Username)) + .Select(u => new UserPresenceDto( + u.Username, + u.DisplayName, + u.NicknameColor, + u.Status, + u.StatusMessage)) + .ToListAsync(); + } + + public Task BroadcastMessageAsync(string channelName, MessageDto message) + => BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, message)); + + public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) + => BroadcastToAllAsync(b => b.SendChannelUpdatedAsync(channel, channelName)); + + private async Task BroadcastToAllAsync(Func action) + { + foreach (var broadcaster in broadcasters) + { + try + { + await action(broadcaster); + } + catch (Exception ex) + { + logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name); + } + } + } + + public async Task GetUserProfileAsync(string username) + { + username = username.ToLowerInvariant(); + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user is null) return null; + + return new UserProfileDto( + user.Id, user.Username, user.DisplayName, user.Bio, + user.NicknameColor, user.AvatarAscii, user.Status, + user.StatusMessage, user.CreatedAt, user.LastSeenAt); + } + + public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (channel is null) return (null, false); + + return (channel.Topic, true); + } + + public async Task> GetChannelListAsync() + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync(); + + return channels.Select(c => new ChannelListItem( + c.Name, + c.Topic, + presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); + } + + public Task> GetChannelsForUserAsync(string username) + => Task.FromResult(presenceTracker.GetChannelsForUser(username)); + + public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) + { + username = username.ToLowerInvariant(); + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user is null) return null; + + if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) + return null; + + return (user.Id, user.Username); + } + + private static async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) + { + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (channel is null) + return []; + + var messages = await db.Messages + .Where(m => m.ChannelId == channel.Id) + .OrderByDescending(m => m.SentAt) + .Take(count) + .Join(db.Users, + m => m.SenderUserId, + u => u.Id, + (m, u) => new MessageDto( + m.Id, + m.Content, + m.SenderUsername, + u.NicknameColor, + channelName, + m.Type, + m.AttachmentUrl, + m.AttachmentFileName, + m.SentAt)) + .ToListAsync(); + + messages.Reverse(); + return messages; + } +} diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index eccb9f4..627718d 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -137,6 +137,11 @@ public class PresenceTracker } } + public string? GetUsernameForConnection(string connectionId) + { + return _connections.TryGetValue(connectionId, out var info) ? info.username : null; + } + public bool IsOnline(string username) { return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs new file mode 100644 index 0000000..29d29a5 --- /dev/null +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -0,0 +1,53 @@ +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Server.Hubs; +using Microsoft.AspNetCore.SignalR; + +namespace EchoHub.Server.Services; + +public class SignalRBroadcaster( + IHubContext hubContext, + PresenceTracker presenceTracker) : IChatBroadcaster +{ + public Task SendMessageToChannelAsync(string channelName, MessageDto message) + => hubContext.Clients.Group(channelName).ReceiveMessage(message); + + public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null) + { + if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-")) + return hubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username); + + return hubContext.Clients.Group(channelName).UserJoined(channelName, username); + } + + public Task SendUserLeftAsync(string channelName, string username) + => hubContext.Clients.Group(channelName).UserLeft(channelName, username); + + public Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null) + { + if (channelName is not null) + return hubContext.Clients.Group(channelName).ChannelUpdated(channel); + + return hubContext.Clients.All.ChannelUpdated(channel); + } + + public Task SendUserStatusChangedAsync(List channelNames, UserPresenceDto presence) + { + var connections = presenceTracker.GetConnectionsInChannels(channelNames) + .Where(c => !c.StartsWith("irc-")) + .ToList(); + + if (connections.Count == 0) + return Task.CompletedTask; + + return hubContext.Clients.Clients(connections).UserStatusChanged(presence); + } + + public Task SendErrorAsync(string connectionId, string message) + { + if (connectionId.StartsWith("irc-")) + return Task.CompletedTask; + + return hubContext.Clients.Client(connectionId).Error(message); + } +} diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index 46200f0..16d0607 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -14,6 +14,16 @@ "PublicServer": false, "PublicHost": "" }, + "Irc": { + "Enabled": false, + "Port": 6667, + "TlsEnabled": false, + "TlsPort": 6697, + "TlsCertPath": "", + "TlsCertPassword": "", + "ServerName": "echohub", + "Motd": "Welcome to EchoHub IRC Gateway!" + }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/src/EchoHub.slnx b/src/EchoHub.slnx index 6abeb04..6478113 100644 --- a/src/EchoHub.slnx +++ b/src/EchoHub.slnx @@ -2,5 +2,6 @@ +