Merge pull request #23 from HueByte/dev_user_irc_improvements

Dev user irc improvements
This commit is contained in:
Hue
2026-02-23 15:58:37 +01:00
committed by GitHub
21 changed files with 487 additions and 273 deletions
+2 -2
View File
@@ -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
+3
View File
@@ -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
+2
View File
@@ -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
-8
View File
@@ -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
+23
View File
@@ -0,0 +1,23 @@
# v0.2.8
## 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
## 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)
## 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
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.2.7</Version>
<Version>0.2.8</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
+32 -26
View File
@@ -32,7 +32,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> 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<LoginResponse>()
@@ -45,7 +45,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> 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<LoginResponse>()
@@ -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<LoginResponse>()
@@ -73,7 +73,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> 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<LoginResponse>()
@@ -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<List<ChannelDto>> GetChannelsAsync()
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync("/api/channels");
using var response = await AuthenticatedGetAsync("/api/channels");
await EnsureSuccessAsync(response);
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
return paginated?.Items ?? [];
@@ -146,7 +146,7 @@ public sealed class ApiClient : IDisposable
public async Task<string> 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<EncryptionKeyResponse>()
?? throw new InvalidOperationException("Server returned empty encryption key response.");
@@ -156,7 +156,7 @@ public sealed class ApiClient : IDisposable
public async Task<UserProfileDto?> 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<UserProfileDto>();
}
@@ -164,7 +164,7 @@ public sealed class ApiClient : IDisposable
public async Task<UserProfileDto?> 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<UserProfileDto>();
@@ -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<AvatarUploadResponse>();
@@ -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<MessageDto>();
@@ -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<MessageDto>();
@@ -214,7 +214,7 @@ public sealed class ApiClient : IDisposable
public async Task<string> 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<ChannelDto>();
@@ -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<ChannelDto>();
@@ -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
/// <summary>
/// Performs a GET request with automatic token refresh on 401.
/// Caller is responsible for disposing the returned response.
/// </summary>
private async Task<HttpResponseMessage> 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
/// <summary>
/// Performs a request with automatic token refresh on 401.
/// Caller is responsible for disposing the returned response.
/// </summary>
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> 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
{
@@ -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<MessageDto> 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<MessageDto> 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 ───────────────────────────────────────────────────────────
+1 -3
View File
@@ -25,8 +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<UserProfileDto?> GetUserProfileAsync(string username);
// Query operations (used by IRC gateway for WHOIS)
Task<List<string>> GetChannelsForUserAsync(string username);
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
}
@@ -0,0 +1,13 @@
using EchoHub.Core.DTOs;
namespace EchoHub.Core.Contracts;
public interface IUserService
{
Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null);
Task<UserOperationResult> AuthenticateUserAsync(string username, string password);
Task<UserProfileDto?> GetUserProfileAsync(string username);
Task<UserProfileDto?> GetUserByIdAsync(Guid userId);
Task<UserOperationResult> UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor);
Task<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt);
}
+17
View File
@@ -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);
}
+25 -14
View File
@@ -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,19 +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);
if (result is null)
// Auth failed — try registering a new account
if (!result.IsSuccess)
result = await _userService.RegisterUserAsync(username, password);
if (!result.IsSuccess)
{
_logger.LogWarning("SASL auth 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})",
@@ -284,22 +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);
if (result is null)
// Auth failed — try registering a new account
if (!result.IsSuccess)
result = await _userService.RegisterUserAsync(_conn.Nickname!, _conn.Password);
if (!result.IsSuccess)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
":Password incorrect or account not found. Register via the EchoHub client first.");
$":{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();
}
@@ -551,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)
{
+2 -1
View File
@@ -120,10 +120,11 @@ public sealed class IrcGatewayService : BackgroundService
try
{
chatService = _services.GetRequiredService<IChatService>();
var userService = _services.GetRequiredService<IUserService>();
var channelService = _services.GetRequiredService<IChannelService>();
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
var handler = new IrcCommandHandler(
connection, _options, chatService, channelService, encryption, _logger);
connection, _options, chatService, userService, channelService, encryption, _logger);
await handler.RunAsync(ct);
}
@@ -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];
@@ -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<IActionResult> 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<IActionResult> 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.")),
};
}
@@ -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<IActionResult> 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.")),
};
}
+1
View File
@@ -115,6 +115,7 @@ while (true)
// ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<IChannelService, ChannelService>();
builder.Services.AddSingleton<IChatService, ChatService>();
@@ -304,41 +304,9 @@ public class ChatService : IChatService
}
}
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
username = username.ToLowerInvariant();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<List<string>> 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<EchoHubDbContext>();
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);
}
/// <summary>
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
/// </summary>
+172
View File
@@ -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<UserOperationResult> 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<EchoHubDbContext>();
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<UserOperationResult> 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<EchoHubDbContext>();
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<UserProfileDto?> GetUserProfileAsync(string username)
{
var normalizedUsername = username.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
return user is null ? null : ToProfileDto(user);
}
public async Task<UserProfileDto?> GetUserByIdAsync(Guid userId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(userId);
return user is null ? null : ToProfileDto(user);
}
public async Task<UserOperationResult> UpdateProfileAsync(
Guid userId, string? displayName, string? bio, string? nicknameColor)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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);
}
@@ -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<List<string>> RunAndCapture(string[] inputLines,
Action<IrcClientConnection>? 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);
+40 -8
View File
@@ -156,8 +156,6 @@ internal sealed class FakeChatService : IChatService
public List<MessageDto> HistoryToReturn { get; set; } = [];
public string? JoinError { get; set; }
public string? SendMessageError { get; set; }
public (Guid UserId, string Username)? AuthResult { get; set; }
public UserProfileDto? ProfileToReturn { get; set; }
public List<string> ChannelsForUserToReturn { get; set; } = [];
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
@@ -210,14 +208,8 @@ internal sealed class FakeChatService : IChatService
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) =>
Task.CompletedTask;
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
public Task<List<string>> GetChannelsForUserAsync(string username) =>
Task.FromResult(ChannelsForUserToReturn);
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
Task.FromResult(AuthResult);
}
/// <summary>
@@ -258,3 +250,43 @@ internal sealed class FakeChannelService : IChannelService
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
Task.FromResult(MembershipResult);
}
/// <summary>
/// Fake user service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeUserService : IUserService
{
// Configurable results
public UserOperationResult? AuthResult { get; set; }
public UserOperationResult? RegisterResult { get; set; }
public UserProfileDto? ProfileToReturn { get; set; }
/// <summary>
/// Helper to create a success result from a simple userId + username pair.
/// </summary>
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<UserOperationResult> AuthenticateUserAsync(string username, string password) =>
Task.FromResult(AuthResult
?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password."));
public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null) =>
Task.FromResult(RegisterResult
?? UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken."));
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
public Task<UserProfileDto?> GetUserByIdAsync(Guid userId) =>
Task.FromResult(ProfileToReturn);
public Task<UserOperationResult> UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor) =>
Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured"));
public Task<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt) =>
Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured"));
}