feat: enhance profile editing with avatar support and UI adjustments

- Added AvatarPath to ProfileEditResult for user profile updates.
- Updated ProfileEditDialog to include avatar selection with a browse button.
- Increased dialog height to accommodate new avatar input fields.
- Implemented avatar file path handling in the profile edit dialog.

feat: introduce moderation features and user roles

- Added ServerRole enum to define user roles (Member, Mod, Admin, Owner).
- Extended User model to include role, mute, and ban status.
- Created ModerationController for user role assignment, kicking, banning, and muting.
- Implemented methods in IChatBroadcaster and SignalRBroadcaster for user moderation actions.
- Updated database schema with new columns for user roles and moderation states.

fix: ensure muted users cannot send messages

- Added mute status checks in ChatService to prevent message sending for muted users.
- Updated user presence and status handling to reflect role changes and moderation actions.

chore: update constants for ASCII art rendering

- Introduced AsciiArtHeightHalfBlock constant for improved ASCII art rendering.
This commit is contained in:
HueByte
2026-02-19 17:47:10 +01:00
parent 257ac34224
commit b4c3ebd254
28 changed files with 1457 additions and 73 deletions
@@ -0,0 +1,228 @@
using System.Security.Claims;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/moderation")]
[Authorize]
[EnableRateLimiting("general")]
public class ModerationController : ControllerBase
{
private readonly EchoHubDbContext _db;
private readonly IChatService _chatService;
private readonly PresenceTracker _presenceTracker;
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
public ModerationController(
EchoHubDbContext db,
IChatService chatService,
PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters)
{
_db = db;
_chatService = chatService;
_presenceTracker = presenceTracker;
_broadcasters = broadcasters;
}
[HttpPost("role")]
public async Task<IActionResult> AssignRole([FromBody] AssignRoleRequest request)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
if (request.Role == ServerRole.Owner)
return BadRequest(new ErrorResponse("Cannot assign the Owner role."));
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{request.Username}' not found."));
if (target.Role == ServerRole.Owner)
return BadRequest(new ErrorResponse("Cannot change the server owner's role."));
if (request.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
target.Role = request.Role;
await _db.SaveChangesAsync();
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
}
[HttpPost("kick/{username}")]
public async Task<IActionResult> KickUser(string username, [FromBody] KickRequest? request = null)
{
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher role."));
// Broadcast kick to all channels the user is in
var channels = _presenceTracker.GetChannelsForUser(target.Username);
foreach (var channel in channels)
{
await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason));
}
return Ok(new { Message = $"{target.Username} has been kicked." });
}
[HttpPost("ban/{username}")]
public async Task<IActionResult> BanUser(string username, [FromBody] BanRequest? request = null)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot ban a user with equal or higher role."));
target.IsBanned = true;
await _db.SaveChangesAsync();
await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason));
return Ok(new { Message = $"{target.Username} has been banned." });
}
[HttpPost("unban/{username}")]
public async Task<IActionResult> UnbanUser(string username)
{
var (_, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
target.IsBanned = false;
await _db.SaveChangesAsync();
return Ok(new { Message = $"{target.Username} has been unbanned." });
}
[HttpPost("mute/{username}")]
public async Task<IActionResult> MuteUser(string username, [FromBody] MuteRequest? request = null)
{
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot mute a user with equal or higher role."));
target.IsMuted = true;
target.MutedUntil = request?.DurationMinutes is > 0
? DateTimeOffset.UtcNow.AddMinutes(request.DurationMinutes.Value)
: null;
await _db.SaveChangesAsync();
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
}
[HttpPost("unmute/{username}")]
public async Task<IActionResult> UnmuteUser(string username)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
if (target is null)
return NotFound(new ErrorResponse($"User '{username}' not found."));
target.IsMuted = false;
target.MutedUntil = null;
await _db.SaveChangesAsync();
return Ok(new { Message = $"{target.Username} has been unmuted." });
}
[HttpDelete("messages/{messageId:guid}")]
public async Task<IActionResult> DeleteMessage(Guid messageId)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var message = await _db.Messages
.Include(m => m.Channel)
.FirstOrDefaultAsync(m => m.Id == messageId);
if (message is null)
return NotFound(new ErrorResponse("Message not found."));
var channelName = message.Channel!.Name;
_db.Messages.Remove(message);
await _db.SaveChangesAsync();
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
return Ok(new { Message = "Message deleted." });
}
[HttpDelete("channels/{channel}/nuke")]
public async Task<IActionResult> NukeChannel(string channel)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var channelName = channel.ToLowerInvariant().Trim();
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync();
_db.Messages.RemoveRange(messages);
await _db.SaveChangesAsync();
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
}
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
if (caller is null)
return (null, Unauthorized(new ErrorResponse("User not found.")));
if (caller.Role < minimumRole)
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
return (caller, null);
}
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
{
foreach (var broadcaster in _broadcasters)
{
try { await action(broadcaster); }
catch { /* logged by broadcaster */ }
}
}
}