From 27a25b1b436d2f73ba4818b74fc563e8c3d6bb56 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 14:51:04 +0100 Subject: [PATCH 1/4] feat: implement user registration and update SASL authentication handling --- docs/changelog/index.md | 3 ++ docs/changelog/toc.yml | 2 ++ docs/changelog/v0.2.7.md | 8 ------ docs/changelog/v0.2.8.md | 15 ++++++++++ src/Directory.Build.props | 2 +- src/EchoHub.Core/Contracts/IChatService.cs | 1 + src/EchoHub.Server.Irc/IrcCommandHandler.cs | 12 ++++++-- src/EchoHub.Server/Services/ChatService.cs | 32 +++++++++++++++++++++ 8 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 docs/changelog/v0.2.8.md diff --git a/docs/changelog/index.md b/docs/changelog/index.md index f35822f..d589a66 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,6 +4,9 @@ Release history for EchoHub. ## Releases +- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix +- [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration +- [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization - [v0.2.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme - [v0.2.4](v0.2.4.md) - E2E Message Encryption - [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 7f71eca..7bc9ec0 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.8 + href: v0.2.8.md - name: v0.2.7 href: v0.2.7.md - name: v0.2.6 diff --git a/docs/changelog/v0.2.7.md b/docs/changelog/v0.2.7.md index 198ccfe..929c70c 100644 --- a/docs/changelog/v0.2.7.md +++ b/docs/changelog/v0.2.7.md @@ -4,14 +4,6 @@ - Fix user list empty on initial connect — `FetchAndUpdateOnlineUsers` was called before `InvokeUI` set the current channel, causing an early return -## New Features - -- Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs - -## CI - -- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release - ## Infrastructure - Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md new file mode 100644 index 0000000..7ffff4b --- /dev/null +++ b/docs/changelog/v0.2.8.md @@ -0,0 +1,15 @@ +# v0.2.8 + +## Bug Fixes + +- Fix IRC gateway sending UTF-8 BOM on first message, breaking CAP negotiation and SASL auth for all clients +- Handle `AUTHENTICATE *` (SASL abort) instead of crashing on invalid base64 + +## New Features + +- Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs +- IRC account creation — connecting with a new username auto-registers the account (PASS and SASL PLAIN) + +## CI + +- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 31d2558..744d01d 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.7 + 0.2.8 true $(NoWarn);CS1591 diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index 03ea1c2..97d880a 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -29,4 +29,5 @@ public interface IChatService Task GetUserProfileAsync(string username); Task> GetChannelsForUserAsync(string username); Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); + Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password); } diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index f2f6a0f..00b4370 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -170,9 +170,13 @@ public sealed class IrcCommandHandler var result = await _chatService.AuthenticateUserAsync(username, password); + // Auth failed — try registering a new account + if (result is null) + result = await _chatService.RegisterUserAsync(username, password); + if (result is null) { - _logger.LogWarning("SASL auth failed for user '{Username}' (connection {Id})", + _logger.LogWarning("SASL auth/register failed for user '{Username}' (connection {Id})", username, _conn.ConnectionId); await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL, ":SASL authentication failed"); @@ -286,10 +290,14 @@ public sealed class IrcCommandHandler var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password); + // Auth failed — try registering a new account + if (result is null) + result = await _chatService.RegisterUserAsync(_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."); + ":Password incorrect."); await _conn.SendAsync("ERROR :Authentication failed"); return; } diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index b5010fd..63954c1 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -339,6 +339,38 @@ public class ChatService : IChatService return (user.Id, user.Username); } + public async Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password) + { + username = username.ToLowerInvariant().Trim(); + + if (!ValidationConstants.UsernameRegex().IsMatch(username)) + return null; + + if (password.Length < 6 || password.Length > ValidationConstants.MaxPasswordLength) + return null; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (await db.Users.AnyAsync(u => u.Username == username)) + return null; + + var isFirstUser = !await db.Users.AnyAsync(); + + var user = new User + { + Id = Guid.NewGuid(), + Username = username, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), + Role = isFirstUser ? ServerRole.Owner : ServerRole.Member, + }; + + db.Users.Add(user); + await db.SaveChangesAsync(); + + return (user.Id, user.Username); + } + /// /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// From bdcff74ad51887c6263fe8a73e6953d7b8c0cdb9 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 14:51:24 +0100 Subject: [PATCH 2/4] Refactor user management: Extract IUserService and UserService, consolidate user registration, authentication, and profile management. Fix memory leaks in ApiClient, enhance connection management, and improve error handling in AuthController and UsersController. Update IRC command handling to utilize IUserService for user operations. --- docs/changelog/v0.2.8.md | 8 + src/EchoHub.Client/Services/ApiClient.cs | 58 +++--- .../Services/ConnectionManager.cs | 122 +++++++------ src/EchoHub.Core/Contracts/IChatService.cs | 5 +- src/EchoHub.Core/Contracts/IUserService.cs | 13 ++ src/EchoHub.Core/DTOs/CommonDtos.cs | 17 ++ src/EchoHub.Server.Irc/IrcCommandHandler.cs | 39 ++-- src/EchoHub.Server.Irc/IrcGatewayService.cs | 3 +- src/EchoHub.Server/Auth/JwtTokenService.cs | 26 +++ .../Controllers/AuthController.cs | 81 +++------ .../Controllers/UsersController.cs | 82 +++------ src/EchoHub.Server/Program.cs | 1 + src/EchoHub.Server/Services/ChatService.cs | 64 ------- src/EchoHub.Server/Services/UserService.cs | 172 ++++++++++++++++++ 14 files changed, 409 insertions(+), 282 deletions(-) create mode 100644 src/EchoHub.Core/Contracts/IUserService.cs create mode 100644 src/EchoHub.Server/Services/UserService.cs diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md index 7ffff4b..d147319 100644 --- a/docs/changelog/v0.2.8.md +++ b/docs/changelog/v0.2.8.md @@ -2,6 +2,9 @@ ## Bug Fixes +- Fix memory leak — `HttpResponseMessage` objects never disposed in `ApiClient`, leaking TCP connections and content buffers on every API call (especially on failed connection attempts) +- Fix 401 retry leak — `AuthenticatedGetAsync`/`AuthenticatedRequestAsync` leaked the original response when retrying after token refresh +- Fix connection failure cleanup — `ConnectionManager.ConnectAsync` now properly disposes `ApiClient` and `EchoHubConnection` on any failure path (previously only cleaned up on saved-token auth failures) - Fix IRC gateway sending UTF-8 BOM on first message, breaking CAP negotiation and SASL auth for all clients - Handle `AUTHENTICATE *` (SASL abort) instead of crashing on invalid base64 @@ -10,6 +13,11 @@ - Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs - IRC account creation — connecting with a new username auto-registers the account (PASS and SASL PLAIN) +## Refactoring + +- Extract `IUserService`/`UserService` — consolidate user registration, authentication, and profile management into a dedicated service, eliminating duplicated logic between `AuthController` and `ChatService` +- IRC gateway now checks ban status during authentication (previously skipped) + ## CI - Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index fea3b2a..2d3d351 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -32,7 +32,7 @@ public sealed class ApiClient : IDisposable public async Task RegisterAsync(string username, string password, string? displayName = null) { var request = new RegisterRequest(username, password, displayName); - var response = await _http.PostAsJsonAsync("/api/auth/register", request); + using var response = await _http.PostAsJsonAsync("/api/auth/register", request); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() @@ -45,7 +45,7 @@ public sealed class ApiClient : IDisposable public async Task LoginAsync(string username, string password) { var request = new LoginRequest(username, password); - var response = await _http.PostAsJsonAsync("/api/auth/login", request); + using var response = await _http.PostAsJsonAsync("/api/auth/login", request); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() @@ -61,7 +61,7 @@ public sealed class ApiClient : IDisposable throw new InvalidOperationException("No refresh token available."); var request = new RefreshRequest(_refreshToken); - var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); + using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() @@ -73,7 +73,7 @@ public sealed class ApiClient : IDisposable public async Task LoginWithRefreshTokenAsync(string refreshToken) { var request = new RefreshRequest(refreshToken); - var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); + using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() @@ -90,7 +90,7 @@ public sealed class ApiClient : IDisposable try { var request = new RefreshRequest(_refreshToken); - await _http.PostAsJsonAsync("/api/auth/logout", request); + using var response = await _http.PostAsJsonAsync("/api/auth/logout", request); } catch { @@ -131,7 +131,7 @@ public sealed class ApiClient : IDisposable public async Task> GetChannelsAsync() { EnsureAuthenticated(); - var response = await AuthenticatedGetAsync("/api/channels"); + using var response = await AuthenticatedGetAsync("/api/channels"); await EnsureSuccessAsync(response); var paginated = await response.Content.ReadFromJsonAsync>(); return paginated?.Items ?? []; @@ -146,7 +146,7 @@ public sealed class ApiClient : IDisposable public async Task GetEncryptionKeyAsync() { EnsureAuthenticated(); - var response = await AuthenticatedGetAsync("/api/server/encryption-key"); + using var response = await AuthenticatedGetAsync("/api/server/encryption-key"); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync() ?? throw new InvalidOperationException("Server returned empty encryption key response."); @@ -156,7 +156,7 @@ public sealed class ApiClient : IDisposable public async Task GetUserProfileAsync(string username) { EnsureAuthenticated(); - var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile"); + using var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile"); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); } @@ -164,7 +164,7 @@ public sealed class ApiClient : IDisposable public async Task UpdateProfileAsync(UpdateProfileRequest request) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PutAsJsonAsync("/api/users/profile", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -178,7 +178,7 @@ public sealed class ApiClient : IDisposable streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); content.Add(streamContent, "file", fileName); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsync("/api/users/avatar", content)); await EnsureSuccessAsync(response); var result = await response.Content.ReadFromJsonAsync(); @@ -194,7 +194,7 @@ public sealed class ApiClient : IDisposable content.Add(streamContent, "file", fileName); var sizeQuery = size is not null ? $"?size={size}" : ""; - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -205,7 +205,7 @@ public sealed class ApiClient : IDisposable EnsureAuthenticated(); var request = new SendUrlRequest(url); var sizeQuery = size is not null ? $"?size={size}" : ""; - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -214,7 +214,7 @@ public sealed class ApiClient : IDisposable public async Task DownloadFileToTempAsync(string relativeUrl, string fileName) { EnsureAuthenticated(); - var response = await AuthenticatedGetAsync(relativeUrl); + using var response = await AuthenticatedGetAsync(relativeUrl); await EnsureSuccessAsync(response); var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub"); @@ -232,7 +232,7 @@ public sealed class ApiClient : IDisposable { EnsureAuthenticated(); var request = new CreateChannelRequest(name, topic, isPublic); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync("/api/channels", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -242,7 +242,7 @@ public sealed class ApiClient : IDisposable { EnsureAuthenticated(); var request = new UpdateTopicRequest(topic); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); @@ -251,7 +251,7 @@ public sealed class ApiClient : IDisposable public async Task DeleteChannelAsync(string channelName) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}")); await EnsureSuccessAsync(response); } @@ -261,7 +261,7 @@ public sealed class ApiClient : IDisposable public async Task AssignRoleAsync(string username, ServerRole role) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role))); await EnsureSuccessAsync(response); } @@ -269,7 +269,7 @@ public sealed class ApiClient : IDisposable public async Task KickUserAsync(string username, string? reason = null) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason))); await EnsureSuccessAsync(response); } @@ -277,7 +277,7 @@ public sealed class ApiClient : IDisposable public async Task BanUserAsync(string username, string? reason = null) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason))); await EnsureSuccessAsync(response); } @@ -285,7 +285,7 @@ public sealed class ApiClient : IDisposable public async Task UnbanUserAsync(string username) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { })); await EnsureSuccessAsync(response); } @@ -293,7 +293,7 @@ public sealed class ApiClient : IDisposable public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes))); await EnsureSuccessAsync(response); } @@ -301,7 +301,7 @@ public sealed class ApiClient : IDisposable public async Task UnmuteUserAsync(string username) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { })); await EnsureSuccessAsync(response); } @@ -309,7 +309,7 @@ public sealed class ApiClient : IDisposable public async Task DeleteMessageAsync(Guid messageId) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.DeleteAsync($"/api/moderation/messages/{messageId}")); await EnsureSuccessAsync(response); } @@ -317,7 +317,7 @@ public sealed class ApiClient : IDisposable public async Task NukeChannelAsync(string channelName) { EnsureAuthenticated(); - var response = await AuthenticatedRequestAsync(() => + using var response = await AuthenticatedRequestAsync(() => _http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke")); await EnsureSuccessAsync(response); } @@ -333,6 +333,7 @@ public sealed class ApiClient : IDisposable /// /// Performs a GET request with automatic token refresh on 401. + /// Caller is responsible for disposing the returned response. /// private async Task AuthenticatedGetAsync(string url) { @@ -343,7 +344,9 @@ public sealed class ApiClient : IDisposable try { await RefreshTokenAsync(); - response = await _http.GetAsync(url); + var retryResponse = await _http.GetAsync(url); + response.Dispose(); + response = retryResponse; } catch { @@ -356,6 +359,7 @@ public sealed class ApiClient : IDisposable /// /// Performs a request with automatic token refresh on 401. + /// Caller is responsible for disposing the returned response. /// private async Task AuthenticatedRequestAsync(Func> requestFactory) { @@ -366,7 +370,9 @@ public sealed class ApiClient : IDisposable try { await RefreshTokenAsync(); - response = await requestFactory(); + var retryResponse = await requestFactory(); + response.Dispose(); + response = retryResponse; } catch { diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index 50cca1c..6a45d5f 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -60,77 +60,83 @@ internal sealed class ConnectionManager : IAsyncDisposable _apiClient?.Dispose(); _apiClient = new ApiClient(info.ServerUrl); - onStatus("Authenticating..."); - - LoginResponse loginResponse; - - if (info.SavedRefreshToken is not null) + try { - try + onStatus("Authenticating..."); + + LoginResponse loginResponse; + + if (info.SavedRefreshToken is not null) { loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken); Log.Information("Authenticated via saved session for {User}", loginResponse.Username); } + else if (info.IsRegister) + { + loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password); + } + else + { + loginResponse = await _apiClient.LoginAsync(info.Username, info.Password); + } + + // Auto-persist rotated refresh tokens for Remember Me + _apiClient.OnTokensRefreshed += HandleTokensRefreshed; + + // E2E encryption key + onStatus("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"); + } + + onStatus("Authenticated, connecting..."); + + if (_connection is not null) + await _connection.DisposeAsync(); + + _connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption); + WireConnectionEvents(_connection); + await _connection.ConnectAsync(); + + var channels = await _apiClient.GetChannelsAsync(); + onStatus("Connected"); + + // Join default channel + fetch history + _joinedChannels.Clear(); + _joinedChannels.Add(HubConstants.DefaultChannel); + await _connection.JoinChannelAsync(HubConstants.DefaultChannel); + + List history = []; + try + { + history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel); + } catch { - _apiClient.Dispose(); - _apiClient = null; - throw; // Caller handles saved-session expiry + // History might not be available } - } - else if (info.IsRegister) - { - loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password); - } - else - { - loginResponse = await _apiClient.LoginAsync(info.Username, info.Password); - } - // Auto-persist rotated refresh tokens for Remember Me - _apiClient.OnTokensRefreshed += HandleTokensRefreshed; - - // E2E encryption key - onStatus("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"); - } - - onStatus("Authenticated, connecting..."); - - if (_connection is not null) - await _connection.DisposeAsync(); - - _connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption); - WireConnectionEvents(_connection); - await _connection.ConnectAsync(); - - var channels = await _apiClient.GetChannelsAsync(); - onStatus("Connected"); - - // Join default channel + fetch history - _joinedChannels.Clear(); - _joinedChannels.Add(HubConstants.DefaultChannel); - await _connection.JoinChannelAsync(HubConstants.DefaultChannel); - - List history = []; - try - { - history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel); + return new ConnectResult(loginResponse, channels, history); } catch { - // History might not be available - } + if (_connection is not null) + { + await _connection.DisposeAsync(); + _connection = null; + } - return new ConnectResult(loginResponse, channels, history); + _apiClient.Dispose(); + _apiClient = null; + throw; + } } // ── Cleanup ─────────────────────────────────────────────────────────── diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index 97d880a..2c1e0cb 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -25,9 +25,6 @@ public interface IChatService Task BroadcastMessageAsync(string channelName, MessageDto message); Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null); - // Query operations (used by IRC gateway for WHOIS, AUTH) - Task GetUserProfileAsync(string username); + // Query operations (used by IRC gateway for WHOIS) Task> GetChannelsForUserAsync(string username); - Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); - Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password); } diff --git a/src/EchoHub.Core/Contracts/IUserService.cs b/src/EchoHub.Core/Contracts/IUserService.cs new file mode 100644 index 0000000..9043e3d --- /dev/null +++ b/src/EchoHub.Core/Contracts/IUserService.cs @@ -0,0 +1,13 @@ +using EchoHub.Core.DTOs; + +namespace EchoHub.Core.Contracts; + +public interface IUserService +{ + Task RegisterUserAsync(string username, string password, string? displayName = null); + Task AuthenticateUserAsync(string username, string password); + Task GetUserProfileAsync(string username); + Task GetUserByIdAsync(Guid userId); + Task UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor); + Task SetAvatarAsync(Guid userId, string asciiArt); +} diff --git a/src/EchoHub.Core/DTOs/CommonDtos.cs b/src/EchoHub.Core/DTOs/CommonDtos.cs index d41c4f6..5781546 100644 --- a/src/EchoHub.Core/DTOs/CommonDtos.cs +++ b/src/EchoHub.Core/DTOs/CommonDtos.cs @@ -24,3 +24,20 @@ public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, s public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null); public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message); } + +public enum UserError +{ + ValidationFailed, + AlreadyExists, + NotFound, + InvalidCredentials, + Banned +} + +public record UserOperationResult(UserProfileDto? User, UserError? Error, string? ErrorMessage) +{ + public bool IsSuccess => Error is null; + + public static UserOperationResult Success(UserProfileDto user) => new(user, null, null); + public static UserOperationResult Fail(UserError error, string message) => new(null, error, message); +} diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 00b4370..4346181 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -12,6 +12,7 @@ public sealed class IrcCommandHandler private readonly IrcClientConnection _conn; private readonly IrcOptions _options; private readonly IChatService _chatService; + private readonly IUserService _userService; private readonly IChannelService _channelService; private readonly IMessageEncryptionService _encryption; private readonly ILogger _logger; @@ -22,6 +23,7 @@ public sealed class IrcCommandHandler IrcClientConnection conn, IrcOptions options, IChatService chatService, + IUserService userService, IChannelService channelService, IMessageEncryptionService encryption, ILogger logger) @@ -29,6 +31,7 @@ public sealed class IrcCommandHandler _conn = conn; _options = options; _chatService = chatService; + _userService = userService; _channelService = channelService; _encryption = encryption; _logger = logger; @@ -168,23 +171,23 @@ public sealed class IrcCommandHandler _logger.LogDebug("SASL PLAIN auth attempt for user '{Username}' (connection {Id})", username, _conn.ConnectionId); - var result = await _chatService.AuthenticateUserAsync(username, password); + var result = await _userService.AuthenticateUserAsync(username, password); // Auth failed — try registering a new account - if (result is null) - result = await _chatService.RegisterUserAsync(username, password); + if (!result.IsSuccess) + result = await _userService.RegisterUserAsync(username, password); - if (result is null) + if (!result.IsSuccess) { - _logger.LogWarning("SASL auth/register failed for user '{Username}' (connection {Id})", - username, _conn.ConnectionId); + _logger.LogWarning("SASL auth/register failed for user '{Username}': {Error} (connection {Id})", + username, result.ErrorMessage, _conn.ConnectionId); await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL, - ":SASL authentication failed"); + $":SASL authentication failed — {result.ErrorMessage}"); return; } - _conn.Nickname = result.Value.Username; - _conn.UserId = result.Value.UserId; + _conn.Nickname = result.User!.Username; + _conn.UserId = result.User!.Id; _conn.IsAuthenticated = true; _logger.LogInformation("SASL auth succeeded for user '{Username}' (connection {Id})", @@ -288,26 +291,26 @@ public sealed class IrcCommandHandler return; } - var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password); + var result = await _userService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password); // Auth failed — try registering a new account - if (result is null) - result = await _chatService.RegisterUserAsync(_conn.Nickname!, _conn.Password); + if (!result.IsSuccess) + result = await _userService.RegisterUserAsync(_conn.Nickname!, _conn.Password); - if (result is null) + if (!result.IsSuccess) { await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH, - ":Password incorrect."); + $":{result.ErrorMessage}"); await _conn.SendAsync("ERROR :Authentication failed"); return; } - _conn.UserId = result.Value.UserId; - _conn.Nickname = result.Value.Username; + _conn.UserId = result.User!.Id; + _conn.Nickname = result.User!.Username; _conn.IsAuthenticated = true; _conn.IsRegistered = true; - await _chatService.UserConnectedAsync(_conn.ConnectionId, result.Value.UserId, result.Value.Username); + await _chatService.UserConnectedAsync(_conn.ConnectionId, result.User!.Id, result.User!.Username); await SendWelcomeBurstAsync(); } @@ -559,7 +562,7 @@ public sealed class IrcCommandHandler if (msg.Parameters.Count < 1) return; var nick = msg.Parameters[^1].ToLowerInvariant(); - var profile = await _chatService.GetUserProfileAsync(nick); + var profile = await _userService.GetUserProfileAsync(nick); if (profile is null) { diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index d9f555c..357125b 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -120,10 +120,11 @@ public sealed class IrcGatewayService : BackgroundService try { chatService = _services.GetRequiredService(); + var userService = _services.GetRequiredService(); var channelService = _services.GetRequiredService(); var encryption = _services.GetRequiredService(); var handler = new IrcCommandHandler( - connection, _options, chatService, channelService, encryption, _logger); + connection, _options, chatService, userService, channelService, encryption, _logger); await handler.RunAsync(ct); } diff --git a/src/EchoHub.Server/Auth/JwtTokenService.cs b/src/EchoHub.Server/Auth/JwtTokenService.cs index d0e9477..5906999 100644 --- a/src/EchoHub.Server/Auth/JwtTokenService.cs +++ b/src/EchoHub.Server/Auth/JwtTokenService.cs @@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using System.Text; +using EchoHub.Core.DTOs; using EchoHub.Core.Models; using Microsoft.IdentityModel.Tokens; @@ -51,6 +52,31 @@ public class JwtTokenService return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); } + public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(UserProfileDto profile) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expiresAt = DateTimeOffset.UtcNow.Add(AccessTokenLifetime); + + Claim[] claims = + [ + new(JwtRegisteredClaimNames.Sub, profile.Id.ToString()), + new("username", profile.Username), + new("display_name", profile.DisplayName ?? profile.Username), + new("role", profile.Role.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + ]; + + var token = new JwtSecurityToken( + issuer: _issuer, + audience: _audience, + claims: claims, + expires: expiresAt.UtcDateTime, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); + } + public static string GenerateRefreshToken() { var randomBytes = new byte[64]; diff --git a/src/EchoHub.Server/Controllers/AuthController.cs b/src/EchoHub.Server/Controllers/AuthController.cs index ce61426..9317e8c 100644 --- a/src/EchoHub.Server/Controllers/AuthController.cs +++ b/src/EchoHub.Server/Controllers/AuthController.cs @@ -1,4 +1,4 @@ -using EchoHub.Core.Constants; +using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Auth; @@ -16,94 +16,59 @@ public class AuthController : ControllerBase { private readonly EchoHubDbContext _db; private readonly JwtTokenService _jwt; + private readonly IUserService _userService; - public AuthController(EchoHubDbContext db, JwtTokenService jwt) + public AuthController(EchoHubDbContext db, JwtTokenService jwt, IUserService userService) { _db = db; _jwt = jwt; + _userService = userService; } [HttpPost("register")] public async Task Register([FromBody] RegisterRequest request) { - if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) - return BadRequest(new ErrorResponse("Username and password are required.")); + var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName); + if (!result.IsSuccess) + return MapUserError(result); - if (!ValidationConstants.UsernameRegex().IsMatch(request.Username)) - return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens.")); - - if (request.Password.Length < 6) - return BadRequest(new ErrorResponse("Password must be at least 6 characters.")); - - if (request.Password.Length > ValidationConstants.MaxPasswordLength) - return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters.")); - - var normalizedUsername = request.Username.ToLowerInvariant().Trim(); - - if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername)) - 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 - { - Id = Guid.NewGuid(), - Username = normalizedUsername, - PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), - DisplayName = request.DisplayName?.Trim(), - Role = isFirstUser ? ServerRole.Owner : ServerRole.Member, - }; - - _db.Users.Add(user); - await _db.SaveChangesAsync(); - - var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user); + var profile = result.User!; + var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile); var refreshToken = JwtTokenService.GenerateRefreshToken(); _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), TokenHash = JwtTokenService.HashToken(refreshToken), - UserId = user.Id, + UserId = profile.Id, ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), }); await _db.SaveChangesAsync(); - return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); + return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, profile.Username, profile.DisplayName, profile.NicknameColor)); } [HttpPost("login")] public async Task Login([FromBody] LoginRequest request) { - if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) - return BadRequest(new ErrorResponse("Username and password are required.")); + var result = await _userService.AuthenticateUserAsync(request.Username, request.Password); + if (!result.IsSuccess) + return MapUserError(result); - var normalizedUsername = request.Username.ToLowerInvariant().Trim(); - var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); - - if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) - return Unauthorized(new ErrorResponse("Invalid username or password.")); - - if (user.IsBanned) - return Unauthorized(new ErrorResponse("Your account has been banned.")); - - user.LastSeenAt = DateTimeOffset.UtcNow; - await _db.SaveChangesAsync(); - - var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user); + var profile = result.User!; + var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile); var refreshToken = JwtTokenService.GenerateRefreshToken(); _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), TokenHash = JwtTokenService.HashToken(refreshToken), - UserId = user.Id, + UserId = profile.Id, ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), }); await _db.SaveChangesAsync(); - return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); + return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, profile.Username, profile.DisplayName, profile.NicknameColor)); } [HttpPost("refresh")] @@ -159,4 +124,14 @@ public class AuthController : ControllerBase return Ok(); } + + private IActionResult MapUserError(UserOperationResult result) => result.Error switch + { + UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)), + UserError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)), + UserError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)), + UserError.InvalidCredentials => Unauthorized(new ErrorResponse(result.ErrorMessage!)), + UserError.Banned => Unauthorized(new ErrorResponse(result.ErrorMessage!)), + _ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")), + }; } diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 9c0d4c8..8621454 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -1,12 +1,11 @@ using System.Security.Claims; using EchoHub.Core.Constants; +using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; -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; @@ -16,25 +15,24 @@ namespace EchoHub.Server.Controllers; [EnableRateLimiting("general")] public class UsersController : ControllerBase { - private readonly EchoHubDbContext _db; + private readonly IUserService _userService; private readonly ImageToAsciiService _asciiService; - public UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) + public UsersController(IUserService userService, ImageToAsciiService asciiService) { - _db = db; + _userService = userService; _asciiService = asciiService; } [HttpGet("{username}/profile")] public async Task GetProfile(string username) { - var normalizedUsername = username.ToLowerInvariant().Trim(); - var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + var profile = await _userService.GetUserProfileAsync(username); - if (user is null) + if (profile is null) return NotFound(new ErrorResponse("User not found.")); - return Ok(ToProfileDto(user)); + return Ok(profile); } [HttpPut("profile")] @@ -44,37 +42,13 @@ public class UsersController : ControllerBase if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var userId = Guid.Parse(userIdClaim); - var user = await _db.Users.FindAsync(userId); + var result = await _userService.UpdateProfileAsync( + Guid.Parse(userIdClaim), request.DisplayName, request.Bio, request.NicknameColor); - if (user is null) - return NotFound(new ErrorResponse("User not found.")); + if (!result.IsSuccess) + return MapUserError(result); - if (request.DisplayName is not null) - { - if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength) - return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters.")); - user.DisplayName = request.DisplayName.Trim(); - } - - if (request.Bio is not null) - { - if (request.Bio.Length > ValidationConstants.MaxBioLength) - return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters.")); - user.Bio = request.Bio.Trim(); - } - - if (request.NicknameColor is not null) - { - var color = request.NicknameColor.Trim(); - if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color)) - return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500).")); - user.NicknameColor = color.Length > 0 ? color : null; - } - - await _db.SaveChangesAsync(); - - return Ok(ToProfileDto(user)); + return Ok(result.User!); } [HttpPost("avatar")] @@ -85,12 +59,6 @@ public class UsersController : ControllerBase if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var userId = Guid.Parse(userIdClaim); - var user = await _db.Users.FindAsync(userId); - - if (user is null) - return NotFound(new ErrorResponse("User not found.")); - if (!Request.HasFormContentType || Request.Form.Files.Count == 0) return BadRequest(new ErrorResponse("No file uploaded.")); @@ -106,22 +74,20 @@ public class UsersController : ControllerBase var asciiArt = _asciiService.ConvertToAscii(stream); - user.AvatarAscii = asciiArt; - await _db.SaveChangesAsync(); + var result = await _userService.SetAvatarAsync(Guid.Parse(userIdClaim), asciiArt); + if (!result.IsSuccess) + return MapUserError(result); return Ok(new AvatarUploadResponse(asciiArt)); } - private static UserProfileDto ToProfileDto(Core.Models.User user) => new( - user.Id, - user.Username, - user.DisplayName, - user.Bio, - user.NicknameColor, - user.AvatarAscii, - user.Status, - user.StatusMessage, - user.Role, - user.CreatedAt, - user.LastSeenAt); + private IActionResult MapUserError(UserOperationResult result) => result.Error switch + { + UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)), + UserError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)), + UserError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)), + UserError.InvalidCredentials => Unauthorized(new ErrorResponse(result.ErrorMessage!)), + UserError.Banned => Unauthorized(new ErrorResponse(result.ErrorMessage!)), + _ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")), + }; } diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 2ab4dcc..f1ac5fb 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -115,6 +115,7 @@ while (true) // ── Chat Service + Broadcasters ───────────────────────────────────── builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 63954c1..0f41271 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -304,73 +304,9 @@ public class ChatService : IChatService } } - 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.Role, user.CreatedAt, user.LastSeenAt); - } - 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); - } - - public async Task<(Guid UserId, string Username)?> RegisterUserAsync(string username, string password) - { - username = username.ToLowerInvariant().Trim(); - - if (!ValidationConstants.UsernameRegex().IsMatch(username)) - return null; - - if (password.Length < 6 || password.Length > ValidationConstants.MaxPasswordLength) - return null; - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - if (await db.Users.AnyAsync(u => u.Username == username)) - return null; - - var isFirstUser = !await db.Users.AnyAsync(); - - var user = new User - { - Id = Guid.NewGuid(), - Username = username, - PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), - Role = isFirstUser ? ServerRole.Owner : ServerRole.Member, - }; - - db.Users.Add(user); - await db.SaveChangesAsync(); - - return (user.Id, user.Username); - } - /// /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// diff --git a/src/EchoHub.Server/Services/UserService.cs b/src/EchoHub.Server/Services/UserService.cs new file mode 100644 index 0000000..2ab8eab --- /dev/null +++ b/src/EchoHub.Server/Services/UserService.cs @@ -0,0 +1,172 @@ +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; + +namespace EchoHub.Server.Services; + +public class UserService : IUserService +{ + private readonly IServiceScopeFactory _scopeFactory; + + public UserService(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory; + } + + public async Task RegisterUserAsync(string username, string password, string? displayName = null) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required."); + + if (!ValidationConstants.UsernameRegex().IsMatch(username)) + return UserOperationResult.Fail(UserError.ValidationFailed, + "Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."); + + if (password.Length < 6) + return UserOperationResult.Fail(UserError.ValidationFailed, "Password must be at least 6 characters."); + + if (password.Length > ValidationConstants.MaxPasswordLength) + return UserOperationResult.Fail(UserError.ValidationFailed, + $"Password must not exceed {ValidationConstants.MaxPasswordLength} characters."); + + var normalizedUsername = username.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (await db.Users.AnyAsync(u => u.Username == normalizedUsername)) + return UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken."); + + var isFirstUser = !await db.Users.AnyAsync(); + + var user = new User + { + Id = Guid.NewGuid(), + Username = normalizedUsername, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), + DisplayName = displayName?.Trim(), + Role = isFirstUser ? ServerRole.Owner : ServerRole.Member, + }; + + db.Users.Add(user); + await db.SaveChangesAsync(); + + return UserOperationResult.Success(ToProfileDto(user)); + } + + public async Task AuthenticateUserAsync(string username, string password) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required."); + + var normalizedUsername = username.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + + if (user is null || !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) + return UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password."); + + if (user.IsBanned) + return UserOperationResult.Fail(UserError.Banned, "Your account has been banned."); + + user.LastSeenAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + return UserOperationResult.Success(ToProfileDto(user)); + } + + public async Task GetUserProfileAsync(string username) + { + var normalizedUsername = username.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + return user is null ? null : ToProfileDto(user); + } + + public async Task GetUserByIdAsync(Guid userId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FindAsync(userId); + return user is null ? null : ToProfileDto(user); + } + + public async Task UpdateProfileAsync( + Guid userId, string? displayName, string? bio, string? nicknameColor) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FindAsync(userId); + if (user is null) + return UserOperationResult.Fail(UserError.NotFound, "User not found."); + + if (displayName is not null) + { + if (displayName.Length > ValidationConstants.MaxDisplayNameLength) + return UserOperationResult.Fail(UserError.ValidationFailed, + $"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."); + user.DisplayName = displayName.Trim(); + } + + if (bio is not null) + { + if (bio.Length > ValidationConstants.MaxBioLength) + return UserOperationResult.Fail(UserError.ValidationFailed, + $"Bio must not exceed {ValidationConstants.MaxBioLength} characters."); + user.Bio = bio.Trim(); + } + + if (nicknameColor is not null) + { + var color = nicknameColor.Trim(); + if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color)) + return UserOperationResult.Fail(UserError.ValidationFailed, + "Nickname color must be a valid hex color (e.g. #FF5500)."); + user.NicknameColor = color.Length > 0 ? color : null; + } + + await db.SaveChangesAsync(); + + return UserOperationResult.Success(ToProfileDto(user)); + } + + public async Task SetAvatarAsync(Guid userId, string asciiArt) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var user = await db.Users.FindAsync(userId); + if (user is null) + return UserOperationResult.Fail(UserError.NotFound, "User not found."); + + user.AvatarAscii = asciiArt; + await db.SaveChangesAsync(); + + return UserOperationResult.Success(ToProfileDto(user)); + } + + private static UserProfileDto ToProfileDto(User user) => new( + user.Id, + user.Username, + user.DisplayName, + user.Bio, + user.NicknameColor, + user.AvatarAscii, + user.Status, + user.StatusMessage, + user.Role, + user.CreatedAt, + user.LastSeenAt); +} From 6e76065dcb1bfaaeebeb590cba4c8a187786363b Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 14:53:58 +0100 Subject: [PATCH 3/4] fix: correct markdown table formatting and specify code block type in Docker documentation --- docs/articles/docker.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/articles/docker.md b/docs/articles/docker.md index 7eaaa19..dd60837 100644 --- a/docs/articles/docker.md +++ b/docs/articles/docker.md @@ -27,7 +27,7 @@ services: All settings are configured through the `.env` file. These are ASP.NET Core environment variables that override `appsettings.json`. | Variable | Default | Description | -|---|---|---| +| --- | --- | --- | | `Server__Name` | My EchoHub Server | Display name for your server | | `Server__Description` | A self-hosted EchoHub chat server | Server description | | `Server__PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) | @@ -50,7 +50,7 @@ All settings are configured through the `.env` file. These are ASP.NET Core envi All server state lives in a single Docker volume mounted at `/app/data`: -``` +```text /app/data/ ├── appsettings.json # generated config with JWT/encryption keys ├── echohub.db # SQLite database From 94b31b6056469b7fc4add6ff2eab58e6433a09a0 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 15:52:18 +0100 Subject: [PATCH 4/4] refactor: replace FakeChatService with FakeUserService for user authentication and profile handling in tests --- .../Irc/IrcCommandHandlerTests.cs | 20 ++++---- src/EchoHub.Tests/Irc/TestHelpers.cs | 48 +++++++++++++++---- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs index 9aed298..35c45f8 100644 --- a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -12,11 +12,12 @@ public class IrcCommandHandlerTests { private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null }; private readonly FakeChatService _chatService = new(); + private readonly FakeUserService _userService = new(); private readonly FakeChannelService _channelService = new(); private readonly FakeEncryptionService _encryption = new(); private IrcCommandHandler CreateHandler(IrcClientConnection conn) => - new(conn, _options, _chatService, _channelService, _encryption, NullLogger.Instance); + new(conn, _options, _chatService, _userService, _channelService, _encryption, NullLogger.Instance); private async Task> RunAndCapture(string[] inputLines, Action? setup = null) @@ -85,7 +86,7 @@ public class IrcCommandHandlerTests public async Task PassNickUser_ValidCredentials_Registers() { var userId = Guid.NewGuid(); - _chatService.AuthResult = (userId, "alice"); + _userService.AuthResult = FakeUserService.SuccessResult(userId, "alice"); var lines = await RunAndCapture([ "PASS secret123", @@ -112,7 +113,7 @@ public class IrcCommandHandlerTests [Fact] public async Task PassNickUser_WrongPassword_GetsAuthError() { - _chatService.AuthResult = null; + _userService.AuthResult = null; var lines = await RunAndCapture([ "PASS wrongpassword", @@ -120,7 +121,8 @@ public class IrcCommandHandlerTests "USER alice 0 * :Alice Smith" ]); - Assert.Contains(lines, l => l.Contains("464") && l.Contains("incorrect")); + Assert.Contains(lines, l => l.Contains("464")); + Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Authentication failed")); } [Fact] @@ -189,7 +191,7 @@ public class IrcCommandHandlerTests public async Task SaslPlain_ValidCredentials_Authenticates() { var userId = Guid.NewGuid(); - _chatService.AuthResult = (userId, "alice"); + _userService.AuthResult = FakeUserService.SuccessResult(userId, "alice"); var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123")); @@ -210,7 +212,7 @@ public class IrcCommandHandlerTests [Fact] public async Task SaslPlain_InvalidCredentials_GetsError() { - _chatService.AuthResult = null; + _userService.AuthResult = null; var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd")); @@ -478,7 +480,7 @@ public class IrcCommandHandlerTests [Fact] public async Task Whois_ExistingUser_ReturnsInfo() { - _chatService.ProfileToReturn = new UserProfileDto( + _userService.ProfileToReturn = new UserProfileDto( Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null, UserStatus.Online, null, ServerRole.Member, DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow); @@ -496,7 +498,7 @@ public class IrcCommandHandlerTests [Fact] public async Task Whois_NonexistentUser_GetsNoSuchNickError() { - _chatService.ProfileToReturn = null; + _userService.ProfileToReturn = null; var lines = await RunAuthenticated(["WHOIS ghost"]); @@ -506,7 +508,7 @@ public class IrcCommandHandlerTests [Fact] public async Task Whois_AwayUser_ShowsAwayMessage() { - _chatService.ProfileToReturn = new UserProfileDto( + _userService.ProfileToReturn = new UserProfileDto( Guid.NewGuid(), "bob", null, null, null, null, UserStatus.Away, "Gone fishing", ServerRole.Member, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow); diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 86001d2..358dec2 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -156,8 +156,6 @@ internal sealed class FakeChatService : IChatService public List HistoryToReturn { get; set; } = []; public string? JoinError { get; set; } public string? SendMessageError { get; set; } - public (Guid UserId, string Username)? AuthResult { get; set; } - public UserProfileDto? ProfileToReturn { get; set; } public List ChannelsForUserToReturn { get; set; } = []; public List OnlineUsersToReturn { get; set; } = []; @@ -210,14 +208,8 @@ internal sealed class FakeChatService : IChatService public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) => Task.CompletedTask; - public Task GetUserProfileAsync(string username) => - Task.FromResult(ProfileToReturn); - public Task> GetChannelsForUserAsync(string username) => Task.FromResult(ChannelsForUserToReturn); - - public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) => - Task.FromResult(AuthResult); } /// @@ -258,3 +250,43 @@ internal sealed class FakeChannelService : IChannelService public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) => Task.FromResult(MembershipResult); } + +/// +/// Fake user service that records method calls and returns pre-configured results. +/// +internal sealed class FakeUserService : IUserService +{ + // Configurable results + public UserOperationResult? AuthResult { get; set; } + public UserOperationResult? RegisterResult { get; set; } + public UserProfileDto? ProfileToReturn { get; set; } + + /// + /// Helper to create a success result from a simple userId + username pair. + /// + public static UserOperationResult SuccessResult(Guid userId, string username) => + UserOperationResult.Success(new UserProfileDto( + userId, username, null, null, null, null, + UserStatus.Online, null, ServerRole.Member, + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + + public Task AuthenticateUserAsync(string username, string password) => + Task.FromResult(AuthResult + ?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password.")); + + public Task RegisterUserAsync(string username, string password, string? displayName = null) => + Task.FromResult(RegisterResult + ?? UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken.")); + + public Task GetUserProfileAsync(string username) => + Task.FromResult(ProfileToReturn); + + public Task GetUserByIdAsync(Guid userId) => + Task.FromResult(ProfileToReturn); + + public Task UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor) => + Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured")); + + public Task SetAvatarAsync(Guid userId, string asciiArt) => + Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured")); +}