mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Merge pull request #23 from HueByte/dev_user_irc_improvements
Dev user irc improvements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.7</Version>
|
||||
<Version>0.2.8</Version>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user