mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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:
@@ -37,6 +37,7 @@ public class JwtTokenService
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new("username", user.Username),
|
||||
new("display_name", user.DisplayName ?? user.Username),
|
||||
new("role", user.Role.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
];
|
||||
|
||||
|
||||
@@ -42,12 +42,16 @@ public class AuthController : ControllerBase
|
||||
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
||||
return Conflict(new ErrorResponse("Username is already taken."));
|
||||
|
||||
// First registered user on the server becomes the Owner
|
||||
var isFirstUser = !await _db.Users.AnyAsync();
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = normalizedUsername,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||
DisplayName = request.DisplayName?.Trim(),
|
||||
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
|
||||
};
|
||||
|
||||
_db.Users.Add(user);
|
||||
@@ -80,6 +84,9 @@ public class AuthController : ControllerBase
|
||||
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||
return Unauthorized(new ErrorResponse("Invalid username or password."));
|
||||
|
||||
if (user.IsBanned)
|
||||
return Unauthorized(new ErrorResponse("Your account has been banned."));
|
||||
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -141,8 +141,10 @@ public class ChannelsController : ControllerBase
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
||||
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
var caller = await _db.Users.FindAsync(userId);
|
||||
if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin))
|
||||
return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel."));
|
||||
|
||||
_db.Channels.Remove(dbChannel);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ public class UsersController : ControllerBase
|
||||
user.AvatarAscii,
|
||||
user.Status,
|
||||
user.StatusMessage,
|
||||
user.Role,
|
||||
user.CreatedAt,
|
||||
user.LastSeenAt);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public class EchoHubDbContext : DbContext
|
||||
entity.Property(u => u.NicknameColor).HasMaxLength(7);
|
||||
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
|
||||
entity.Property(u => u.StatusMessage).HasMaxLength(100);
|
||||
entity.Property(u => u.Role).HasConversion<int>();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Channel>(entity =>
|
||||
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EchoHub.Server.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EchoHubDbContext))]
|
||||
[Migration("20260219162414_AddModerationRoles")]
|
||||
partial class AddModerationRoles
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AttachmentFileName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AttachmentUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SenderUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SenderUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SentAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
b.HasIndex("SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarAscii")
|
||||
.HasMaxLength(10000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsMuted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("MutedUntil")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NicknameColor")
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StatusMessage")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Channel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EchoHub.Server.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddModerationRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsBanned",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsMuted",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MutedUntil",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Role",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsBanned",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsMuted",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MutedUntil",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Role",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,9 +144,18 @@ namespace EchoHub.Server.Data.Migrations
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsMuted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("MutedUntil")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NicknameColor")
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -155,6 +164,9 @@ namespace EchoHub.Server.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -72,7 +72,8 @@ public class ChatService : IChatService
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
UserStatus.Invisible,
|
||||
user.StatusMessage);
|
||||
user.StatusMessage,
|
||||
user.Role);
|
||||
|
||||
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
|
||||
}
|
||||
@@ -139,6 +140,21 @@ public class ChatService : IChatService
|
||||
|
||||
var sender = await db.Users.FindAsync(userId);
|
||||
|
||||
// Check mute status
|
||||
if (sender is not null && sender.IsMuted)
|
||||
{
|
||||
if (sender.MutedUntil.HasValue && sender.MutedUntil.Value <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
sender.IsMuted = false;
|
||||
sender.MutedUntil = null;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
return "You are muted and cannot send messages.";
|
||||
}
|
||||
}
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -203,7 +219,8 @@ public class ChatService : IChatService
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
status,
|
||||
statusMessage);
|
||||
statusMessage,
|
||||
user.Role);
|
||||
|
||||
var channels = _presenceTracker.GetChannelsForUser(username);
|
||||
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
||||
@@ -226,7 +243,8 @@ public class ChatService : IChatService
|
||||
u.DisplayName,
|
||||
u.NicknameColor,
|
||||
u.Status,
|
||||
u.StatusMessage))
|
||||
u.StatusMessage,
|
||||
u.Role))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -264,7 +282,7 @@ public class ChatService : IChatService
|
||||
return new UserProfileDto(
|
||||
user.Id, user.Username, user.DisplayName, user.Bio,
|
||||
user.NicknameColor, user.AvatarAscii, user.Status,
|
||||
user.StatusMessage, user.CreatedAt, user.LastSeenAt);
|
||||
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
|
||||
}
|
||||
|
||||
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
|
||||
|
||||
@@ -8,47 +8,72 @@ namespace EchoHub.Server.Services;
|
||||
|
||||
public class ImageToAsciiService
|
||||
{
|
||||
private static readonly char[] AsciiChars = " .:-=+*#%@".ToCharArray();
|
||||
|
||||
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeight)
|
||||
/// <summary>
|
||||
/// Converts an image to ASCII art using half-block characters (▀▄█) with
|
||||
/// 24-bit ANSI foreground and background colors for 2x vertical resolution.
|
||||
/// Each character cell represents two vertical pixels.
|
||||
/// </summary>
|
||||
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock)
|
||||
{
|
||||
using var image = Image.Load<Rgba32>(imageStream);
|
||||
|
||||
// Ensure height is even for pair processing
|
||||
if (height % 2 != 0) height++;
|
||||
|
||||
image.Mutate(x => x.Resize(width, height));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
byte lastR = 0, lastG = 0, lastB = 0;
|
||||
bool hasLastColor = false;
|
||||
|
||||
for (int y = 0; y < image.Height; y++)
|
||||
for (int y = 0; y < image.Height; y += 2)
|
||||
{
|
||||
byte lastFgR = 0, lastFgG = 0, lastFgB = 0;
|
||||
byte lastBgR = 0, lastBgG = 0, lastBgB = 0;
|
||||
bool hasLastColor = false;
|
||||
|
||||
for (int x = 0; x < image.Width; x++)
|
||||
{
|
||||
var pixel = image[x, y];
|
||||
var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B;
|
||||
var topPixel = image[x, y];
|
||||
var bottomPixel = (y + 1 < image.Height) ? image[x, y + 1] : topPixel;
|
||||
|
||||
// Map brightness (0-255) to ASCII char index
|
||||
var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1));
|
||||
byte fgR, fgG, fgB, bgR, bgG, bgB;
|
||||
char blockChar;
|
||||
|
||||
// Emit ANSI 24-bit color only when it changes
|
||||
if (!hasLastColor || pixel.R != lastR || pixel.G != lastG || pixel.B != lastB)
|
||||
if (topPixel.R == bottomPixel.R && topPixel.G == bottomPixel.G && topPixel.B == bottomPixel.B)
|
||||
{
|
||||
sb.Append($"\x1b[38;2;{pixel.R};{pixel.G};{pixel.B}m");
|
||||
lastR = pixel.R;
|
||||
lastG = pixel.G;
|
||||
lastB = pixel.B;
|
||||
hasLastColor = true;
|
||||
// Both pixels same color — full block
|
||||
fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
|
||||
bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B;
|
||||
blockChar = '\u2588'; // █
|
||||
}
|
||||
else
|
||||
{
|
||||
// Top pixel = foreground, bottom pixel = background, upper half block
|
||||
fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
|
||||
bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B;
|
||||
blockChar = '\u2580'; // ▀
|
||||
}
|
||||
|
||||
sb.Append(AsciiChars[index]);
|
||||
// Emit color codes only when they change
|
||||
bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB;
|
||||
bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB;
|
||||
|
||||
if (fgChanged)
|
||||
sb.Append($"\x1b[38;2;{fgR};{fgG};{fgB}m");
|
||||
if (bgChanged)
|
||||
sb.Append($"\x1b[48;2;{bgR};{bgG};{bgB}m");
|
||||
|
||||
sb.Append(blockChar);
|
||||
|
||||
lastFgR = fgR; lastFgG = fgG; lastFgB = fgB;
|
||||
lastBgR = bgR; lastBgG = bgG; lastBgB = bgB;
|
||||
hasLastColor = true;
|
||||
}
|
||||
|
||||
// Reset color at end of line
|
||||
sb.Append("\x1b[0m");
|
||||
hasLastColor = false;
|
||||
|
||||
if (y < image.Height - 1)
|
||||
if (y + 2 < image.Height)
|
||||
{
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
@@ -54,6 +54,18 @@ public class SignalRBroadcaster : IChatBroadcaster
|
||||
return HubContext.Clients.Clients(connections).UserStatusChanged(presence);
|
||||
}
|
||||
|
||||
public Task SendUserKickedAsync(string channelName, string username, string? reason)
|
||||
=> HubContext.Clients.Group(channelName).UserKicked(channelName, username, reason);
|
||||
|
||||
public Task SendUserBannedAsync(string username, string? reason)
|
||||
=> HubContext.Clients.All.UserBanned(username, reason);
|
||||
|
||||
public Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
||||
=> HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId);
|
||||
|
||||
public Task SendChannelNukedAsync(string channelName)
|
||||
=> HubContext.Clients.Group(channelName).ChannelNuked(channelName);
|
||||
|
||||
public Task SendErrorAsync(string connectionId, string message)
|
||||
{
|
||||
if (connectionId.StartsWith("irc-"))
|
||||
|
||||
Reference in New Issue
Block a user