Refactor user management: Extract IUserService and UserService, consolidate user registration, authentication, and profile management. Fix memory leaks in ApiClient, enhance connection management, and improve error handling in AuthController and UsersController. Update IRC command handling to utilize IUserService for user operations.

This commit is contained in:
HueByte
2026-02-23 14:51:24 +01:00
parent 27a25b1b43
commit bdcff74ad5
14 changed files with 409 additions and 282 deletions
+32 -26
View File
@@ -32,7 +32,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
{
var request = new RegisterRequest(username, password, displayName);
var response = await _http.PostAsJsonAsync("/api/auth/register", request);
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -45,7 +45,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> LoginAsync(string username, string password)
{
var request = new LoginRequest(username, password);
var response = await _http.PostAsJsonAsync("/api/auth/login", request);
using var response = await _http.PostAsJsonAsync("/api/auth/login", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -61,7 +61,7 @@ public sealed class ApiClient : IDisposable
throw new InvalidOperationException("No refresh token available.");
var request = new RefreshRequest(_refreshToken);
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -73,7 +73,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
{
var request = new RefreshRequest(refreshToken);
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -90,7 +90,7 @@ public sealed class ApiClient : IDisposable
try
{
var request = new RefreshRequest(_refreshToken);
await _http.PostAsJsonAsync("/api/auth/logout", request);
using var response = await _http.PostAsJsonAsync("/api/auth/logout", request);
}
catch
{
@@ -131,7 +131,7 @@ public sealed class ApiClient : IDisposable
public async Task<List<ChannelDto>> GetChannelsAsync()
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync("/api/channels");
using var response = await AuthenticatedGetAsync("/api/channels");
await EnsureSuccessAsync(response);
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
return paginated?.Items ?? [];
@@ -146,7 +146,7 @@ public sealed class ApiClient : IDisposable
public async Task<string> GetEncryptionKeyAsync()
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
using var response = await AuthenticatedGetAsync("/api/server/encryption-key");
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
?? throw new InvalidOperationException("Server returned empty encryption key response.");
@@ -156,7 +156,7 @@ public sealed class ApiClient : IDisposable
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile");
using var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile");
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
}
@@ -164,7 +164,7 @@ public sealed class ApiClient : IDisposable
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PutAsJsonAsync("/api/users/profile", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
@@ -178,7 +178,7 @@ public sealed class ApiClient : IDisposable
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName);
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync("/api/users/avatar", content));
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
@@ -194,7 +194,7 @@ public sealed class ApiClient : IDisposable
content.Add(streamContent, "file", fileName);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>();
@@ -205,7 +205,7 @@ public sealed class ApiClient : IDisposable
EnsureAuthenticated();
var request = new SendUrlRequest(url);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>();
@@ -214,7 +214,7 @@ public sealed class ApiClient : IDisposable
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync(relativeUrl);
using var response = await AuthenticatedGetAsync(relativeUrl);
await EnsureSuccessAsync(response);
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
@@ -232,7 +232,7 @@ public sealed class ApiClient : IDisposable
{
EnsureAuthenticated();
var request = new CreateChannelRequest(name, topic, isPublic);
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/channels", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelDto>();
@@ -242,7 +242,7 @@ public sealed class ApiClient : IDisposable
{
EnsureAuthenticated();
var request = new UpdateTopicRequest(topic);
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelDto>();
@@ -251,7 +251,7 @@ public sealed class ApiClient : IDisposable
public async Task DeleteChannelAsync(string channelName)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
await EnsureSuccessAsync(response);
}
@@ -261,7 +261,7 @@ public sealed class ApiClient : IDisposable
public async Task AssignRoleAsync(string username, ServerRole role)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
await EnsureSuccessAsync(response);
}
@@ -269,7 +269,7 @@ public sealed class ApiClient : IDisposable
public async Task KickUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
await EnsureSuccessAsync(response);
}
@@ -277,7 +277,7 @@ public sealed class ApiClient : IDisposable
public async Task BanUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
await EnsureSuccessAsync(response);
}
@@ -285,7 +285,7 @@ public sealed class ApiClient : IDisposable
public async Task UnbanUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
@@ -293,7 +293,7 @@ public sealed class ApiClient : IDisposable
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
await EnsureSuccessAsync(response);
}
@@ -301,7 +301,7 @@ public sealed class ApiClient : IDisposable
public async Task UnmuteUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
@@ -309,7 +309,7 @@ public sealed class ApiClient : IDisposable
public async Task DeleteMessageAsync(Guid messageId)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
await EnsureSuccessAsync(response);
}
@@ -317,7 +317,7 @@ public sealed class ApiClient : IDisposable
public async Task NukeChannelAsync(string channelName)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
await EnsureSuccessAsync(response);
}
@@ -333,6 +333,7 @@ public sealed class ApiClient : IDisposable
/// <summary>
/// Performs a GET request with automatic token refresh on 401.
/// Caller is responsible for disposing the returned response.
/// </summary>
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
{
@@ -343,7 +344,9 @@ public sealed class ApiClient : IDisposable
try
{
await RefreshTokenAsync();
response = await _http.GetAsync(url);
var retryResponse = await _http.GetAsync(url);
response.Dispose();
response = retryResponse;
}
catch
{
@@ -356,6 +359,7 @@ public sealed class ApiClient : IDisposable
/// <summary>
/// Performs a request with automatic token refresh on 401.
/// Caller is responsible for disposing the returned response.
/// </summary>
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
{
@@ -366,7 +370,9 @@ public sealed class ApiClient : IDisposable
try
{
await RefreshTokenAsync();
response = await requestFactory();
var retryResponse = await requestFactory();
response.Dispose();
response = retryResponse;
}
catch
{
@@ -60,77 +60,83 @@ internal sealed class ConnectionManager : IAsyncDisposable
_apiClient?.Dispose();
_apiClient = new ApiClient(info.ServerUrl);
onStatus("Authenticating...");
LoginResponse loginResponse;
if (info.SavedRefreshToken is not null)
try
{
try
onStatus("Authenticating...");
LoginResponse loginResponse;
if (info.SavedRefreshToken is not null)
{
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
}
else if (info.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
}
else
{
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
}
// Auto-persist rotated refresh tokens for Remember Me
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
// E2E encryption key
onStatus("Fetching encryption key...");
try
{
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
_encryption.SetKey(encryptionKey);
Log.Information("E2E encryption key established");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
}
onStatus("Authenticated, connecting...");
if (_connection is not null)
await _connection.DisposeAsync();
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
WireConnectionEvents(_connection);
await _connection.ConnectAsync();
var channels = await _apiClient.GetChannelsAsync();
onStatus("Connected");
// Join default channel + fetch history
_joinedChannels.Clear();
_joinedChannels.Add(HubConstants.DefaultChannel);
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
List<MessageDto> history = [];
try
{
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
}
catch
{
_apiClient.Dispose();
_apiClient = null;
throw; // Caller handles saved-session expiry
// History might not be available
}
}
else if (info.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
}
else
{
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
}
// Auto-persist rotated refresh tokens for Remember Me
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
// E2E encryption key
onStatus("Fetching encryption key...");
try
{
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
_encryption.SetKey(encryptionKey);
Log.Information("E2E encryption key established");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
}
onStatus("Authenticated, connecting...");
if (_connection is not null)
await _connection.DisposeAsync();
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
WireConnectionEvents(_connection);
await _connection.ConnectAsync();
var channels = await _apiClient.GetChannelsAsync();
onStatus("Connected");
// Join default channel + fetch history
_joinedChannels.Clear();
_joinedChannels.Add(HubConstants.DefaultChannel);
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
List<MessageDto> history = [];
try
{
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
return new ConnectResult(loginResponse, channels, history);
}
catch
{
// History might not be available
}
if (_connection is not null)
{
await _connection.DisposeAsync();
_connection = null;
}
return new ConnectResult(loginResponse, channels, history);
_apiClient.Dispose();
_apiClient = null;
throw;
}
}
// ── Cleanup ───────────────────────────────────────────────────────────