mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-07 23:34:17 +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`.
|
All settings are configured through the `.env` file. These are ASP.NET Core environment variables that override `appsettings.json`.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| `Server__Name` | My EchoHub Server | Display name for your server |
|
| `Server__Name` | My EchoHub Server | Display name for your server |
|
||||||
| `Server__Description` | A self-hosted EchoHub chat server | Server description |
|
| `Server__Description` | A self-hosted EchoHub chat server | Server description |
|
||||||
| `Server__PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
|
| `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`:
|
All server state lives in a single Docker volume mounted at `/app/data`:
|
||||||
|
|
||||||
```
|
```text
|
||||||
/app/data/
|
/app/data/
|
||||||
├── appsettings.json # generated config with JWT/encryption keys
|
├── appsettings.json # generated config with JWT/encryption keys
|
||||||
├── echohub.db # SQLite database
|
├── echohub.db # SQLite database
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## 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.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme
|
||||||
- [v0.2.4](v0.2.4.md) - E2E Message Encryption
|
- [v0.2.4](v0.2.4.md) - E2E Message Encryption
|
||||||
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
|
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.8
|
||||||
|
href: v0.2.8.md
|
||||||
- name: v0.2.7
|
- name: v0.2.7
|
||||||
href: v0.2.7.md
|
href: v0.2.7.md
|
||||||
- name: v0.2.6
|
- 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
|
- 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
|
## Infrastructure
|
||||||
|
|
||||||
- Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream
|
- 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>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.7</Version>
|
<Version>0.2.8</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
|
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
|
||||||
{
|
{
|
||||||
var request = new RegisterRequest(username, password, displayName);
|
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);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
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)
|
public async Task<LoginResponse> LoginAsync(string username, string password)
|
||||||
{
|
{
|
||||||
var request = new LoginRequest(username, 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);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -61,7 +61,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
throw new InvalidOperationException("No refresh token available.");
|
throw new InvalidOperationException("No refresh token available.");
|
||||||
|
|
||||||
var request = new RefreshRequest(_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);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -73,7 +73,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
|
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
|
||||||
{
|
{
|
||||||
var request = new RefreshRequest(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);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -90,7 +90,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = new RefreshRequest(_refreshToken);
|
var request = new RefreshRequest(_refreshToken);
|
||||||
await _http.PostAsJsonAsync("/api/auth/logout", request);
|
using var response = await _http.PostAsJsonAsync("/api/auth/logout", request);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -131,7 +131,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<List<ChannelDto>> GetChannelsAsync()
|
public async Task<List<ChannelDto>> GetChannelsAsync()
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedGetAsync("/api/channels");
|
using var response = await AuthenticatedGetAsync("/api/channels");
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
|
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
|
||||||
return paginated?.Items ?? [];
|
return paginated?.Items ?? [];
|
||||||
@@ -146,7 +146,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<string> GetEncryptionKeyAsync()
|
public async Task<string> GetEncryptionKeyAsync()
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
|
using var response = await AuthenticatedGetAsync("/api/server/encryption-key");
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
|
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
|
||||||
?? throw new InvalidOperationException("Server returned empty encryption key response.");
|
?? 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)
|
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
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);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PutAsJsonAsync("/api/users/profile", request));
|
_http.PutAsJsonAsync("/api/users/profile", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
@@ -178,7 +178,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||||
content.Add(streamContent, "file", fileName);
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsync("/api/users/avatar", content));
|
_http.PostAsync("/api/users/avatar", content));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
||||||
@@ -194,7 +194,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
content.Add(streamContent, "file", fileName);
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
var sizeQuery = size is not null ? $"?size={size}" : "";
|
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));
|
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
@@ -205,7 +205,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new SendUrlRequest(url);
|
var request = new SendUrlRequest(url);
|
||||||
var sizeQuery = size is not null ? $"?size={size}" : "";
|
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));
|
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
@@ -214,7 +214,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
|
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedGetAsync(relativeUrl);
|
using var response = await AuthenticatedGetAsync(relativeUrl);
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
|
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
|
||||||
@@ -232,7 +232,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new CreateChannelRequest(name, topic, isPublic);
|
var request = new CreateChannelRequest(name, topic, isPublic);
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync("/api/channels", request));
|
_http.PostAsJsonAsync("/api/channels", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
@@ -242,7 +242,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new UpdateTopicRequest(topic);
|
var request = new UpdateTopicRequest(topic);
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
|
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
@@ -251,7 +251,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task DeleteChannelAsync(string channelName)
|
public async Task DeleteChannelAsync(string channelName)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
|
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task AssignRoleAsync(string username, ServerRole role)
|
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
|
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task KickUserAsync(string username, string? reason = null)
|
public async Task KickUserAsync(string username, string? reason = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
|
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -277,7 +277,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task BanUserAsync(string username, string? reason = null)
|
public async Task BanUserAsync(string username, string? reason = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
|
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -285,7 +285,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task UnbanUserAsync(string username)
|
public async Task UnbanUserAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
|
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
|
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
|
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -301,7 +301,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task UnmuteUserAsync(string username)
|
public async Task UnmuteUserAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
|
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task DeleteMessageAsync(Guid messageId)
|
public async Task DeleteMessageAsync(Guid messageId)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
|
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task NukeChannelAsync(string channelName)
|
public async Task NukeChannelAsync(string channelName)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
|
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -333,6 +333,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs a GET request with automatic token refresh on 401.
|
/// Performs a GET request with automatic token refresh on 401.
|
||||||
|
/// Caller is responsible for disposing the returned response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
|
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
|
||||||
{
|
{
|
||||||
@@ -343,7 +344,9 @@ public sealed class ApiClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RefreshTokenAsync();
|
await RefreshTokenAsync();
|
||||||
response = await _http.GetAsync(url);
|
var retryResponse = await _http.GetAsync(url);
|
||||||
|
response.Dispose();
|
||||||
|
response = retryResponse;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -356,6 +359,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs a request with automatic token refresh on 401.
|
/// Performs a request with automatic token refresh on 401.
|
||||||
|
/// Caller is responsible for disposing the returned response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
|
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
|
||||||
{
|
{
|
||||||
@@ -366,7 +370,9 @@ public sealed class ApiClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RefreshTokenAsync();
|
await RefreshTokenAsync();
|
||||||
response = await requestFactory();
|
var retryResponse = await requestFactory();
|
||||||
|
response.Dispose();
|
||||||
|
response = retryResponse;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,77 +60,83 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
_apiClient?.Dispose();
|
_apiClient?.Dispose();
|
||||||
_apiClient = new ApiClient(info.ServerUrl);
|
_apiClient = new ApiClient(info.ServerUrl);
|
||||||
|
|
||||||
onStatus("Authenticating...");
|
try
|
||||||
|
|
||||||
LoginResponse loginResponse;
|
|
||||||
|
|
||||||
if (info.SavedRefreshToken is not null)
|
|
||||||
{
|
{
|
||||||
try
|
onStatus("Authenticating...");
|
||||||
|
|
||||||
|
LoginResponse loginResponse;
|
||||||
|
|
||||||
|
if (info.SavedRefreshToken is not null)
|
||||||
{
|
{
|
||||||
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
|
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
|
||||||
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
|
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
|
catch
|
||||||
{
|
{
|
||||||
_apiClient.Dispose();
|
// History might not be available
|
||||||
_apiClient = null;
|
|
||||||
throw; // Caller handles saved-session expiry
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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
|
return new ConnectResult(loginResponse, channels, history);
|
||||||
_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
|
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 ───────────────────────────────────────────────────────────
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ public interface IChatService
|
|||||||
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
||||||
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
|
|
||||||
// Query operations (used by IRC gateway for WHOIS, AUTH)
|
// Query operations (used by IRC gateway for WHOIS)
|
||||||
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
|
||||||
Task<List<string>> GetChannelsForUserAsync(string username);
|
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 Success(ChannelDto channel) => new(channel, null, null);
|
||||||
public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message);
|
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 IrcClientConnection _conn;
|
||||||
private readonly IrcOptions _options;
|
private readonly IrcOptions _options;
|
||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
|
private readonly IUserService _userService;
|
||||||
private readonly IChannelService _channelService;
|
private readonly IChannelService _channelService;
|
||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
@@ -22,6 +23,7 @@ public sealed class IrcCommandHandler
|
|||||||
IrcClientConnection conn,
|
IrcClientConnection conn,
|
||||||
IrcOptions options,
|
IrcOptions options,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
|
IUserService userService,
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
IMessageEncryptionService encryption,
|
IMessageEncryptionService encryption,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
@@ -29,6 +31,7 @@ public sealed class IrcCommandHandler
|
|||||||
_conn = conn;
|
_conn = conn;
|
||||||
_options = options;
|
_options = options;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
|
_userService = userService;
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -168,19 +171,23 @@ public sealed class IrcCommandHandler
|
|||||||
_logger.LogDebug("SASL PLAIN auth attempt for user '{Username}' (connection {Id})",
|
_logger.LogDebug("SASL PLAIN auth attempt for user '{Username}' (connection {Id})",
|
||||||
username, _conn.ConnectionId);
|
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})",
|
_logger.LogWarning("SASL auth/register failed for user '{Username}': {Error} (connection {Id})",
|
||||||
username, _conn.ConnectionId);
|
username, result.ErrorMessage, _conn.ConnectionId);
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
|
||||||
":SASL authentication failed");
|
$":SASL authentication failed — {result.ErrorMessage}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_conn.Nickname = result.Value.Username;
|
_conn.Nickname = result.User!.Username;
|
||||||
_conn.UserId = result.Value.UserId;
|
_conn.UserId = result.User!.Id;
|
||||||
_conn.IsAuthenticated = true;
|
_conn.IsAuthenticated = true;
|
||||||
|
|
||||||
_logger.LogInformation("SASL auth succeeded for user '{Username}' (connection {Id})",
|
_logger.LogInformation("SASL auth succeeded for user '{Username}' (connection {Id})",
|
||||||
@@ -284,22 +291,26 @@ public sealed class IrcCommandHandler
|
|||||||
return;
|
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,
|
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");
|
await _conn.SendAsync("ERROR :Authentication failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_conn.UserId = result.Value.UserId;
|
_conn.UserId = result.User!.Id;
|
||||||
_conn.Nickname = result.Value.Username;
|
_conn.Nickname = result.User!.Username;
|
||||||
_conn.IsAuthenticated = true;
|
_conn.IsAuthenticated = true;
|
||||||
_conn.IsRegistered = 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();
|
await SendWelcomeBurstAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +562,7 @@ public sealed class IrcCommandHandler
|
|||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
|
|
||||||
var nick = msg.Parameters[^1].ToLowerInvariant();
|
var nick = msg.Parameters[^1].ToLowerInvariant();
|
||||||
var profile = await _chatService.GetUserProfileAsync(nick);
|
var profile = await _userService.GetUserProfileAsync(nick);
|
||||||
|
|
||||||
if (profile is null)
|
if (profile is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -120,10 +120,11 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
chatService = _services.GetRequiredService<IChatService>();
|
chatService = _services.GetRequiredService<IChatService>();
|
||||||
|
var userService = _services.GetRequiredService<IUserService>();
|
||||||
var channelService = _services.GetRequiredService<IChannelService>();
|
var channelService = _services.GetRequiredService<IChannelService>();
|
||||||
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
|
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
|
||||||
var handler = new IrcCommandHandler(
|
var handler = new IrcCommandHandler(
|
||||||
connection, _options, chatService, channelService, encryption, _logger);
|
connection, _options, chatService, userService, channelService, encryption, _logger);
|
||||||
|
|
||||||
await handler.RunAsync(ct);
|
await handler.RunAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
@@ -51,6 +52,31 @@ public class JwtTokenService
|
|||||||
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
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()
|
public static string GenerateRefreshToken()
|
||||||
{
|
{
|
||||||
var randomBytes = new byte[64];
|
var randomBytes = new byte[64];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Auth;
|
using EchoHub.Server.Auth;
|
||||||
@@ -16,94 +16,59 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly EchoHubDbContext _db;
|
private readonly EchoHubDbContext _db;
|
||||||
private readonly JwtTokenService _jwt;
|
private readonly JwtTokenService _jwt;
|
||||||
|
private readonly IUserService _userService;
|
||||||
|
|
||||||
public AuthController(EchoHubDbContext db, JwtTokenService jwt)
|
public AuthController(EchoHubDbContext db, JwtTokenService jwt, IUserService userService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_jwt = jwt;
|
_jwt = jwt;
|
||||||
|
_userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName);
|
||||||
return BadRequest(new ErrorResponse("Username and password are required."));
|
if (!result.IsSuccess)
|
||||||
|
return MapUserError(result);
|
||||||
|
|
||||||
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
|
var profile = result.User!;
|
||||||
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
|
||||||
|
|
||||||
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 refreshToken = JwtTokenService.GenerateRefreshToken();
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
_db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
UserId = user.Id,
|
UserId = profile.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await _db.SaveChangesAsync();
|
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")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
var result = await _userService.AuthenticateUserAsync(request.Username, request.Password);
|
||||||
return BadRequest(new ErrorResponse("Username and password are required."));
|
if (!result.IsSuccess)
|
||||||
|
return MapUserError(result);
|
||||||
|
|
||||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
var profile = result.User!;
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
|
||||||
|
|
||||||
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 refreshToken = JwtTokenService.GenerateRefreshToken();
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
_db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
UserId = user.Id,
|
UserId = profile.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await _db.SaveChangesAsync();
|
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")]
|
[HttpPost("refresh")]
|
||||||
@@ -159,4 +124,14 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
return Ok();
|
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 System.Security.Claims;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Server.Data;
|
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
@@ -16,25 +15,24 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class UsersController : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly EchoHubDbContext _db;
|
private readonly IUserService _userService;
|
||||||
private readonly ImageToAsciiService _asciiService;
|
private readonly ImageToAsciiService _asciiService;
|
||||||
|
|
||||||
public UsersController(EchoHubDbContext db, ImageToAsciiService asciiService)
|
public UsersController(IUserService userService, ImageToAsciiService asciiService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_userService = userService;
|
||||||
_asciiService = asciiService;
|
_asciiService = asciiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
public async Task<IActionResult> GetProfile(string username)
|
public async Task<IActionResult> GetProfile(string username)
|
||||||
{
|
{
|
||||||
var normalizedUsername = username.ToLowerInvariant().Trim();
|
var profile = await _userService.GetUserProfileAsync(username);
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
|
||||||
|
|
||||||
if (user is null)
|
if (profile is null)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
|
|
||||||
return Ok(ToProfileDto(user));
|
return Ok(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("profile")]
|
[HttpPut("profile")]
|
||||||
@@ -44,37 +42,13 @@ public class UsersController : ControllerBase
|
|||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
var result = await _userService.UpdateProfileAsync(
|
||||||
var user = await _db.Users.FindAsync(userId);
|
Guid.Parse(userIdClaim), request.DisplayName, request.Bio, request.NicknameColor);
|
||||||
|
|
||||||
if (user is null)
|
if (!result.IsSuccess)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return MapUserError(result);
|
||||||
|
|
||||||
if (request.DisplayName is not null)
|
return Ok(result.User!);
|
||||||
{
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("avatar")]
|
[HttpPost("avatar")]
|
||||||
@@ -85,12 +59,6 @@ public class UsersController : ControllerBase
|
|||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
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)
|
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||||
return BadRequest(new ErrorResponse("No file uploaded."));
|
return BadRequest(new ErrorResponse("No file uploaded."));
|
||||||
|
|
||||||
@@ -106,22 +74,20 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
var asciiArt = _asciiService.ConvertToAscii(stream);
|
var asciiArt = _asciiService.ConvertToAscii(stream);
|
||||||
|
|
||||||
user.AvatarAscii = asciiArt;
|
var result = await _userService.SetAvatarAsync(Guid.Parse(userIdClaim), asciiArt);
|
||||||
await _db.SaveChangesAsync();
|
if (!result.IsSuccess)
|
||||||
|
return MapUserError(result);
|
||||||
|
|
||||||
return Ok(new AvatarUploadResponse(asciiArt));
|
return Ok(new AvatarUploadResponse(asciiArt));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UserProfileDto ToProfileDto(Core.Models.User user) => new(
|
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
|
||||||
user.Id,
|
{
|
||||||
user.Username,
|
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.DisplayName,
|
UserError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.Bio,
|
UserError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.NicknameColor,
|
UserError.InvalidCredentials => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.AvatarAscii,
|
UserError.Banned => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.Status,
|
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
|
||||||
user.StatusMessage,
|
};
|
||||||
user.Role,
|
|
||||||
user.CreatedAt,
|
|
||||||
user.LastSeenAt);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ while (true)
|
|||||||
|
|
||||||
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
||||||
|
builder.Services.AddSingleton<IUserService, UserService>();
|
||||||
builder.Services.AddSingleton<IChannelService, ChannelService>();
|
builder.Services.AddSingleton<IChannelService, ChannelService>();
|
||||||
builder.Services.AddSingleton<IChatService, ChatService>();
|
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)
|
public Task<List<string>> GetChannelsForUserAsync(string username)
|
||||||
=> Task.FromResult(_presenceTracker.GetChannelsForUser(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>
|
/// <summary>
|
||||||
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
|
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
|
||||||
/// </summary>
|
/// </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 IrcOptions _options = new() { ServerName = "testserver", Motd = null };
|
||||||
private readonly FakeChatService _chatService = new();
|
private readonly FakeChatService _chatService = new();
|
||||||
|
private readonly FakeUserService _userService = new();
|
||||||
private readonly FakeChannelService _channelService = new();
|
private readonly FakeChannelService _channelService = new();
|
||||||
private readonly FakeEncryptionService _encryption = new();
|
private readonly FakeEncryptionService _encryption = new();
|
||||||
|
|
||||||
private IrcCommandHandler CreateHandler(IrcClientConnection conn) =>
|
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,
|
private async Task<List<string>> RunAndCapture(string[] inputLines,
|
||||||
Action<IrcClientConnection>? setup = null)
|
Action<IrcClientConnection>? setup = null)
|
||||||
@@ -85,7 +86,7 @@ public class IrcCommandHandlerTests
|
|||||||
public async Task PassNickUser_ValidCredentials_Registers()
|
public async Task PassNickUser_ValidCredentials_Registers()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
_chatService.AuthResult = (userId, "alice");
|
_userService.AuthResult = FakeUserService.SuccessResult(userId, "alice");
|
||||||
|
|
||||||
var lines = await RunAndCapture([
|
var lines = await RunAndCapture([
|
||||||
"PASS secret123",
|
"PASS secret123",
|
||||||
@@ -112,7 +113,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PassNickUser_WrongPassword_GetsAuthError()
|
public async Task PassNickUser_WrongPassword_GetsAuthError()
|
||||||
{
|
{
|
||||||
_chatService.AuthResult = null;
|
_userService.AuthResult = null;
|
||||||
|
|
||||||
var lines = await RunAndCapture([
|
var lines = await RunAndCapture([
|
||||||
"PASS wrongpassword",
|
"PASS wrongpassword",
|
||||||
@@ -120,7 +121,8 @@ public class IrcCommandHandlerTests
|
|||||||
"USER alice 0 * :Alice Smith"
|
"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]
|
[Fact]
|
||||||
@@ -189,7 +191,7 @@ public class IrcCommandHandlerTests
|
|||||||
public async Task SaslPlain_ValidCredentials_Authenticates()
|
public async Task SaslPlain_ValidCredentials_Authenticates()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
_chatService.AuthResult = (userId, "alice");
|
_userService.AuthResult = FakeUserService.SuccessResult(userId, "alice");
|
||||||
|
|
||||||
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123"));
|
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123"));
|
||||||
|
|
||||||
@@ -210,7 +212,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SaslPlain_InvalidCredentials_GetsError()
|
public async Task SaslPlain_InvalidCredentials_GetsError()
|
||||||
{
|
{
|
||||||
_chatService.AuthResult = null;
|
_userService.AuthResult = null;
|
||||||
|
|
||||||
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd"));
|
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd"));
|
||||||
|
|
||||||
@@ -478,7 +480,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Whois_ExistingUser_ReturnsInfo()
|
public async Task Whois_ExistingUser_ReturnsInfo()
|
||||||
{
|
{
|
||||||
_chatService.ProfileToReturn = new UserProfileDto(
|
_userService.ProfileToReturn = new UserProfileDto(
|
||||||
Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null,
|
Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null,
|
||||||
UserStatus.Online, null, ServerRole.Member,
|
UserStatus.Online, null, ServerRole.Member,
|
||||||
DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow);
|
||||||
@@ -496,7 +498,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Whois_NonexistentUser_GetsNoSuchNickError()
|
public async Task Whois_NonexistentUser_GetsNoSuchNickError()
|
||||||
{
|
{
|
||||||
_chatService.ProfileToReturn = null;
|
_userService.ProfileToReturn = null;
|
||||||
|
|
||||||
var lines = await RunAuthenticated(["WHOIS ghost"]);
|
var lines = await RunAuthenticated(["WHOIS ghost"]);
|
||||||
|
|
||||||
@@ -506,7 +508,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Whois_AwayUser_ShowsAwayMessage()
|
public async Task Whois_AwayUser_ShowsAwayMessage()
|
||||||
{
|
{
|
||||||
_chatService.ProfileToReturn = new UserProfileDto(
|
_userService.ProfileToReturn = new UserProfileDto(
|
||||||
Guid.NewGuid(), "bob", null, null, null, null,
|
Guid.NewGuid(), "bob", null, null, null, null,
|
||||||
UserStatus.Away, "Gone fishing", ServerRole.Member,
|
UserStatus.Away, "Gone fishing", ServerRole.Member,
|
||||||
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow);
|
||||||
|
|||||||
@@ -156,8 +156,6 @@ internal sealed class FakeChatService : IChatService
|
|||||||
public List<MessageDto> HistoryToReturn { get; set; } = [];
|
public List<MessageDto> HistoryToReturn { get; set; } = [];
|
||||||
public string? JoinError { get; set; }
|
public string? JoinError { get; set; }
|
||||||
public string? SendMessageError { 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<string> ChannelsForUserToReturn { get; set; } = [];
|
||||||
public List<UserPresenceDto> OnlineUsersToReturn { 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) =>
|
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
|
|
||||||
Task.FromResult(ProfileToReturn);
|
|
||||||
|
|
||||||
public Task<List<string>> GetChannelsForUserAsync(string username) =>
|
public Task<List<string>> GetChannelsForUserAsync(string username) =>
|
||||||
Task.FromResult(ChannelsForUserToReturn);
|
Task.FromResult(ChannelsForUserToReturn);
|
||||||
|
|
||||||
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
|
|
||||||
Task.FromResult(AuthResult);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -258,3 +250,43 @@ internal sealed class FakeChannelService : IChannelService
|
|||||||
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
|
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
|
||||||
Task.FromResult(MembershipResult);
|
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