Refactor classes to use constructor injection for dependencies

- Updated IrcBroadcaster to use constructor injection for IrcGatewayService.
- Refactored JwtTokenService to initialize configuration values in the constructor.
- Modified AuthController to use constructor injection for EchoHubDbContext and JwtTokenService.
- Refactored ChannelsController to utilize constructor injection for dependencies.
- Updated FilesController to use constructor injection for FileStorageService.
- Refactored ServerController to initialize EchoHubDbContext and IConfiguration via constructor.
- Modified UsersController to use constructor injection for EchoHubDbContext and ImageToAsciiService.
- Updated EchoHubDbContext to use constructor for DbContextOptions.
- Refactored ChatHub to use constructor injection for IChatService and ILogger.
- Modified ChatService to utilize constructor injection for dependencies.
- Refactored ServerDirectoryService to use constructor injection for IConfiguration, PresenceTracker, and ILogger.
This commit is contained in:
HueByte
2026-02-19 14:04:19 +01:00
parent 0422066851
commit 13297fd017
11 changed files with 248 additions and 156 deletions
+15 -8
View File
@@ -3,13 +3,20 @@ using EchoHub.Core.DTOs;
namespace EchoHub.Server.Irc; namespace EchoHub.Server.Irc;
public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster public class IrcBroadcaster : IChatBroadcaster
{ {
private readonly IrcGatewayService _gateway;
public IrcBroadcaster(IrcGatewayService gateway)
{
_gateway = gateway;
}
public async Task SendMessageToChannelAsync(string channelName, MessageDto message) public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
{ {
var lines = IrcMessageFormatter.FormatMessage(message); var lines = IrcMessageFormatter.FormatMessage(message);
foreach (var conn in gateway.GetConnectionsInChannel(channelName)) foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{ {
// IRC convention: don't echo sender's own message // IRC convention: don't echo sender's own message
if (conn.Nickname == message.SenderUsername) if (conn.Nickname == message.SenderUsername)
@@ -22,7 +29,7 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null) public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
{ {
foreach (var conn in gateway.GetConnectionsInChannel(channelName)) foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{ {
if (conn.ConnectionId == excludeConnectionId) continue; if (conn.ConnectionId == excludeConnectionId) continue;
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}"); await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
@@ -31,7 +38,7 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
public async Task SendUserLeftAsync(string channelName, string username) public async Task SendUserLeftAsync(string channelName, string username)
{ {
foreach (var conn in gateway.GetConnectionsInChannel(channelName)) foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{ {
if (conn.Nickname == username) continue; if (conn.Nickname == username) continue;
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}"); await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
@@ -43,9 +50,9 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
var target = channelName ?? channel.Name; var target = channelName ?? channel.Name;
if (channel.Topic is null) return; if (channel.Topic is null) return;
foreach (var conn in gateway.GetConnectionsInChannel(target)) foreach (var conn in _gateway.GetConnectionsInChannel(target))
{ {
await conn.SendAsync($":{gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}"); await conn.SendAsync($":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
} }
} }
@@ -59,9 +66,9 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
{ {
if (!connectionId.StartsWith("irc-")) return; if (!connectionId.StartsWith("irc-")) return;
if (gateway.Connections.TryGetValue(connectionId, out var conn)) if (_gateway.Connections.TryGetValue(connectionId, out var conn))
{ {
await conn.SendAsync($":{gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}"); await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}");
} }
} }
} }
+14 -7
View File
@@ -7,18 +7,25 @@ using Microsoft.IdentityModel.Tokens;
namespace EchoHub.Server.Auth; namespace EchoHub.Server.Auth;
public class JwtTokenService(IConfiguration configuration) public class JwtTokenService
{ {
private readonly string _secret = configuration["Jwt:Secret"] private readonly string _secret;
?? throw new InvalidOperationException("Jwt:Secret is not configured."); private readonly string _issuer;
private readonly string _issuer = configuration["Jwt:Issuer"] private readonly string _audience;
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
private readonly string _audience = configuration["Jwt:Audience"]
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15); private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15);
public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30); public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30);
public JwtTokenService(IConfiguration configuration)
{
_secret = configuration["Jwt:Secret"]
?? throw new InvalidOperationException("Jwt:Secret is not configured.");
_issuer = configuration["Jwt:Issuer"]
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
_audience = configuration["Jwt:Audience"]
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
}
public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(User user) public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(User user)
{ {
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
@@ -12,8 +12,16 @@ namespace EchoHub.Server.Controllers;
[ApiController] [ApiController]
[Route("api/auth")] [Route("api/auth")]
[EnableRateLimiting("auth")] [EnableRateLimiting("auth")]
public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase public class AuthController : ControllerBase
{ {
private readonly EchoHubDbContext _db;
private readonly JwtTokenService _jwt;
public AuthController(EchoHubDbContext db, JwtTokenService jwt)
{
_db = db;
_jwt = jwt;
}
[HttpPost("register")] [HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request) public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{ {
@@ -31,7 +39,7 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
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 ErrorResponse("Username is already taken.")); return Conflict(new ErrorResponse("Username is already taken."));
var user = new User var user = new User
@@ -42,20 +50,20 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
DisplayName = request.DisplayName?.Trim(), DisplayName = request.DisplayName?.Trim(),
}; };
db.Users.Add(user); _db.Users.Add(user);
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); 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 = user.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, user.Username, user.DisplayName, user.NicknameColor));
} }
@@ -67,25 +75,25 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
return BadRequest(new ErrorResponse("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(new ErrorResponse("Invalid username or password.")); return Unauthorized(new ErrorResponse("Invalid username or password."));
user.LastSeenAt = DateTimeOffset.UtcNow; user.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); 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 = user.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, user.Username, user.DisplayName, user.NicknameColor));
} }
@@ -97,7 +105,7 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
return BadRequest(new ErrorResponse("Refresh token is required.")); return BadRequest(new ErrorResponse("Refresh token is required."));
var tokenHash = JwtTokenService.HashToken(request.RefreshToken); var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
var storedToken = await db.RefreshTokens var storedToken = await _db.RefreshTokens
.Include(r => r.User) .Include(r => r.User)
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash); .FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
@@ -111,17 +119,17 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
user.LastSeenAt = DateTimeOffset.UtcNow; user.LastSeenAt = DateTimeOffset.UtcNow;
// Issue new token pair // Issue new token pair
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
var newRefreshToken = JwtTokenService.GenerateRefreshToken(); var newRefreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken _db.RefreshTokens.Add(new RefreshToken
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(newRefreshToken), TokenHash = JwtTokenService.HashToken(newRefreshToken),
UserId = user.Id, UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
}); });
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
} }
@@ -133,12 +141,12 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
return BadRequest(new ErrorResponse("Refresh token is required.")); return BadRequest(new ErrorResponse("Refresh token is required."));
var tokenHash = JwtTokenService.HashToken(request.RefreshToken); var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash); var storedToken = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
if (storedToken is not null && storedToken.IsActive) if (storedToken is not null && storedToken.IsActive)
{ {
storedToken.RevokedAt = DateTimeOffset.UtcNow; storedToken.RevokedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
return Ok(); return Ok();
@@ -16,22 +16,36 @@ namespace EchoHub.Server.Controllers;
[Route("api/channels")] [Route("api/channels")]
[Authorize] [Authorize]
[EnableRateLimiting("general")] [EnableRateLimiting("general")]
public class ChannelsController( public class ChannelsController : ControllerBase
{
private readonly EchoHubDbContext _db;
private readonly FileStorageService _fileStorage;
private readonly ImageToAsciiService _asciiService;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IChatService _chatService;
public ChannelsController(
EchoHubDbContext db, EchoHubDbContext db,
FileStorageService fileStorage, FileStorageService fileStorage,
ImageToAsciiService asciiService, ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IChatService chatService) : ControllerBase IChatService chatService)
{ {
_db = db;
_fileStorage = fileStorage;
_asciiService = asciiService;
_httpClientFactory = httpClientFactory;
_chatService = chatService;
}
[HttpGet] [HttpGet]
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
{ {
offset = Math.Max(0, offset); offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100); limit = Math.Clamp(limit, 1, 100);
var total = await db.Channels.CountAsync(); var total = await _db.Channels.CountAsync();
var channels = await db.Channels var channels = await _db.Channels
.OrderBy(c => c.Name) .OrderBy(c => c.Name)
.Skip(offset) .Skip(offset)
.Take(limit) .Take(limit)
@@ -57,7 +71,7 @@ public class ChannelsController(
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.")); 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)) if (await _db.Channels.AnyAsync(c => c.Name == channelName))
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists.")); return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -72,11 +86,11 @@ public class ChannelsController(
CreatedByUserId = Guid.Parse(userIdClaim), CreatedByUserId = Guid.Parse(userIdClaim),
}; };
db.Channels.Add(channel); _db.Channels.Add(channel);
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
await chatService.BroadcastChannelUpdatedAsync(dto); await _chatService.BroadcastChannelUpdatedAsync(dto);
return Created($"/api/channels/{channelName}", dto); return Created($"/api/channels/{channelName}", dto);
} }
@@ -89,7 +103,7 @@ public class ChannelsController(
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim(); var channelName = channel.ToLowerInvariant().Trim();
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 ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
@@ -101,11 +115,11 @@ public class ChannelsController(
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.")); return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
dbChannel.Topic = request.Topic?.Trim(); dbChannel.Topic = request.Topic?.Trim();
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt); var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
await chatService.BroadcastChannelUpdatedAsync(dto, channelName); await _chatService.BroadcastChannelUpdatedAsync(dto, channelName);
return Ok(dto); return Ok(dto);
} }
@@ -122,7 +136,7 @@ public class ChannelsController(
if (channelName == HubConstants.DefaultChannel) if (channelName == HubConstants.DefaultChannel)
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted.")); return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
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 ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
@@ -130,8 +144,8 @@ public class ChannelsController(
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel.")); return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
db.Channels.Remove(dbChannel); _db.Channels.Remove(dbChannel);
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
return NoContent(); return NoContent();
} }
@@ -151,7 +165,7 @@ public class ChannelsController(
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); 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 ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
@@ -167,7 +181,7 @@ public class ChannelsController(
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream); var isImage = FileValidationHelper.IsValidImage(stream);
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName); var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
var messageType = isImage ? MessageType.Image : MessageType.File; var messageType = isImage ? MessageType.Image : MessageType.File;
string content; string content;
@@ -175,7 +189,7 @@ public class ChannelsController(
if (isImage) if (isImage)
{ {
using var imageStream = System.IO.File.OpenRead(filePath); using var imageStream = System.IO.File.OpenRead(filePath);
content = asciiService.ConvertToAscii(imageStream); content = _asciiService.ConvertToAscii(imageStream);
} }
else else
{ {
@@ -183,7 +197,7 @@ public class ChannelsController(
} }
var attachmentUrl = $"/api/files/{fileId}"; 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
{ {
@@ -198,8 +212,8 @@ public class ChannelsController(
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
db.Messages.Add(message); _db.Messages.Add(message);
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
var messageDto = new MessageDto( var messageDto = new MessageDto(
message.Id, message.Id,
@@ -212,7 +226,7 @@ public class ChannelsController(
file.FileName, file.FileName,
message.SentAt); message.SentAt);
await chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(messageDto); return Ok(messageDto);
} }
@@ -232,7 +246,7 @@ public class ChannelsController(
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); 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 ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
@@ -248,7 +262,7 @@ public class ChannelsController(
string fileName; string fileName;
try try
{ {
using var client = httpClientFactory.CreateClient("ImageDownload"); using var client = _httpClientFactory.CreateClient("ImageDownload");
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -291,16 +305,16 @@ public class ChannelsController(
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP.")); return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
// Save file and convert to ASCII // Save file and convert to ASCII
var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName); var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
string content; string content;
using (var imageStream = System.IO.File.OpenRead(filePath)) using (var imageStream = System.IO.File.OpenRead(filePath))
{ {
content = asciiService.ConvertToAscii(imageStream); content = _asciiService.ConvertToAscii(imageStream);
} }
var attachmentUrl = $"/api/files/{fileId}"; 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
{ {
@@ -315,8 +329,8 @@ public class ChannelsController(
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
db.Messages.Add(message); _db.Messages.Add(message);
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
var messageDto = new MessageDto( var messageDto = new MessageDto(
message.Id, message.Id,
@@ -329,7 +343,7 @@ public class ChannelsController(
fileName, fileName,
message.SentAt); message.SentAt);
await chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(messageDto); return Ok(messageDto);
} }
@@ -10,15 +10,21 @@ namespace EchoHub.Server.Controllers;
[Route("api/files")] [Route("api/files")]
[Authorize] [Authorize]
[EnableRateLimiting("general")] [EnableRateLimiting("general")]
public class FilesController(FileStorageService fileStorage) : ControllerBase public class FilesController : ControllerBase
{ {
private readonly FileStorageService _fileStorage;
public FilesController(FileStorageService fileStorage)
{
_fileStorage = fileStorage;
}
[HttpGet("{fileId}")] [HttpGet("{fileId}")]
public IActionResult GetFile(string fileId) public IActionResult GetFile(string fileId)
{ {
if (!Guid.TryParse(fileId, out _)) if (!Guid.TryParse(fileId, out _))
return BadRequest(new ErrorResponse("Invalid file identifier.")); 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 ErrorResponse("File not found.")); return NotFound(new ErrorResponse("File not found."));
@@ -7,17 +7,25 @@ namespace EchoHub.Server.Controllers;
[ApiController] [ApiController]
[Route("api/server")] [Route("api/server")]
public class ServerController(EchoHubDbContext db, IConfiguration config) : ControllerBase public class ServerController : ControllerBase
{ {
private readonly EchoHubDbContext _db;
private readonly IConfiguration _config;
public ServerController(EchoHubDbContext db, IConfiguration config)
{
_db = db;
_config = config;
}
[HttpGet("info")] [HttpGet("info")]
public async Task<IActionResult> GetInfo() public async Task<IActionResult> GetInfo()
{ {
var userCount = await db.Users.CountAsync(); var userCount = await _db.Users.CountAsync();
var channelCount = await db.Channels.CountAsync(); var channelCount = await _db.Channels.CountAsync();
var status = new ServerStatusDto( var status = new ServerStatusDto(
config["Server:Name"] ?? "EchoHub Server", _config["Server:Name"] ?? "EchoHub Server",
config["Server:Description"], _config["Server:Description"],
userCount, userCount,
channelCount); channelCount);
@@ -14,13 +14,21 @@ namespace EchoHub.Server.Controllers;
[Route("api/users")] [Route("api/users")]
[Authorize] [Authorize]
[EnableRateLimiting("general")] [EnableRateLimiting("general")]
public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase public class UsersController : ControllerBase
{ {
private readonly EchoHubDbContext _db;
private readonly ImageToAsciiService _asciiService;
public UsersController(EchoHubDbContext db, ImageToAsciiService asciiService)
{
_db = db;
_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 normalizedUsername = 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) if (user is null)
return NotFound(new ErrorResponse("User not found.")); return NotFound(new ErrorResponse("User not found."));
@@ -36,7 +44,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
return Unauthorized(new ErrorResponse("Authentication required.")); 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 ErrorResponse("User not found.")); return NotFound(new ErrorResponse("User not found."));
@@ -63,7 +71,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
user.NicknameColor = color.Length > 0 ? color : null; user.NicknameColor = color.Length > 0 ? color : null;
} }
await db.SaveChangesAsync(); await _db.SaveChangesAsync();
return Ok(ToProfileDto(user)); return Ok(ToProfileDto(user));
} }
@@ -77,7 +85,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
return Unauthorized(new ErrorResponse("Authentication required.")); 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 ErrorResponse("User not found.")); return NotFound(new ErrorResponse("User not found."));
@@ -95,10 +103,10 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
if (!FileValidationHelper.IsValidImage(stream)) if (!FileValidationHelper.IsValidImage(stream))
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP.")); 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 AvatarUploadResponse(asciiArt)); return Ok(new AvatarUploadResponse(asciiArt));
} }
+2 -1
View File
@@ -4,8 +4,9 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace EchoHub.Server.Data; namespace EchoHub.Server.Data;
public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbContext(options) public class EchoHubDbContext : DbContext
{ {
public EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : base(options) { }
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>();
+26 -17
View File
@@ -9,8 +9,17 @@ using Microsoft.AspNetCore.SignalR;
namespace EchoHub.Server.Hubs; namespace EchoHub.Server.Hubs;
[Authorize] [Authorize]
public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IEchoHubClient> public class ChatHub : Hub<IEchoHubClient>
{ {
private readonly IChatService _chatService;
private readonly ILogger<ChatHub> _logger;
public ChatHub(IChatService chatService, ILogger<ChatHub> logger)
{
_chatService = chatService;
_logger = logger;
}
private Guid CurrentUserId => private Guid CurrentUserId =>
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier) Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new HubException("User ID claim not found.")); ?? throw new HubException("User ID claim not found."));
@@ -23,12 +32,12 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
{ {
try try
{ {
await chatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername); await _chatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername);
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error in OnConnectedAsync for {ConnectionId}", Context.ConnectionId); _logger.LogError(ex, "Error in OnConnectedAsync for {ConnectionId}", Context.ConnectionId);
throw; throw;
} }
} }
@@ -37,12 +46,12 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
{ {
try try
{ {
await chatService.UserDisconnectedAsync(Context.ConnectionId); await _chatService.UserDisconnectedAsync(Context.ConnectionId);
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error in OnDisconnectedAsync for {ConnectionId}", Context.ConnectionId); _logger.LogError(ex, "Error in OnDisconnectedAsync for {ConnectionId}", Context.ConnectionId);
throw; throw;
} }
} }
@@ -51,7 +60,7 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
{ {
try try
{ {
var (history, error) = await chatService.JoinChannelAsync( var (history, error) = await _chatService.JoinChannelAsync(
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName); Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
if (error is not null) if (error is not null)
@@ -65,7 +74,7 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername); _logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
await Clients.Caller.Error($"Failed to join channel: {ex.Message}"); await Clients.Caller.Error($"Failed to join channel: {ex.Message}");
return []; return [];
} }
@@ -76,12 +85,12 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
try try
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
await chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName); await _chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName); await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error leaving channel '{Channel}' for {User}", channelName, CurrentUsername); _logger.LogError(ex, "Error leaving channel '{Channel}' for {User}", channelName, CurrentUsername);
await Clients.Caller.Error($"Failed to leave channel: {ex.Message}"); await Clients.Caller.Error($"Failed to leave channel: {ex.Message}");
} }
} }
@@ -90,13 +99,13 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
{ {
try try
{ {
var error = await chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content); var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
if (error is not null) if (error is not null)
await Clients.Caller.Error(error); await Clients.Caller.Error(error);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername); _logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername);
await Clients.Caller.Error($"Failed to send message: {ex.Message}"); await Clients.Caller.Error($"Failed to send message: {ex.Message}");
} }
} }
@@ -105,11 +114,11 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
{ {
try try
{ {
return await chatService.GetChannelHistoryAsync(channelName, count); return await _chatService.GetChannelHistoryAsync(channelName, count);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error fetching history for '{Channel}'", channelName); _logger.LogError(ex, "Error fetching history for '{Channel}'", channelName);
await Clients.Caller.Error($"Failed to load history: {ex.Message}"); await Clients.Caller.Error($"Failed to load history: {ex.Message}");
return []; return [];
} }
@@ -119,13 +128,13 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
{ {
try try
{ {
var error = await chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage); var error = await _chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage);
if (error is not null) if (error is not null)
await Clients.Caller.Error(error); await Clients.Caller.Error(error);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error updating status for {User}", CurrentUsername); _logger.LogError(ex, "Error updating status for {User}", CurrentUsername);
await Clients.Caller.Error($"Failed to update status: {ex.Message}"); await Clients.Caller.Error($"Failed to update status: {ex.Message}");
} }
} }
@@ -134,11 +143,11 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
{ {
try try
{ {
return await chatService.GetOnlineUsersAsync(channelName); return await _chatService.GetOnlineUsersAsync(channelName);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error listing users in '{Channel}'", channelName); _logger.LogError(ex, "Error listing users in '{Channel}'", channelName);
await Clients.Caller.Error($"Failed to list users: {ex.Message}"); await Clients.Caller.Error($"Failed to list users: {ex.Message}");
return []; return [];
} }
+44 -31
View File
@@ -9,17 +9,30 @@ using Microsoft.Extensions.Logging;
namespace EchoHub.Server.Services; namespace EchoHub.Server.Services;
public class ChatService( public class ChatService : IChatService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presenceTracker;
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly ILogger<ChatService> _logger;
public ChatService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker, PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters, IEnumerable<IChatBroadcaster> broadcasters,
ILogger<ChatService> logger) : IChatService ILogger<ChatService> logger)
{ {
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_broadcasters = broadcasters;
_logger = logger;
}
public async Task UserConnectedAsync(string connectionId, Guid userId, string username) public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
{ {
presenceTracker.UserConnected(connectionId, userId, username); _presenceTracker.UserConnected(connectionId, userId, username);
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(userId); var user = await db.Users.FindAsync(userId);
@@ -30,21 +43,21 @@ public class ChatService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId); _logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
} }
public async Task<string?> UserDisconnectedAsync(string connectionId) public async Task<string?> UserDisconnectedAsync(string connectionId)
{ {
var preDisconnectUsername = presenceTracker.GetUsernameForConnection(connectionId); var preDisconnectUsername = _presenceTracker.GetUsernameForConnection(connectionId);
var channelsBeforeDisconnect = preDisconnectUsername is not null var channelsBeforeDisconnect = preDisconnectUsername is not null
? presenceTracker.GetChannelsForUser(preDisconnectUsername) ? _presenceTracker.GetChannelsForUser(preDisconnectUsername)
: []; : [];
var username = presenceTracker.UserDisconnected(connectionId); var username = _presenceTracker.UserDisconnected(connectionId);
if (username is not null && !presenceTracker.IsOnline(username)) if (username is not null && !_presenceTracker.IsOnline(username))
{ {
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
@@ -65,7 +78,7 @@ public class ChatService(
} }
} }
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId); _logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
return username; return username;
} }
@@ -77,19 +90,19 @@ public class ChatService(
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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 ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
var isNewJoin = presenceTracker.JoinChannel(username, channelName); var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
if (isNewJoin) if (isNewJoin)
{ {
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId)); await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
} }
var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount); var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount);
@@ -99,9 +112,9 @@ public class ChatService(
public async Task LeaveChannelAsync(string connectionId, string username, string channelName) public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
presenceTracker.LeaveChannel(username, channelName); _presenceTracker.LeaveChannel(username, channelName);
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username)); await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
logger.LogInformation("{User} left channel '{Channel}'", username, channelName); _logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
} }
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content) public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
@@ -117,7 +130,7 @@ public class ChatService(
if (content.Length > HubConstants.MaxMessageLength) if (content.Length > HubConstants.MaxMessageLength)
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
@@ -153,7 +166,7 @@ public class ChatService(
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
logger.LogDebug("{User} sent message in '{Channel}'", username, channelName); _logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
return null; return null;
} }
@@ -162,7 +175,7 @@ public class ChatService(
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await GetChannelHistoryInternalAsync(db, channelName, count); return await GetChannelHistoryInternalAsync(db, channelName, count);
@@ -173,7 +186,7 @@ public class ChatService(
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength) if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters."; return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(userId); var user = await db.Users.FindAsync(userId);
@@ -192,7 +205,7 @@ public class ChatService(
status, status,
statusMessage); statusMessage);
var channels = presenceTracker.GetChannelsForUser(username); var channels = _presenceTracker.GetChannelsForUser(username);
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
return null; return null;
@@ -201,9 +214,9 @@ public class ChatService(
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName) public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName); var onlineUsernames = _presenceTracker.GetOnlineUsersInChannel(channelName);
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await db.Users return await db.Users
@@ -225,7 +238,7 @@ public class ChatService(
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action) private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
{ {
foreach (var broadcaster in broadcasters) foreach (var broadcaster in _broadcasters)
{ {
try try
{ {
@@ -233,7 +246,7 @@ public class ChatService(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name); _logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name);
} }
} }
} }
@@ -242,7 +255,7 @@ public class ChatService(
{ {
username = username.ToLowerInvariant(); username = username.ToLowerInvariant();
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
@@ -258,7 +271,7 @@ public class ChatService(
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
@@ -269,7 +282,7 @@ public class ChatService(
public async Task<List<ChannelListItem>> GetChannelListAsync() public async Task<List<ChannelListItem>> GetChannelListAsync()
{ {
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync(); var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
@@ -277,17 +290,17 @@ public class ChatService(
return channels.Select(c => new ChannelListItem( return channels.Select(c => new ChannelListItem(
c.Name, c.Name,
c.Topic, c.Topic,
presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
} }
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) public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
{ {
username = username.ToLowerInvariant(); username = username.ToLowerInvariant();
using var scope = scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
@@ -2,19 +2,30 @@ using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Server.Services; namespace EchoHub.Server.Services;
public sealed class ServerDirectoryService( public sealed class ServerDirectoryService : BackgroundService
IConfiguration configuration,
PresenceTracker presenceTracker,
ILogger<ServerDirectoryService> logger) : BackgroundService
{ {
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers"; private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30); private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2); private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30); private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
private readonly IConfiguration _configuration;
private readonly PresenceTracker _presenceTracker;
private readonly ILogger<ServerDirectoryService> _logger;
private HubConnection? _connection; private HubConnection? _connection;
private int _lastReportedUserCount = -1; private int _lastReportedUserCount = -1;
public ServerDirectoryService(
IConfiguration configuration,
PresenceTracker presenceTracker,
ILogger<ServerDirectoryService> logger)
{
_configuration = configuration;
_presenceTracker = presenceTracker;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync entered."); Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync entered.");
@@ -22,27 +33,27 @@ public sealed class ServerDirectoryService(
await Task.Yield(); await Task.Yield();
Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync resumed after Task.Yield()."); Console.Error.WriteLine("[DIAG] ServerDirectoryService.ExecuteAsync resumed after Task.Yield().");
var isPublic = configuration.GetValue<bool>("Server:PublicServer"); var isPublic = _configuration.GetValue<bool>("Server:PublicServer");
Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicServer={isPublic}"); Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicServer={isPublic}");
if (!isPublic) if (!isPublic)
{ {
logger.LogInformation("PublicServer is disabled — not registering with directory"); _logger.LogInformation("PublicServer is disabled — not registering with directory");
return; return;
} }
var host = configuration["Server:PublicHost"]; var host = _configuration["Server:PublicHost"];
Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicHost={host}"); Console.Error.WriteLine($"[DIAG] ServerDirectoryService: PublicHost={host}");
if (string.IsNullOrWhiteSpace(host)) if (string.IsNullOrWhiteSpace(host))
{ {
logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration"); _logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration");
return; return;
} }
var serverName = configuration["Server:Name"] ?? "EchoHub Server"; var serverName = _configuration["Server:Name"] ?? "EchoHub Server";
var description = configuration["Server:Description"]; var description = _configuration["Server:Description"];
logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host); _logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
// Outer loop: rebuilds the connection if automatic reconnect permanently fails // Outer loop: rebuilds the connection if automatic reconnect permanently fails
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
@@ -57,7 +68,7 @@ public sealed class ServerDirectoryService(
connection.Reconnected += async _ => connection.Reconnected += async _ =>
{ {
logger.LogInformation("Reconnected to directory — re-registering server"); _logger.LogInformation("Reconnected to directory — re-registering server");
_lastReportedUserCount = -1; _lastReportedUserCount = -1;
await RegisterAsync(serverName, description, host); await RegisterAsync(serverName, description, host);
}; };
@@ -65,9 +76,9 @@ public sealed class ServerDirectoryService(
connection.Closed += ex => connection.Closed += ex =>
{ {
if (ex is not null) if (ex is not null)
logger.LogWarning(ex, "Directory connection permanently closed — will rebuild"); _logger.LogWarning(ex, "Directory connection permanently closed — will rebuild");
else else
logger.LogWarning("Directory connection permanently closed — will rebuild"); _logger.LogWarning("Directory connection permanently closed — will rebuild");
connectionPermanentlyClosed.TrySetResult(); connectionPermanentlyClosed.TrySetResult();
return Task.CompletedTask; return Task.CompletedTask;
@@ -82,7 +93,7 @@ public sealed class ServerDirectoryService(
} }
Console.Error.WriteLine("[DIAG] ServerDirectoryService: Connected successfully!"); Console.Error.WriteLine("[DIAG] ServerDirectoryService: Connected successfully!");
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl); _logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
await RegisterAsync(serverName, description, host); await RegisterAsync(serverName, description, host);
// Poll user count until the connection is permanently closed or cancellation // Poll user count until the connection is permanently closed or cancellation
@@ -92,7 +103,7 @@ public sealed class ServerDirectoryService(
return; return;
// Connection was permanently closed — wait briefly then rebuild // Connection was permanently closed — wait briefly then rebuild
logger.LogInformation("Rebuilding directory connection..."); _logger.LogInformation("Rebuilding directory connection...");
await Task.Delay(ReconnectBaseDelay, stoppingToken); await Task.Delay(ReconnectBaseDelay, stoppingToken);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -129,7 +140,7 @@ public sealed class ServerDirectoryService(
{ {
attempt++; attempt++;
var delay = GetBackoffDelay(attempt); var delay = GetBackoffDelay(attempt);
logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds); _logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds);
await Task.Delay(delay, ct); await Task.Delay(delay, ct);
} }
} }
@@ -154,7 +165,7 @@ public sealed class ServerDirectoryService(
if (connection.State != HubConnectionState.Connected) if (connection.State != HubConnectionState.Connected)
continue; continue;
var currentCount = presenceTracker.GetOnlineUserCount(); var currentCount = _presenceTracker.GetOnlineUserCount();
if (currentCount == _lastReportedUserCount) if (currentCount == _lastReportedUserCount)
continue; continue;
@@ -162,11 +173,11 @@ public sealed class ServerDirectoryService(
{ {
await connection.InvokeAsync("UpdateUserCount", currentCount, ct); await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
_lastReportedUserCount = currentCount; _lastReportedUserCount = currentCount;
logger.LogDebug("Updated directory user count to {Count}", currentCount); _logger.LogDebug("Updated directory user count to {Count}", currentCount);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to update user count on directory"); _logger.LogWarning(ex, "Failed to update user count on directory");
} }
} }
} }
@@ -184,15 +195,15 @@ public sealed class ServerDirectoryService(
try try
{ {
var userCount = presenceTracker.GetOnlineUserCount(); var userCount = _presenceTracker.GetOnlineUserCount();
var dto = new RegisterServerDto(name, description, host, userCount); var dto = new RegisterServerDto(name, description, host, userCount);
await _connection.InvokeAsync("RegisterServer", dto); await _connection.InvokeAsync("RegisterServer", dto);
_lastReportedUserCount = userCount; _lastReportedUserCount = userCount;
logger.LogInformation("Registered with directory as {Name} at {Host}", name, host); _logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to register with directory"); _logger.LogWarning(ex, "Failed to register with directory");
} }
} }