mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 23:34:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159a28e890 | ||
|
|
328821cd14 | ||
|
|
46a30c6b80 | ||
|
|
8dfdc163bf | ||
|
|
13297fd017 | ||
|
|
0422066851 | ||
|
|
cf5ef80c08 | ||
|
|
c8e33c96ce | ||
|
|
2a6dbb4461 | ||
|
|
caba400f43 | ||
|
|
552fe6afa3 | ||
|
|
87e8df4ec5 | ||
|
|
4c192717de | ||
|
|
1bf32450fd |
@@ -4,6 +4,8 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes
|
||||||
|
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
|
||||||
- [v0.2.0](v0.2.0.md) - IRC Gateway
|
- [v0.2.0](v0.2.0.md) - IRC Gateway
|
||||||
- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing
|
- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing
|
||||||
- [v0.1.0](v0.1.0.md) - Initial Release
|
- [v0.1.0](v0.1.0.md) - Initial Release
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.2
|
||||||
|
href: v0.2.2.md
|
||||||
|
- name: v0.2.1
|
||||||
|
href: v0.2.1.md
|
||||||
- name: v0.2.0
|
- name: v0.2.0
|
||||||
href: v0.2.0.md
|
href: v0.2.0.md
|
||||||
- name: v0.1.1
|
- name: v0.1.1
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# v0.2.1 - Shutdown & CI Fixes
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed server hanging on Ctrl+C when the directory server is unreachable — `StopAsync` now cancels background services before disposing connections
|
||||||
|
- Fixed IRC gateway shutdown blocking indefinitely on unresponsive clients — send operations are now bounded to 2 seconds
|
||||||
|
- Fixed CI release workflow not having full git history for building release notes (`fetch-depth: 0`)
|
||||||
|
- Fixed `workflow_dispatch` trigger breaking change detection when `github.event.before` is empty
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- GitHub releases now include a commit list and version diff link instead of generic auto-generated notes
|
||||||
|
- GitHub releases link to the full changelog on the docs site
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# v0.2.2 - Startup & Shutdown Fixes
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed server hanging on startup when IRC gateway is enabled — circular DI dependency between `IrcGatewayService` → `IChatService` → `IChatBroadcaster` → `IrcBroadcaster` caused the DI container to deadlock
|
||||||
|
- Fixed `SignalRBroadcaster` eagerly resolving `IHubContext<ChatHub>` during DI construction, which could deadlock on some platforms — now lazy-resolves via `IServiceProvider` on first use
|
||||||
|
- Simplified IRC service registration to use standard `AddSingleton<IChatBroadcaster, IrcBroadcaster>` instead of manual factory, breaking the circular resolution chain
|
||||||
|
- Fixed server hanging on Ctrl+C — replaced `await using` with explicit dispose bounded to 3 seconds, so a stuck `HubConnection` can no longer block shutdown
|
||||||
|
- Reduced host shutdown timeout from 30s (default) to 5s
|
||||||
|
- Caught `OperationCanceledException` in the directory service reconnect loop so cancellation exits immediately instead of propagating through dispose
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Replaced primary constructors with standard constructor injection across all server classes for consistency
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.1.1</Version>
|
<Version>0.2.2</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Net.Security;
|
|||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -13,7 +14,7 @@ namespace EchoHub.Server.Irc;
|
|||||||
public sealed class IrcGatewayService : BackgroundService
|
public sealed class IrcGatewayService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IrcOptions _options;
|
private readonly IrcOptions _options;
|
||||||
private readonly IChatService _chatService;
|
private readonly IServiceProvider _services;
|
||||||
private readonly ILogger<IrcGatewayService> _logger;
|
private readonly ILogger<IrcGatewayService> _logger;
|
||||||
private readonly ConcurrentDictionary<string, IrcClientConnection> _connections = new();
|
private readonly ConcurrentDictionary<string, IrcClientConnection> _connections = new();
|
||||||
|
|
||||||
@@ -22,11 +23,11 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
public IrcGatewayService(
|
public IrcGatewayService(
|
||||||
IOptions<IrcOptions> options,
|
IOptions<IrcOptions> options,
|
||||||
IChatService chatService,
|
IServiceProvider services,
|
||||||
ILogger<IrcGatewayService> logger)
|
ILogger<IrcGatewayService> logger)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_chatService = chatService;
|
_services = services;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +110,13 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
|
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
|
||||||
|
|
||||||
|
IChatService? chatService = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
chatService = _services.GetRequiredService<IChatService>();
|
||||||
var handler = new IrcCommandHandler(
|
var handler = new IrcCommandHandler(
|
||||||
connection, _options, _chatService, _logger);
|
connection, _options, chatService, _logger);
|
||||||
|
|
||||||
await handler.RunAsync(ct);
|
await handler.RunAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -126,10 +130,12 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
{
|
{
|
||||||
foreach (var ch in connection.JoinedChannels.ToList())
|
foreach (var ch in connection.JoinedChannels.ToList())
|
||||||
{
|
{
|
||||||
await _chatService.LeaveChannelAsync(
|
if (chatService is null) break;
|
||||||
|
await chatService.LeaveChannelAsync(
|
||||||
connection.ConnectionId, connection.Nickname!, ch);
|
connection.ConnectionId, connection.Nickname!, ch);
|
||||||
}
|
}
|
||||||
await _chatService.UserDisconnectedAsync(connection.ConnectionId);
|
if (chatService is not null)
|
||||||
|
await chatService.UserDisconnectedAsync(connection.ConnectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
_connections.TryRemove(connection.ConnectionId, out _);
|
_connections.TryRemove(connection.ConnectionId, out _);
|
||||||
@@ -141,17 +147,24 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Cancel ExecuteAsync first so listeners stop accepting
|
||||||
|
await base.StopAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Force-close any remaining client connections
|
||||||
foreach (var (_, conn) in _connections)
|
foreach (var (_, conn) in _connections)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await conn.SendAsync("ERROR :Server shutting down");
|
await conn.SendAsync("ERROR :Server shutting down")
|
||||||
await conn.DisposeAsync();
|
.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { await conn.DisposeAsync(); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_connections.Clear();
|
_connections.Clear();
|
||||||
|
|
||||||
await base.StopAsync(cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ public static class IrcServiceExtensions
|
|||||||
if (builder.Configuration.GetValue<bool>("Irc:Enabled"))
|
if (builder.Configuration.GetValue<bool>("Irc:Enabled"))
|
||||||
{
|
{
|
||||||
builder.Services.AddSingleton<IrcGatewayService>();
|
builder.Services.AddSingleton<IrcGatewayService>();
|
||||||
builder.Services.AddSingleton<IChatBroadcaster>(sp =>
|
builder.Services.AddSingleton<IChatBroadcaster, IrcBroadcaster>();
|
||||||
new IrcBroadcaster(sp.GetRequiredService<IrcGatewayService>()));
|
|
||||||
builder.Services.AddHostedService(sp =>
|
builder.Services.AddHostedService(sp =>
|
||||||
sp.GetRequiredService<IrcGatewayService>());
|
sp.GetRequiredService<IrcGatewayService>());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
EchoHubDbContext db,
|
|
||||||
FileStorageService fileStorage,
|
|
||||||
ImageToAsciiService asciiService,
|
|
||||||
IHttpClientFactory httpClientFactory,
|
|
||||||
IChatService chatService) : 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,
|
||||||
|
FileStorageService fileStorage,
|
||||||
|
ImageToAsciiService asciiService,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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 [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ while (true)
|
|||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ── Host options ────────────────────────────────────────────────────
|
||||||
|
builder.Services.Configure<HostOptions>(options =>
|
||||||
|
options.ShutdownTimeout = TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
// ── Serilog ──────────────────────────────────────────────────────────
|
// ── Serilog ──────────────────────────────────────────────────────────
|
||||||
builder.Host.UseSerilog((context, config) =>
|
builder.Host.UseSerilog((context, config) =>
|
||||||
config.ReadFrom.Configuration(context.Configuration));
|
config.ReadFrom.Configuration(context.Configuration));
|
||||||
|
|||||||
@@ -9,17 +9,30 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
public class ChatService(
|
public class ChatService : IChatService
|
||||||
IServiceScopeFactory scopeFactory,
|
|
||||||
PresenceTracker presenceTracker,
|
|
||||||
IEnumerable<IChatBroadcaster> broadcasters,
|
|
||||||
ILogger<ChatService> logger) : IChatService
|
|
||||||
{
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
|
public ChatService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
PresenceTracker presenceTracker,
|
||||||
|
IEnumerable<IChatBroadcaster> broadcasters,
|
||||||
|
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,87 +2,109 @@ 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)
|
||||||
{
|
{
|
||||||
// Yield to let the host finish starting before we log or connect
|
// Yield to let the host finish starting before we log or connect
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
|
|
||||||
var isPublic = configuration.GetValue<bool>("Server:PublicServer");
|
var isPublic = _configuration.GetValue<bool>("Server:PublicServer");
|
||||||
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"];
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
await using var connection = BuildConnection();
|
var connection = BuildConnection();
|
||||||
_connection = connection;
|
_connection = connection;
|
||||||
|
|
||||||
var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
try
|
||||||
|
|
||||||
connection.Reconnected += async _ =>
|
|
||||||
{
|
{
|
||||||
logger.LogInformation("Reconnected to directory — re-registering server");
|
var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
_lastReportedUserCount = -1;
|
|
||||||
|
connection.Reconnected += async _ =>
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Reconnected to directory — re-registering server");
|
||||||
|
_lastReportedUserCount = -1;
|
||||||
|
await RegisterAsync(serverName, description, host);
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.Closed += ex =>
|
||||||
|
{
|
||||||
|
if (ex is not null)
|
||||||
|
_logger.LogWarning(ex, "Directory connection permanently closed — will rebuild");
|
||||||
|
else
|
||||||
|
_logger.LogWarning("Directory connection permanently closed — will rebuild");
|
||||||
|
|
||||||
|
connectionPermanentlyClosed.TrySetResult();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect with retry
|
||||||
|
if (!await ConnectWithRetryAsync(connection, stoppingToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
||||||
await RegisterAsync(serverName, description, host);
|
await RegisterAsync(serverName, description, host);
|
||||||
};
|
|
||||||
|
|
||||||
connection.Closed += ex =>
|
// Poll user count until the connection is permanently closed or cancellation
|
||||||
|
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
||||||
|
|
||||||
|
if (stoppingToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Connection was permanently closed — wait briefly then rebuild
|
||||||
|
_logger.LogInformation("Rebuilding directory connection...");
|
||||||
|
await Task.Delay(ReconnectBaseDelay, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
if (ex is not null)
|
|
||||||
logger.LogWarning(ex, "Directory connection permanently closed — will rebuild");
|
|
||||||
else
|
|
||||||
logger.LogWarning("Directory connection permanently closed — will rebuild");
|
|
||||||
|
|
||||||
connectionPermanentlyClosed.TrySetResult();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Connect with retry
|
|
||||||
if (!await ConnectWithRetryAsync(connection, stoppingToken))
|
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
finally
|
||||||
await RegisterAsync(serverName, description, host);
|
{
|
||||||
|
_connection = null;
|
||||||
// Poll user count until the connection is permanently closed or cancellation
|
await DisposeConnectionAsync(connection);
|
||||||
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
}
|
||||||
|
|
||||||
if (stoppingToken.IsCancellationRequested)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Connection was permanently closed — wait briefly then rebuild
|
|
||||||
_connection = null;
|
|
||||||
logger.LogInformation("Rebuilding directory connection...");
|
|
||||||
await Task.Delay(ReconnectBaseDelay, stoppingToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +130,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +155,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;
|
||||||
|
|
||||||
@@ -141,11 +163,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,27 +185,35 @@ 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DisposeConnectionAsync(HubConnection connection)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.DisposeAsync()
|
||||||
|
.AsTask().WaitAsync(TimeSpan.FromSeconds(3));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Don't let a slow dispose block shutdown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_connection is not null)
|
|
||||||
{
|
|
||||||
await _connection.DisposeAsync();
|
|
||||||
_connection = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await base.StopAsync(cancellationToken);
|
await base.StopAsync(cancellationToken);
|
||||||
|
_connection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -5,42 +5,53 @@ using Microsoft.AspNetCore.SignalR;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
public class SignalRBroadcaster(
|
public class SignalRBroadcaster : IChatBroadcaster
|
||||||
IHubContext<ChatHub, IEchoHubClient> hubContext,
|
|
||||||
PresenceTracker presenceTracker) : IChatBroadcaster
|
|
||||||
{
|
{
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private IHubContext<ChatHub, IEchoHubClient>? _hubContext;
|
||||||
|
|
||||||
|
private IHubContext<ChatHub, IEchoHubClient> HubContext
|
||||||
|
=> _hubContext ??= _serviceProvider.GetRequiredService<IHubContext<ChatHub, IEchoHubClient>>();
|
||||||
|
|
||||||
|
public SignalRBroadcaster(IServiceProvider serviceProvider, PresenceTracker presenceTracker)
|
||||||
|
{
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
}
|
||||||
|
|
||||||
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
=> hubContext.Clients.Group(channelName).ReceiveMessage(message);
|
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
||||||
|
|
||||||
public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
|
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
|
||||||
return hubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username);
|
return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username);
|
||||||
|
|
||||||
return hubContext.Clients.Group(channelName).UserJoined(channelName, username);
|
return HubContext.Clients.Group(channelName).UserJoined(channelName, username);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendUserLeftAsync(string channelName, string username)
|
public Task SendUserLeftAsync(string channelName, string username)
|
||||||
=> hubContext.Clients.Group(channelName).UserLeft(channelName, username);
|
=> HubContext.Clients.Group(channelName).UserLeft(channelName, username);
|
||||||
|
|
||||||
public Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
|
public Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
|
||||||
{
|
{
|
||||||
if (channelName is not null)
|
if (channelName is not null)
|
||||||
return hubContext.Clients.Group(channelName).ChannelUpdated(channel);
|
return HubContext.Clients.Group(channelName).ChannelUpdated(channel);
|
||||||
|
|
||||||
return hubContext.Clients.All.ChannelUpdated(channel);
|
return HubContext.Clients.All.ChannelUpdated(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence)
|
public Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence)
|
||||||
{
|
{
|
||||||
var connections = presenceTracker.GetConnectionsInChannels(channelNames)
|
var connections = _presenceTracker.GetConnectionsInChannels(channelNames)
|
||||||
.Where(c => !c.StartsWith("irc-"))
|
.Where(c => !c.StartsWith("irc-"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (connections.Count == 0)
|
if (connections.Count == 0)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return hubContext.Clients.Clients(connections).UserStatusChanged(presence);
|
return HubContext.Clients.Clients(connections).UserStatusChanged(presence);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendErrorAsync(string connectionId, string message)
|
public Task SendErrorAsync(string connectionId, string message)
|
||||||
@@ -48,6 +59,6 @@ public class SignalRBroadcaster(
|
|||||||
if (connectionId.StartsWith("irc-"))
|
if (connectionId.StartsWith("irc-"))
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return hubContext.Clients.Client(connectionId).Error(message);
|
return HubContext.Clients.Client(connectionId).Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user