mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: Refactor authentication and token management
- Updated EchoHubConnection to use ApiClient for token retrieval. - Introduced ValidationConstants for common validation patterns and limits. - Enhanced AuthDtos with refresh token support and expiration details. - Added new ChatDtos for channel creation and topic updates. - Created CommonDtos for error and paginated responses. - Implemented RefreshToken model for managing refresh tokens. - Modified JwtTokenService to generate and hash refresh tokens. - Updated AuthController to handle registration, login, token refresh, and logout with improved error handling. - Enhanced ChannelsController with channel creation, topic updates, and pagination for channel retrieval. - Added FilesController for file management with rate limiting. - Improved UsersController for profile updates and avatar uploads with validation. - Integrated rate limiting across controllers to manage request load. - Introduced FileValidationHelper for validating uploaded image files. - Updated database context to include RefreshToken and enforce unique constraints. - Enhanced ChatHub for improved channel and message handling with validation. - Updated Program.cs to configure rate limiting and CORS policies.
This commit is contained in:
@@ -243,15 +243,24 @@ public static class Program
|
|||||||
|
|
||||||
_commandHandler.OnSetTopic += async (topic) =>
|
_commandHandler.OnSetTopic += async (topic) =>
|
||||||
{
|
{
|
||||||
// Topic setting would go through an API endpoint if available.
|
if (_apiClient is null)
|
||||||
// For now, show as a system message.
|
return;
|
||||||
await Task.CompletedTask;
|
|
||||||
_app.Invoke(() =>
|
|
||||||
{
|
|
||||||
var channel = _mainWindow!.CurrentChannel;
|
var channel = _mainWindow!.CurrentChannel;
|
||||||
if (!string.IsNullOrEmpty(channel))
|
if (string.IsNullOrEmpty(channel))
|
||||||
_mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}");
|
return;
|
||||||
});
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _apiClient.UpdateChannelTopicAsync(channel, topic);
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.AddSystemMessage(channel, $"Topic set to: {topic}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
_mainWindow!.ShowError($"Failed to set topic: {ex.Message}"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnListUsers += async () =>
|
_commandHandler.OnListUsers += async () =>
|
||||||
@@ -340,7 +349,7 @@ public static class Program
|
|||||||
await _connection.DisposeAsync();
|
await _connection.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
_connection = new EchoHubConnection(result.ServerUrl, _apiClient.Token!);
|
_connection = new EchoHubConnection(result.ServerUrl, _apiClient);
|
||||||
WireConnectionEvents(_connection);
|
WireConnectionEvents(_connection);
|
||||||
|
|
||||||
await _connection.ConnectAsync();
|
await _connection.ConnectAsync();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -8,9 +9,12 @@ namespace EchoHub.Client.Services;
|
|||||||
public sealed class ApiClient : IDisposable
|
public sealed class ApiClient : IDisposable
|
||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private string? _token;
|
private string? _accessToken;
|
||||||
|
private string? _refreshToken;
|
||||||
|
private DateTimeOffset _expiresAt;
|
||||||
|
|
||||||
public string? Token => _token;
|
public string? Token => _accessToken;
|
||||||
|
public string? RefreshToken => _refreshToken;
|
||||||
public string BaseUrl { get; }
|
public string BaseUrl { get; }
|
||||||
|
|
||||||
public ApiClient(string baseUrl)
|
public ApiClient(string baseUrl)
|
||||||
@@ -31,7 +35,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
?? throw new InvalidOperationException("Registration returned empty response.");
|
?? throw new InvalidOperationException("Registration returned empty response.");
|
||||||
|
|
||||||
SetToken(result.Token);
|
SetTokens(result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,15 +48,77 @@ public sealed class ApiClient : IDisposable
|
|||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
?? throw new InvalidOperationException("Login returned empty response.");
|
?? throw new InvalidOperationException("Login returned empty response.");
|
||||||
|
|
||||||
SetToken(result.Token);
|
SetTokens(result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task RefreshTokenAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_refreshToken))
|
||||||
|
throw new InvalidOperationException("No refresh token available.");
|
||||||
|
|
||||||
|
var request = new RefreshRequest(_refreshToken);
|
||||||
|
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
|
?? throw new InvalidOperationException("Token refresh returned empty response.");
|
||||||
|
|
||||||
|
SetTokens(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LogoutAsync()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_refreshToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new RefreshRequest(_refreshToken);
|
||||||
|
await _http.PostAsJsonAsync("/api/auth/logout", request);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort logout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_accessToken = null;
|
||||||
|
_refreshToken = null;
|
||||||
|
_http.DefaultRequestHeaders.Authorization = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a valid access token, refreshing if expired.
|
||||||
|
/// Used by EchoHubConnection for SignalR token provider.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> GetValidTokenAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_accessToken))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Refresh if token expires within 60 seconds
|
||||||
|
if (DateTimeOffset.UtcNow >= _expiresAt.AddSeconds(-60) && !string.IsNullOrEmpty(_refreshToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RefreshTokenAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Return current token and let the caller handle auth failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<ChannelDto>> GetChannelsAsync()
|
public async Task<List<ChannelDto>> GetChannelsAsync()
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var channels = await _http.GetFromJsonAsync<List<ChannelDto>>("/api/channels");
|
var response = await AuthenticatedGetAsync("/api/channels");
|
||||||
return channels ?? [];
|
await EnsureSuccessAsync(response);
|
||||||
|
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
|
||||||
|
return paginated?.Items ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ServerStatusDto?> GetServerInfoAsync()
|
public async Task<ServerStatusDto?> GetServerInfoAsync()
|
||||||
@@ -64,15 +130,17 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var profile = await _http.GetFromJsonAsync<UserProfileDto>($"/api/users/{Uri.EscapeDataString(username)}/profile");
|
var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile");
|
||||||
return profile;
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await _http.PutAsJsonAsync("/api/users/profile", request);
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
response.EnsureSuccessStatusCode();
|
_http.PutAsJsonAsync("/api/users/profile", request));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,10 +152,11 @@ 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 _http.PostAsync("/api/users/avatar", content);
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
response.EnsureSuccessStatusCode();
|
_http.PostAsync("/api/users/avatar", content));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
||||||
return result?.AsciiArt;
|
return result?.AvatarAscii;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName)
|
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName)
|
||||||
@@ -98,15 +167,92 @@ 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 _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content);
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
response.EnsureSuccessStatusCode();
|
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetToken(string token)
|
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null)
|
||||||
{
|
{
|
||||||
_token = token;
|
EnsureAuthenticated();
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
var request = new CreateChannelRequest(name, topic);
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync("/api/channels", request));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChannelDto?> UpdateChannelTopicAsync(string channelName, string? topic)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var request = new UpdateTopicRequest(topic);
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetTokens(LoginResponse result)
|
||||||
|
{
|
||||||
|
_accessToken = result.Token;
|
||||||
|
_refreshToken = result.RefreshToken;
|
||||||
|
_expiresAt = result.ExpiresAt;
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs a GET request with automatic token refresh on 401.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
|
||||||
|
{
|
||||||
|
var response = await _http.GetAsync(url);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(_refreshToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RefreshTokenAsync();
|
||||||
|
response = await _http.GetAsync(url);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Refresh failed, return original 401
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs a request with automatic token refresh on 401.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
|
||||||
|
{
|
||||||
|
var response = await requestFactory();
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(_refreshToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RefreshTokenAsync();
|
||||||
|
response = await requestFactory();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Refresh failed, return original 401
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
|
||||||
@@ -114,14 +260,12 @@ public sealed class ApiClient : IDisposable
|
|||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Try to extract a meaningful error message from the response body
|
|
||||||
var errorMessage = $"{(int)response.StatusCode} {response.ReasonPhrase}";
|
var errorMessage = $"{(int)response.StatusCode} {response.ReasonPhrase}";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var body = await response.Content.ReadAsStringAsync();
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
if (!string.IsNullOrWhiteSpace(body))
|
if (!string.IsNullOrWhiteSpace(body))
|
||||||
{
|
{
|
||||||
// Try to parse {"error": "..."} format
|
|
||||||
using var doc = JsonDocument.Parse(body);
|
using var doc = JsonDocument.Parse(body);
|
||||||
if (doc.RootElement.TryGetProperty("error", out var errorProp) ||
|
if (doc.RootElement.TryGetProperty("error", out var errorProp) ||
|
||||||
doc.RootElement.TryGetProperty("Error", out errorProp))
|
doc.RootElement.TryGetProperty("Error", out errorProp))
|
||||||
@@ -144,7 +288,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
|
|
||||||
private void EnsureAuthenticated()
|
private void EnsureAuthenticated()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_token))
|
if (string.IsNullOrEmpty(_accessToken))
|
||||||
throw new InvalidOperationException("Not authenticated. Call LoginAsync or RegisterAsync first.");
|
throw new InvalidOperationException("Not authenticated. Call LoginAsync or RegisterAsync first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,5 +312,3 @@ public sealed class ApiClient : IDisposable
|
|||||||
_http.Dispose();
|
_http.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal record AvatarUploadResponse(string AsciiArt);
|
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||||
|
|
||||||
public EchoHubConnection(string serverUrl, string jwtToken)
|
public EchoHubConnection(string serverUrl, ApiClient apiClient)
|
||||||
{
|
{
|
||||||
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
||||||
|
|
||||||
_connection = new HubConnectionBuilder()
|
_connection = new HubConnectionBuilder()
|
||||||
.WithUrl(hubUrl, options =>
|
.WithUrl(hubUrl, options =>
|
||||||
{
|
{
|
||||||
options.AccessTokenProvider = () => Task.FromResult<string?>(jwtToken);
|
options.AccessTokenProvider = () => apiClient.GetValidTokenAsync();
|
||||||
})
|
})
|
||||||
.WithAutomaticReconnect()
|
.WithAutomaticReconnect()
|
||||||
.Build();
|
.Build();
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.Constants;
|
||||||
|
|
||||||
|
public static partial class ValidationConstants
|
||||||
|
{
|
||||||
|
public const string UsernamePattern = @"^[a-zA-Z0-9_-]{3,50}$";
|
||||||
|
public const string ChannelNamePattern = @"^[a-zA-Z0-9_-]{2,100}$";
|
||||||
|
public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$";
|
||||||
|
|
||||||
|
public const int MaxPasswordLength = 128;
|
||||||
|
public const int MaxDisplayNameLength = 100;
|
||||||
|
public const int MaxBioLength = 500;
|
||||||
|
public const int MaxStatusMessageLength = 100;
|
||||||
|
public const int MaxChannelTopicLength = 500;
|
||||||
|
public const int MaxHistoryCount = 100;
|
||||||
|
|
||||||
|
[GeneratedRegex(UsernamePattern)]
|
||||||
|
public static partial Regex UsernameRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(ChannelNamePattern)]
|
||||||
|
public static partial Regex ChannelNameRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(HexColorPattern)]
|
||||||
|
public static partial Regex HexColorRegex();
|
||||||
|
}
|
||||||
@@ -4,4 +4,12 @@ public record RegisterRequest(string Username, string Password, string? DisplayN
|
|||||||
|
|
||||||
public record LoginRequest(string Username, string Password);
|
public record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public record LoginResponse(string Token, string Username, string? DisplayName, string? NicknameColor);
|
public record LoginResponse(
|
||||||
|
string Token,
|
||||||
|
string RefreshToken,
|
||||||
|
DateTimeOffset ExpiresAt,
|
||||||
|
string Username,
|
||||||
|
string? DisplayName,
|
||||||
|
string? NicknameColor);
|
||||||
|
|
||||||
|
public record RefreshRequest(string RefreshToken);
|
||||||
|
|||||||
@@ -29,3 +29,7 @@ public record UserDto(
|
|||||||
DateTimeOffset LastSeenAt);
|
DateTimeOffset LastSeenAt);
|
||||||
|
|
||||||
public record SendMessageRequest(string ChannelName, string Content);
|
public record SendMessageRequest(string ChannelName, string Content);
|
||||||
|
|
||||||
|
public record CreateChannelRequest(string Name, string? Topic = null);
|
||||||
|
|
||||||
|
public record UpdateTopicRequest(string? Topic);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record ErrorResponse(string Error, string? Detail = null);
|
||||||
|
|
||||||
|
public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit);
|
||||||
@@ -29,3 +29,5 @@ public record UserPresenceDto(
|
|||||||
string? NicknameColor,
|
string? NicknameColor,
|
||||||
UserStatus Status,
|
UserStatus Status,
|
||||||
string? StatusMessage);
|
string? StatusMessage);
|
||||||
|
|
||||||
|
public record AvatarUploadResponse(string AvatarAscii);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public class RefreshToken
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public required string TokenHash { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset? RevokedAt { get; set; }
|
||||||
|
|
||||||
|
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
|
||||||
|
public bool IsRevoked => RevokedAt is not null;
|
||||||
|
public bool IsActive => !IsExpired && !IsRevoked;
|
||||||
|
|
||||||
|
public User? User { get; set; }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
@@ -15,10 +16,14 @@ public class JwtTokenService(IConfiguration configuration)
|
|||||||
private readonly string _audience = configuration["Jwt:Audience"]
|
private readonly string _audience = configuration["Jwt:Audience"]
|
||||||
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
||||||
|
|
||||||
public string GenerateToken(User user)
|
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15);
|
||||||
|
public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
|
public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(User user)
|
||||||
{
|
{
|
||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
||||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
var expiresAt = DateTimeOffset.UtcNow.Add(AccessTokenLifetime);
|
||||||
|
|
||||||
Claim[] claims =
|
Claim[] claims =
|
||||||
[
|
[
|
||||||
@@ -32,9 +37,23 @@ public class JwtTokenService(IConfiguration configuration)
|
|||||||
issuer: _issuer,
|
issuer: _issuer,
|
||||||
audience: _audience,
|
audience: _audience,
|
||||||
claims: claims,
|
claims: claims,
|
||||||
expires: DateTime.UtcNow.AddDays(7),
|
expires: expiresAt.UtcDateTime,
|
||||||
signingCredentials: credentials);
|
signingCredentials: credentials);
|
||||||
|
|
||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GenerateRefreshToken()
|
||||||
|
{
|
||||||
|
var randomBytes = new byte[64];
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
rng.GetBytes(randomBytes);
|
||||||
|
return Convert.ToBase64String(randomBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string HashToken(string token)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||||
|
return Convert.ToBase64String(bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Auth;
|
using EchoHub.Server.Auth;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
|
[EnableRateLimiting("auth")]
|
||||||
public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase
|
public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase
|
||||||
{
|
{
|
||||||
[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))
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
return BadRequest(new { Error = "Username and password are required." });
|
return BadRequest(new ErrorResponse("Username and password are required."));
|
||||||
|
|
||||||
if (request.Username.Length < 3 || request.Username.Length > 50)
|
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
|
||||||
return BadRequest(new { Error = "Username must be between 3 and 50 characters." });
|
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
|
||||||
|
|
||||||
if (request.Password.Length < 6)
|
if (request.Password.Length < 6)
|
||||||
return BadRequest(new { Error = "Password must be at least 6 characters." });
|
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();
|
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
||||||
return Conflict(new { Error = "Username is already taken." });
|
return Conflict(new ErrorResponse("Username is already taken."));
|
||||||
|
|
||||||
var user = new User
|
var user = new User
|
||||||
{
|
{
|
||||||
@@ -39,28 +45,102 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
db.Users.Add(user);
|
db.Users.Add(user);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var token = jwt.GenerateToken(user);
|
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
||||||
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
return Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor));
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
|
UserId = user.Id,
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.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))
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
return BadRequest(new { Error = "Username and password are required." });
|
return BadRequest(new ErrorResponse("Username and password are required."));
|
||||||
|
|
||||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
|
||||||
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||||
return Unauthorized();
|
return Unauthorized(new ErrorResponse("Invalid username or password."));
|
||||||
|
|
||||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var token = jwt.GenerateToken(user);
|
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
||||||
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
return Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor));
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
|
UserId = user.Id,
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||||
|
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||||
|
|
||||||
|
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||||
|
var storedToken = await db.RefreshTokens
|
||||||
|
.Include(r => r.User)
|
||||||
|
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||||
|
|
||||||
|
if (storedToken is null || !storedToken.IsActive || storedToken.User is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Invalid or expired refresh token."));
|
||||||
|
|
||||||
|
// Revoke old refresh token (rotation)
|
||||||
|
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var user = storedToken.User;
|
||||||
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// Issue new token pair
|
||||||
|
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
||||||
|
var newRefreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TokenHash = JwtTokenService.HashToken(newRefreshToken),
|
||||||
|
UserId = user.Id,
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logout")]
|
||||||
|
public async Task<IActionResult> Logout([FromBody] RefreshRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||||
|
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||||
|
|
||||||
|
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||||
|
var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||||
|
|
||||||
|
if (storedToken is not null && storedToken.IsActive)
|
||||||
|
{
|
||||||
|
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using EchoHub.Server.Hubs;
|
|||||||
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.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/channels")]
|
[Route("api/channels")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[EnableRateLimiting("general")]
|
||||||
public class ChannelsController(
|
public class ChannelsController(
|
||||||
EchoHubDbContext db,
|
EchoHubDbContext db,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
@@ -23,9 +25,17 @@ public class ChannelsController(
|
|||||||
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase
|
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetChannels()
|
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
||||||
{
|
{
|
||||||
|
offset = Math.Max(0, offset);
|
||||||
|
limit = Math.Clamp(limit, 1, 100);
|
||||||
|
|
||||||
|
var total = await db.Channels.CountAsync();
|
||||||
|
|
||||||
var channels = await db.Channels
|
var channels = await db.Channels
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(limit)
|
||||||
.Select(c => new ChannelDto(
|
.Select(c => new ChannelDto(
|
||||||
c.Id,
|
c.Id,
|
||||||
c.Name,
|
c.Name,
|
||||||
@@ -34,45 +44,146 @@ public class ChannelsController(
|
|||||||
c.CreatedAt))
|
c.CreatedAt))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return Ok(channels);
|
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
return BadRequest(new ErrorResponse("Channel name is required."));
|
||||||
|
|
||||||
|
var channelName = request.Name.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
|
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
|
||||||
|
|
||||||
|
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
||||||
|
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
|
||||||
|
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
|
var channel = new Channel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = channelName,
|
||||||
|
Topic = request.Topic?.Trim(),
|
||||||
|
CreatedByUserId = Guid.Parse(userIdClaim),
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Channels.Add(channel);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
|
||||||
|
await hubContext.Clients.All.ChannelUpdated(dto);
|
||||||
|
|
||||||
|
return Created($"/api/channels/{channelName}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{channel}/topic")]
|
||||||
|
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
|
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
|
||||||
|
if (dbChannel is null)
|
||||||
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
|
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
||||||
|
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
|
||||||
|
|
||||||
|
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
|
||||||
|
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
|
||||||
|
|
||||||
|
dbChannel.Topic = request.Topic?.Trim();
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||||
|
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
|
||||||
|
await hubContext.Clients.Group(channelName).ChannelUpdated(dto);
|
||||||
|
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{channel}")]
|
||||||
|
public async Task<IActionResult> DeleteChannel(string channel)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (channelName == HubConstants.DefaultChannel)
|
||||||
|
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
|
||||||
|
|
||||||
|
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
|
||||||
|
if (dbChannel is null)
|
||||||
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
|
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
||||||
|
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
|
||||||
|
|
||||||
|
db.Channels.Remove(dbChannel);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{channel}/upload")]
|
[HttpPost("{channel}/upload")]
|
||||||
|
[EnableRateLimiting("upload")]
|
||||||
public async Task<IActionResult> Upload(string channel)
|
public async Task<IActionResult> Upload(string channel)
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var usernameClaim = User.FindFirstValue("username");
|
var usernameClaim = User.FindFirstValue("username");
|
||||||
if (userIdClaim is null || usernameClaim is null)
|
if (userIdClaim is null || usernameClaim is null)
|
||||||
return Unauthorized();
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
var userId = Guid.Parse(userIdClaim);
|
||||||
var channelName = channel.ToLowerInvariant().Trim();
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
|
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||||
|
|
||||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return NotFound(new { Error = $"Channel '{channelName}' does not exist." });
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||||
return BadRequest(new { Error = "No file uploaded." });
|
return BadRequest(new ErrorResponse("No file uploaded."));
|
||||||
|
|
||||||
var file = Request.Form.Files[0];
|
var file = Request.Form.Files[0];
|
||||||
|
|
||||||
if (file.Length > HubConstants.MaxFileSizeBytes)
|
if (file.Length > HubConstants.MaxFileSizeBytes)
|
||||||
return BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB." });
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
|
// Detect if file is an image by checking magic bytes
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
|
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||||
|
|
||||||
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
|
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
|
||||||
|
|
||||||
var imageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
|
|
||||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
|
||||||
var isImage = imageExtensions.Contains(extension);
|
|
||||||
|
|
||||||
var messageType = isImage ? MessageType.Image : MessageType.File;
|
var messageType = isImage ? MessageType.Image : MessageType.File;
|
||||||
var content = isImage
|
string content;
|
||||||
? asciiService.ConvertToAscii(System.IO.File.OpenRead(filePath))
|
|
||||||
: file.FileName;
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
|
||||||
|
|
||||||
|
if (isImage)
|
||||||
|
{
|
||||||
|
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||||
|
content = asciiService.ConvertToAscii(imageStream);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
content = file.FileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachmentUrl = $"/api/files/{fileId}";
|
||||||
var sender = await db.Users.FindAsync(userId);
|
var sender = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
@@ -102,7 +213,6 @@ public class ChannelsController(
|
|||||||
file.FileName,
|
file.FileName,
|
||||||
message.SentAt);
|
message.SentAt);
|
||||||
|
|
||||||
// Broadcast to all clients in the channel via SignalR
|
|
||||||
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
|
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||||
|
|
||||||
return Ok(messageDto);
|
return Ok(messageDto);
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/files")]
|
[Route("api/files")]
|
||||||
|
[Authorize]
|
||||||
|
[EnableRateLimiting("general")]
|
||||||
public class FilesController(FileStorageService fileStorage) : ControllerBase
|
public class FilesController(FileStorageService fileStorage) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{fileId}")]
|
[HttpGet("{fileId}")]
|
||||||
public IActionResult GetFile(string fileId)
|
public IActionResult GetFile(string fileId)
|
||||||
{
|
{
|
||||||
|
if (!Guid.TryParse(fileId, out _))
|
||||||
|
return BadRequest(new ErrorResponse("Invalid file identifier."));
|
||||||
|
|
||||||
var filePath = fileStorage.GetFilePath(fileId);
|
var filePath = fileStorage.GetFilePath(fileId);
|
||||||
|
|
||||||
if (filePath is null)
|
if (filePath is null)
|
||||||
return NotFound(new { Error = "File not found." });
|
return NotFound(new ErrorResponse("File not found."));
|
||||||
|
|
||||||
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
|
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ 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.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
|
[Authorize]
|
||||||
|
[EnableRateLimiting("general")]
|
||||||
public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase
|
public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
@@ -20,33 +23,45 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return NotFound(new { Error = "User not found." });
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
|
|
||||||
return Ok(ToProfileDto(user));
|
return Ok(ToProfileDto(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("profile")]
|
[HttpPut("profile")]
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
|
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized();
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
var userId = Guid.Parse(userIdClaim);
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return NotFound(new { Error = "User not found." });
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
|
|
||||||
if (request.DisplayName is not null)
|
if (request.DisplayName is not null)
|
||||||
|
{
|
||||||
|
if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength)
|
||||||
|
return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."));
|
||||||
user.DisplayName = request.DisplayName.Trim();
|
user.DisplayName = request.DisplayName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (request.Bio is not null)
|
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();
|
user.Bio = request.Bio.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (request.NicknameColor is not null)
|
if (request.NicknameColor is not null)
|
||||||
user.NicknameColor = request.NicknameColor.Trim();
|
{
|
||||||
|
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();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -54,34 +69,38 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("avatar")]
|
[HttpPost("avatar")]
|
||||||
[Authorize]
|
[EnableRateLimiting("upload")]
|
||||||
public async Task<IActionResult> UploadAvatar()
|
public async Task<IActionResult> UploadAvatar()
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized();
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
var userId = Guid.Parse(userIdClaim);
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return NotFound(new { Error = "User not found." });
|
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 { Error = "No file uploaded." });
|
return BadRequest(new ErrorResponse("No file uploaded."));
|
||||||
|
|
||||||
var file = Request.Form.Files[0];
|
var file = Request.Form.Files[0];
|
||||||
|
|
||||||
if (file.Length > HubConstants.MaxAvatarSizeBytes)
|
if (file.Length > HubConstants.MaxAvatarSizeBytes)
|
||||||
return BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB." });
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
|
|
||||||
|
if (!FileValidationHelper.IsValidImage(stream))
|
||||||
|
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
||||||
|
|
||||||
var asciiArt = asciiService.ConvertToAscii(stream);
|
var asciiArt = asciiService.ConvertToAscii(stream);
|
||||||
|
|
||||||
user.AvatarAscii = asciiArt;
|
user.AvatarAscii = asciiArt;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new { AvatarAscii = asciiArt });
|
return Ok(new AvatarUploadResponse(asciiArt));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UserProfileDto ToProfileDto(Core.Models.User user) => new(
|
private static UserProfileDto ToProfileDto(Core.Models.User user) => new(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
|
|||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<Channel> Channels => Set<Channel>();
|
public DbSet<Channel> Channels => Set<Channel>();
|
||||||
public DbSet<Message> Messages => Set<Message>();
|
public DbSet<Message> Messages => Set<Message>();
|
||||||
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -36,7 +37,7 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
|
|||||||
modelBuilder.Entity<Channel>(entity =>
|
modelBuilder.Entity<Channel>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(c => c.Id);
|
entity.HasKey(c => c.Id);
|
||||||
entity.HasIndex(c => c.Name);
|
entity.HasIndex(c => c.Name).IsUnique();
|
||||||
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
|
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
|
||||||
entity.Property(c => c.Topic).HasMaxLength(500);
|
entity.Property(c => c.Topic).HasMaxLength(500);
|
||||||
|
|
||||||
@@ -56,6 +57,19 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
|
|||||||
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RefreshToken>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(r => r.Id);
|
||||||
|
entity.HasIndex(r => r.TokenHash);
|
||||||
|
entity.HasIndex(r => r.UserId);
|
||||||
|
entity.Property(r => r.TokenHash).IsRequired().HasMaxLength(128);
|
||||||
|
|
||||||
|
entity.HasOne(r => r.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
// SQLite does not support DateTimeOffset in ORDER BY clauses.
|
// SQLite does not support DateTimeOffset in ORDER BY clauses.
|
||||||
// Convert all DateTimeOffset properties to Unix milliseconds (long) for storage.
|
// Convert all DateTimeOffset properties to Unix milliseconds (long) for storage.
|
||||||
var dateTimeOffsetConverter = new ValueConverter<DateTimeOffset, long>(
|
var dateTimeOffsetConverter = new ValueConverter<DateTimeOffset, long>(
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
|
|
||||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
{
|
{
|
||||||
// Capture channels before disconnecting, since UserDisconnected clears them
|
|
||||||
// when the last connection for a user is removed.
|
|
||||||
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
||||||
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
||||||
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
||||||
@@ -82,21 +80,18 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
|
{
|
||||||
|
await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
{
|
{
|
||||||
channel = new Channel
|
await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
||||||
{
|
return [];
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Name = channelName,
|
|
||||||
CreatedByUserId = CurrentUserId,
|
|
||||||
};
|
|
||||||
|
|
||||||
db.Channels.Add(channel);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
logger.LogInformation("Channel '{Channel}' created by {User}", channelName, CurrentUsername);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
presenceTracker.JoinChannel(CurrentUsername, channelName);
|
presenceTracker.JoinChannel(CurrentUsername, channelName);
|
||||||
@@ -126,6 +121,12 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
|
{
|
||||||
|
await Clients.Caller.Error("Invalid channel name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
{
|
{
|
||||||
await Clients.Caller.Error("Message content cannot be empty.");
|
await Clients.Caller.Error("Message content cannot be empty.");
|
||||||
@@ -181,13 +182,12 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
{
|
|
||||||
return [];
|
return [];
|
||||||
}
|
|
||||||
|
|
||||||
var messages = await db.Messages
|
var messages = await db.Messages
|
||||||
.Where(m => m.ChannelId == channel.Id)
|
.Where(m => m.ChannelId == channel.Id)
|
||||||
@@ -214,6 +214,12 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
|
|
||||||
public async Task UpdateStatus(UserStatus status, string? statusMessage)
|
public async Task UpdateStatus(UserStatus status, string? statusMessage)
|
||||||
{
|
{
|
||||||
|
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||||
|
{
|
||||||
|
await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var user = await db.Users.FindAsync(CurrentUserId);
|
var user = await db.Users.FindAsync(CurrentUserId);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
@@ -223,7 +229,7 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.Status = status;
|
user.Status = status;
|
||||||
user.StatusMessage = statusMessage;
|
user.StatusMessage = statusMessage?.Trim();
|
||||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Auth;
|
using EchoHub.Server.Auth;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
@@ -72,15 +74,48 @@ builder.Services.AddSingleton<PresenceTracker>();
|
|||||||
builder.Services.AddSingleton<ImageToAsciiService>();
|
builder.Services.AddSingleton<ImageToAsciiService>();
|
||||||
builder.Services.AddSingleton<FileStorageService>();
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
|
|
||||||
// ── CORS (allow all for development) ──────────────────────────────────────────
|
// ── Rate Limiting ────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("auth", limiter =>
|
||||||
|
{
|
||||||
|
limiter.PermitLimit = 10;
|
||||||
|
limiter.Window = TimeSpan.FromMinutes(1);
|
||||||
|
limiter.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("upload", limiter =>
|
||||||
|
{
|
||||||
|
limiter.PermitLimit = 5;
|
||||||
|
limiter.Window = TimeSpan.FromMinutes(1);
|
||||||
|
limiter.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("general", limiter =>
|
||||||
|
{
|
||||||
|
limiter.PermitLimit = 100;
|
||||||
|
limiter.Window = TimeSpan.FromMinutes(1);
|
||||||
|
limiter.QueueLimit = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CORS ─────────────────────────────────────────────────────────────────────
|
||||||
|
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddDefaultPolicy(policy =>
|
options.AddDefaultPolicy(policy =>
|
||||||
{
|
{
|
||||||
policy.AllowAnyHeader()
|
policy.AllowAnyHeader()
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowCredentials()
|
.AllowCredentials();
|
||||||
.SetIsOriginAllowed(_ => true);
|
|
||||||
|
if (allowedOrigins is { Length: > 0 })
|
||||||
|
policy.WithOrigins(allowedOrigins);
|
||||||
|
else
|
||||||
|
policy.SetIsOriginAllowed(_ => true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,6 +143,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
|
|
||||||
// ── Middleware ─────────────────────────────────────────────────────────────────
|
// ── Middleware ─────────────────────────────────────────────────────────────────
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public static class FileValidationHelper
|
||||||
|
{
|
||||||
|
private static readonly byte[] JpegMagic = [0xFF, 0xD8, 0xFF];
|
||||||
|
private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47];
|
||||||
|
private static readonly byte[] GifMagic = [0x47, 0x49, 0x46];
|
||||||
|
private static readonly byte[] WebpRiff = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
|
||||||
|
private static readonly byte[] WebpTag = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that a stream contains a recognized image format by checking magic bytes.
|
||||||
|
/// The stream position is reset to the beginning after validation.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsValidImage(Stream stream)
|
||||||
|
{
|
||||||
|
if (!stream.CanSeek)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var originalPosition = stream.Position;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var header = new byte[12];
|
||||||
|
var bytesRead = stream.Read(header, 0, header.Length);
|
||||||
|
|
||||||
|
if (bytesRead < 3)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// JPEG: FF D8 FF
|
||||||
|
if (StartsWith(header, bytesRead, JpegMagic))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// PNG: 89 50 4E 47
|
||||||
|
if (bytesRead >= 4 && StartsWith(header, bytesRead, PngMagic))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// GIF: 47 49 46 (GIF87a or GIF89a)
|
||||||
|
if (StartsWith(header, bytesRead, GifMagic))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// WebP: RIFF....WEBP
|
||||||
|
if (bytesRead >= 12 && StartsWith(header, bytesRead, WebpRiff)
|
||||||
|
&& header[8] == WebpTag[0] && header[9] == WebpTag[1]
|
||||||
|
&& header[10] == WebpTag[2] && header[11] == WebpTag[3])
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stream.Position = originalPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool StartsWith(byte[] buffer, int length, byte[] magic)
|
||||||
|
{
|
||||||
|
if (length < magic.Length)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (int i = 0; i < magic.Length; i++)
|
||||||
|
{
|
||||||
|
if (buffer[i] != magic[i])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user