diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs new file mode 100644 index 0000000..aa72b5c --- /dev/null +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -0,0 +1,219 @@ +using System.Collections; +using System.Collections.Specialized; +using System.Text; +using System.Text.RegularExpressions; +using Terminal.Gui.Drawing; +using Terminal.Gui.Views; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.UI; + +/// +/// A colored text segment within a chat line. +/// +public record ChatSegment(string Text, Attribute? Color); + +/// +/// A single line in the chat, composed of colored segments. +/// +public partial class ChatLine +{ + public List Segments { get; } + public int TextLength { get; } + + public ChatLine(string plainText) + { + Segments = [new ChatSegment(plainText, null)]; + TextLength = plainText.Length; + } + + public ChatLine(List segments) + { + Segments = segments; + TextLength = segments.Sum(s => s.Text.Length); + } + + public override string ToString() => string.Concat(Segments.Select(s => s.Text)); + + /// + /// Parse a string containing ANSI 24-bit color escape codes into colored segments. + /// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset) + /// + public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null) + { + var segments = new List(); + var regex = AnsiColorRegex(); + int lastIndex = 0; + Attribute? currentColor = defaultAttr; + + foreach (Match match in regex.Matches(ansiText)) + { + // Add any text before this escape sequence + if (match.Index > lastIndex) + { + var text = ansiText[lastIndex..match.Index]; + if (text.Length > 0) + segments.Add(new ChatSegment(text, currentColor)); + } + + // Parse the escape sequence + if (match.Groups[1].Value == "0") + { + // Reset + currentColor = defaultAttr; + } + else if (match.Groups[2].Success) + { + // 38;2;R;G;B — 24-bit foreground color + var r = int.Parse(match.Groups[3].Value); + var g = int.Parse(match.Groups[4].Value); + var b = int.Parse(match.Groups[5].Value); + currentColor = new Attribute(new Color(r, g, b), Color.Black); + } + + lastIndex = match.Index + match.Length; + } + + // Add remaining text + if (lastIndex < ansiText.Length) + { + var text = ansiText[lastIndex..]; + if (text.Length > 0) + segments.Add(new ChatSegment(text, currentColor)); + } + + return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); + } + + // Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground) + [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] + private static partial Regex AnsiColorRegex(); +} + +/// +/// Custom list data source for chat messages with per-character coloring. +/// +public class ChatListSource : IListDataSource +{ + private readonly List _lines = []; + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + public int Count => _lines.Count; + public int MaxItemLength { get; private set; } + public bool SuspendCollectionChangedEvent { get; set; } + + public void Add(ChatLine line) + { + _lines.Add(line); + UpdateMaxLength(line); + RaiseCollectionChanged(); + } + + public void AddRange(IEnumerable lines) + { + foreach (var line in lines) + { + _lines.Add(line); + UpdateMaxLength(line); + } + RaiseCollectionChanged(); + } + + public void InsertRange(int index, IEnumerable lines) + { + var items = lines.ToList(); + _lines.InsertRange(index, items); + foreach (var line in items) + UpdateMaxLength(line); + RaiseCollectionChanged(); + } + + public void Clear() + { + _lines.Clear(); + MaxItemLength = 0; + RaiseCollectionChanged(); + } + + public bool IsMarked(int item) => false; + public void SetMark(int item, bool value) { } + public IList ToList() => _lines.Select(l => l.ToString()).ToList(); + + public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0) + { + listView.Move(Math.Max(col - viewportX, 0), row); + + var chatLine = _lines[item]; + var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); + + int charPos = 0; + int drawnChars = 0; + + foreach (var segment in chatLine.Segments) + { + var attr = segment.Color ?? normalAttr; + listView.SetAttribute(attr); + + foreach (var ch in segment.Text) + { + if (charPos >= viewportX && drawnChars < width) + { + listView.AddRune(new Rune(ch)); + drawnChars++; + } + charPos++; + } + } + + // Fill remaining width with spaces using default colors + listView.SetAttribute(normalAttr); + while (drawnChars < width) + { + listView.AddRune(new Rune(' ')); + drawnChars++; + } + } + + private void UpdateMaxLength(ChatLine line) + { + if (line.TextLength > MaxItemLength) + MaxItemLength = line.TextLength; + } + + private void RaiseCollectionChanged() + { + if (!SuspendCollectionChangedEvent) + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + public void Dispose() { } +} + +/// +/// Helper to parse hex colors to Terminal.Gui Attributes. +/// +public static class ColorHelper +{ + public static Attribute? ParseHexColor(string? hex) + { + if (string.IsNullOrWhiteSpace(hex)) + return null; + + hex = hex.TrimStart('#'); + if (hex.Length != 6) + return null; + + try + { + var r = Convert.ToInt32(hex[..2], 16); + var g = Convert.ToInt32(hex[2..4], 16); + var b = Convert.ToInt32(hex[4..6], 16); + return new Attribute(new Color(r, g, b), Color.Black); + } + catch + { + return null; + } + } +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 26f0d29..7d8cce4 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -7,6 +7,7 @@ using Terminal.Gui.Configuration; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; +using Attribute = Terminal.Gui.Drawing.Attribute; namespace EchoHub.Client.UI; @@ -29,7 +30,7 @@ public sealed class MainWindow : Runnable private static readonly Key CtrlCKey = Key.C.WithCtrl; private readonly List _channelNames = []; - private readonly Dictionary> _channelMessages = []; + private readonly Dictionary> _channelMessages = []; private string _currentChannel = string.Empty; private string _currentUser = string.Empty; @@ -121,7 +122,7 @@ public sealed class MainWindow : Runnable Width = Dim.Fill(), Height = Dim.Fill() }; - _messageList.SetSource(new ObservableCollection(new List())); + _messageList.Source = new ChatListSource(); _chatFrame.Add(_messageList); Add(_chatFrame); @@ -341,7 +342,7 @@ public sealed class MainWindow : Runnable messages = []; _channelMessages[channelName] = messages; } - messages.Add(formatted); + messages.Add(new ChatLine(formatted)); if (channelName == _currentChannel) { @@ -360,7 +361,7 @@ public sealed class MainWindow : Runnable messages = []; _channelMessages[channelName] = messages; } - messages.Add(formatted); + messages.Add(new ChatLine(formatted)); if (channelName == _currentChannel) { @@ -454,6 +455,7 @@ public sealed class MainWindow : Runnable } } + /// /// Clear all messages and channels (used on disconnect). /// @@ -480,38 +482,44 @@ public sealed class MainWindow : Runnable { if (_channelMessages.TryGetValue(_currentChannel, out var messages)) { - _messageList.SetSource(new ObservableCollection(messages)); + var source = new ChatListSource(); + source.AddRange(messages); + _messageList.Source = source; if (messages.Count > 0) _messageList.SelectedItem = messages.Count - 1; } else { - _messageList.SetSource(new ObservableCollection(new List())); + _messageList.Source = new ChatListSource(); } } /// /// Format a message DTO into one or more display lines based on its MessageType. /// - private static List FormatMessage(MessageDto message) + private static List FormatMessage(MessageDto message) { var time = message.SentAt.ToLocalTime().ToString("HH:mm"); - var sender = message.SenderNicknameColor is not null - ? $"<{message.SenderUsername}>" - : message.SenderUsername + ":"; + var senderName = message.SenderUsername + ":"; + var senderColor = ColorHelper.ParseHexColor(message.SenderNicknameColor); - var lines = new List(); + var lines = new List(); switch (message.Type) { case MessageType.Image: - lines.Add($"[{time}] {sender} [Image]"); + lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, " [Image]")); // Content IS the ASCII art — add each line as a separate list item if (!string.IsNullOrWhiteSpace(message.Content)) { foreach (var artLine in message.Content.Split('\n')) { - lines.Add($" {artLine}"); + // Parse ANSI color codes from colored ASCII art + var trimmed = artLine.TrimEnd('\r'); + if (trimmed.Contains('\x1b')) + lines.Add(ChatLine.FromAnsi(" " + trimmed)); + else + lines.Add(new ChatLine($" {trimmed}")); } } break; @@ -519,21 +527,41 @@ public sealed class MainWindow : Runnable case MessageType.File: var fileName = message.AttachmentFileName ?? "unknown"; var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : ""; - lines.Add($"[{time}] {sender} [File: {fileName}]{fileContent}"); + lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, $" [File: {fileName}]{fileContent}")); break; case MessageType.Text: default: var contentLines = message.Content.Split('\n'); - lines.Add($"[{time}] {sender} {contentLines[0]}"); + var firstLine = contentLines[0].TrimEnd('\r'); + lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, $" {firstLine}")); // Continuation lines indented to align with first line's content + // Prefix is: [HH:mm] + space + senderName + space + var indent = new string(' ', $"[{time}] {senderName} ".Length); for (int i = 1; i < contentLines.Length; i++) { - lines.Add($" {contentLines[i]}"); + lines.Add(new ChatLine($"{indent}{contentLines[i].TrimEnd('\r')}")); } break; } return lines; } + + /// + /// Build a chat line with an optionally colored sender name. + /// + private static ChatLine BuildChatLine(string prefix, string senderName, Attribute? senderColor, string suffix) + { + if (senderColor is null) + return new ChatLine($"{prefix}{senderName}{suffix}"); + + var segments = new List + { + new(prefix, null), + new(senderName, senderColor.Value), + new(suffix, null) + }; + return new ChatLine(segments); + } } diff --git a/src/EchoHub.Server/Controllers/AuthController.cs b/src/EchoHub.Server/Controllers/AuthController.cs new file mode 100644 index 0000000..5af1ca4 --- /dev/null +++ b/src/EchoHub.Server/Controllers/AuthController.cs @@ -0,0 +1,66 @@ +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Auth; +using EchoHub.Server.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Controllers; + +[ApiController] +[Route("api/auth")] +public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase +{ + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest request) + { + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + return BadRequest(new { Error = "Username and password are required." }); + + if (request.Username.Length < 3 || request.Username.Length > 50) + return BadRequest(new { Error = "Username must be between 3 and 50 characters." }); + + if (request.Password.Length < 6) + return BadRequest(new { Error = "Password must be at least 6 characters." }); + + var normalizedUsername = request.Username.ToLowerInvariant().Trim(); + + if (await db.Users.AnyAsync(u => u.Username == normalizedUsername)) + return Conflict(new { Error = "Username is already taken." }); + + var user = new User + { + Id = Guid.NewGuid(), + Username = normalizedUsername, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), + DisplayName = request.DisplayName?.Trim(), + }; + + db.Users.Add(user); + await db.SaveChangesAsync(); + + var token = jwt.GenerateToken(user); + + return Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor)); + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + return BadRequest(new { Error = "Username and password are required." }); + + var normalizedUsername = request.Username.ToLowerInvariant().Trim(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + + if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) + return Unauthorized(); + + user.LastSeenAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + var token = jwt.GenerateToken(user); + + return Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor)); + } +} diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs new file mode 100644 index 0000000..037c4cd --- /dev/null +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -0,0 +1,110 @@ +using System.Security.Claims; +using EchoHub.Core.Constants; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Data; +using EchoHub.Server.Hubs; +using EchoHub.Server.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Controllers; + +[ApiController] +[Route("api/channels")] +[Authorize] +public class ChannelsController( + EchoHubDbContext db, + FileStorageService fileStorage, + ImageToAsciiService asciiService, + IHubContext hubContext) : ControllerBase +{ + [HttpGet] + public async Task GetChannels() + { + var channels = await db.Channels + .Select(c => new ChannelDto( + c.Id, + c.Name, + c.Topic, + c.Messages.Count, + c.CreatedAt)) + .ToListAsync(); + + return Ok(channels); + } + + [HttpPost("{channel}/upload")] + public async Task Upload(string channel) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + var usernameClaim = User.FindFirstValue("username"); + if (userIdClaim is null || usernameClaim is null) + return Unauthorized(); + + var userId = Guid.Parse(userIdClaim); + var channelName = channel.ToLowerInvariant().Trim(); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return NotFound(new { Error = $"Channel '{channelName}' does not exist." }); + + if (!Request.HasFormContentType || Request.Form.Files.Count == 0) + return BadRequest(new { Error = "No file uploaded." }); + + var file = Request.Form.Files[0]; + + if (file.Length > HubConstants.MaxFileSizeBytes) + return BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB." }); + + using var stream = file.OpenReadStream(); + var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName); + + var imageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; + var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); + var isImage = imageExtensions.Contains(extension); + + var messageType = isImage ? MessageType.Image : MessageType.File; + var content = isImage + ? asciiService.ConvertToAscii(System.IO.File.OpenRead(filePath)) + : file.FileName; + var attachmentUrl = $"/api/files/{fileId}"; + + var sender = await db.Users.FindAsync(userId); + + var message = new Message + { + Id = Guid.NewGuid(), + Content = content, + Type = messageType, + AttachmentUrl = attachmentUrl, + AttachmentFileName = file.FileName, + SentAt = DateTimeOffset.UtcNow, + ChannelId = dbChannel.Id, + SenderUserId = userId, + SenderUsername = usernameClaim, + }; + + db.Messages.Add(message); + await db.SaveChangesAsync(); + + var messageDto = new MessageDto( + message.Id, + message.Content, + message.SenderUsername, + sender?.NicknameColor, + channelName, + messageType, + attachmentUrl, + file.FileName, + message.SentAt); + + // Broadcast to all clients in the channel via SignalR + await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); + + return Ok(messageDto); + } +} diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs new file mode 100644 index 0000000..d60927c --- /dev/null +++ b/src/EchoHub.Server/Controllers/FilesController.cs @@ -0,0 +1,32 @@ +using EchoHub.Server.Services; +using Microsoft.AspNetCore.Mvc; + +namespace EchoHub.Server.Controllers; + +[ApiController] +[Route("api/files")] +public class FilesController(FileStorageService fileStorage) : ControllerBase +{ + [HttpGet("{fileId}")] + public IActionResult GetFile(string fileId) + { + var filePath = fileStorage.GetFilePath(fileId); + + if (filePath is null) + return NotFound(new { Error = "File not found." }); + + var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".pdf" => "application/pdf", + ".txt" => "text/plain", + _ => "application/octet-stream" + }; + + var fileName = Path.GetFileName(filePath); + return PhysicalFile(filePath, contentType, fileName); + } +} diff --git a/src/EchoHub.Server/Controllers/ServerController.cs b/src/EchoHub.Server/Controllers/ServerController.cs new file mode 100644 index 0000000..881babb --- /dev/null +++ b/src/EchoHub.Server/Controllers/ServerController.cs @@ -0,0 +1,26 @@ +using EchoHub.Core.DTOs; +using EchoHub.Server.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Controllers; + +[ApiController] +[Route("api/server")] +public class ServerController(EchoHubDbContext db, IConfiguration config) : ControllerBase +{ + [HttpGet("info")] + public async Task GetInfo() + { + var userCount = await db.Users.CountAsync(); + var channelCount = await db.Channels.CountAsync(); + + var status = new ServerStatusDto( + config["Server:Name"] ?? "EchoHub Server", + config["Server:Description"], + userCount, + channelCount); + + return Ok(status); + } +} diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs new file mode 100644 index 0000000..8a8e672 --- /dev/null +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -0,0 +1,98 @@ +using System.Security.Claims; +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +using EchoHub.Server.Data; +using EchoHub.Server.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Controllers; + +[ApiController] +[Route("api/users")] +public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase +{ + [HttpGet("{username}/profile")] + public async Task GetProfile(string username) + { + var normalizedUsername = username.ToLowerInvariant().Trim(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + + if (user is null) + return NotFound(new { Error = "User not found." }); + + return Ok(ToProfileDto(user)); + } + + [HttpPut("profile")] + [Authorize] + public async Task UpdateProfile([FromBody] UpdateProfileRequest request) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Unauthorized(); + + var userId = Guid.Parse(userIdClaim); + var user = await db.Users.FindAsync(userId); + + if (user is null) + return NotFound(new { Error = "User not found." }); + + if (request.DisplayName is not null) + user.DisplayName = request.DisplayName.Trim(); + + if (request.Bio is not null) + user.Bio = request.Bio.Trim(); + + if (request.NicknameColor is not null) + user.NicknameColor = request.NicknameColor.Trim(); + + await db.SaveChangesAsync(); + + return Ok(ToProfileDto(user)); + } + + [HttpPost("avatar")] + [Authorize] + public async Task UploadAvatar() + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Unauthorized(); + + var userId = Guid.Parse(userIdClaim); + var user = await db.Users.FindAsync(userId); + + if (user is null) + return NotFound(new { Error = "User not found." }); + + if (!Request.HasFormContentType || Request.Form.Files.Count == 0) + return BadRequest(new { Error = "No file uploaded." }); + + var file = Request.Form.Files[0]; + + if (file.Length > HubConstants.MaxAvatarSizeBytes) + return BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB." }); + + using var stream = file.OpenReadStream(); + var asciiArt = asciiService.ConvertToAscii(stream); + + user.AvatarAscii = asciiArt; + await db.SaveChangesAsync(); + + return Ok(new { AvatarAscii = asciiArt }); + } + + private static UserProfileDto ToProfileDto(Core.Models.User user) => new( + user.Id, + user.Username, + user.DisplayName, + user.Bio, + user.NicknameColor, + user.AvatarAscii, + user.Status, + user.StatusMessage, + user.CreatedAt, + user.LastSeenAt); +} diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 90ca6c3..c9d1009 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -1,7 +1,5 @@ -using System.Security.Claims; using System.Text; using EchoHub.Core.Constants; -using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Auth; using EchoHub.Server.Data; @@ -64,7 +62,8 @@ builder.Services.AddAuthentication(options => builder.Services.AddAuthorization(); -// ── SignalR ─────────────────────────────────────────────────────────────────── +// ── Controllers + SignalR ─────────────────────────────────────────────────────── +builder.Services.AddControllers(); builder.Services.AddSignalR(); // ── Services ────────────────────────────────────────────────────────────────── @@ -112,288 +111,8 @@ app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); -// ── Auth Endpoints ──────────────────────────────────────────────────────────── -var auth = app.MapGroup("/api/auth"); - -auth.MapPost("/register", async (RegisterRequest request, EchoHubDbContext db, JwtTokenService jwt) => -{ - if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) - { - return Results.BadRequest(new { Error = "Username and password are required." }); - } - - if (request.Username.Length < 3 || request.Username.Length > 50) - { - return Results.BadRequest(new { Error = "Username must be between 3 and 50 characters." }); - } - - if (request.Password.Length < 6) - { - return Results.BadRequest(new { Error = "Password must be at least 6 characters." }); - } - - var normalizedUsername = request.Username.ToLowerInvariant().Trim(); - - if (await db.Users.AnyAsync(u => u.Username == normalizedUsername)) - { - return Results.Conflict(new { Error = "Username is already taken." }); - } - - var user = new User - { - Id = Guid.NewGuid(), - Username = normalizedUsername, - PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), - DisplayName = request.DisplayName?.Trim(), - }; - - db.Users.Add(user); - await db.SaveChangesAsync(); - - var token = jwt.GenerateToken(user); - - return Results.Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor)); -}); - -auth.MapPost("/login", async (LoginRequest request, EchoHubDbContext db, JwtTokenService jwt) => -{ - if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) - { - return Results.BadRequest(new { Error = "Username and password are required." }); - } - - var normalizedUsername = request.Username.ToLowerInvariant().Trim(); - var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); - - if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) - { - return Results.Unauthorized(); - } - - user.LastSeenAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(); - - var token = jwt.GenerateToken(user); - - return Results.Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor)); -}); - -// ── Channel Endpoints ───────────────────────────────────────────────────────── -app.MapGet("/api/channels", async (EchoHubDbContext db) => -{ - var channels = await db.Channels - .Select(c => new ChannelDto( - c.Id, - c.Name, - c.Topic, - c.Messages.Count, - c.CreatedAt)) - .ToListAsync(); - - return Results.Ok(channels); -}) -.RequireAuthorization(); - -// ── Server Info Endpoint ────────────────────────────────────────────────────── -app.MapGet("/api/server/info", async (EchoHubDbContext db, IConfiguration config) => -{ - var userCount = await db.Users.CountAsync(); - var channelCount = await db.Channels.CountAsync(); - - var status = new ServerStatusDto( - config["Server:Name"] ?? "EchoHub Server", - config["Server:Description"], - userCount, - channelCount); - - return Results.Ok(status); -}); - -// ── User Profile Endpoints ──────────────────────────────────────────────────── -app.MapGet("/api/users/{username}/profile", async (string username, EchoHubDbContext db) => -{ - var normalizedUsername = username.ToLowerInvariant().Trim(); - var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); - - if (user is null) - { - return Results.NotFound(new { Error = "User not found." }); - } - - var profile = new UserProfileDto( - user.Id, - user.Username, - user.DisplayName, - user.Bio, - user.NicknameColor, - user.AvatarAscii, - user.Status, - user.StatusMessage, - user.CreatedAt, - user.LastSeenAt); - - return Results.Ok(profile); -}); - -app.MapPut("/api/users/profile", async (UpdateProfileRequest request, EchoHubDbContext db, HttpContext ctx) => -{ - var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); - if (userIdClaim is null) - return Results.Unauthorized(); - - var userId = Guid.Parse(userIdClaim); - var user = await db.Users.FindAsync(userId); - - if (user is null) - return Results.NotFound(new { Error = "User not found." }); - - if (request.DisplayName is not null) - user.DisplayName = request.DisplayName.Trim(); - - if (request.Bio is not null) - user.Bio = request.Bio.Trim(); - - if (request.NicknameColor is not null) - user.NicknameColor = request.NicknameColor.Trim(); - - await db.SaveChangesAsync(); - - var profile = new UserProfileDto( - user.Id, - user.Username, - user.DisplayName, - user.Bio, - user.NicknameColor, - user.AvatarAscii, - user.Status, - user.StatusMessage, - user.CreatedAt, - user.LastSeenAt); - - return Results.Ok(profile); -}) -.RequireAuthorization(); - -app.MapPost("/api/users/avatar", async (HttpRequest req, EchoHubDbContext db, ImageToAsciiService asciiService, HttpContext ctx) => -{ - var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); - if (userIdClaim is null) - return Results.Unauthorized(); - - var userId = Guid.Parse(userIdClaim); - var user = await db.Users.FindAsync(userId); - - if (user is null) - return Results.NotFound(new { Error = "User not found." }); - - if (!req.HasFormContentType || req.Form.Files.Count == 0) - return Results.BadRequest(new { Error = "No file uploaded." }); - - var file = req.Form.Files[0]; - - if (file.Length > HubConstants.MaxAvatarSizeBytes) - return Results.BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB." }); - - using var stream = file.OpenReadStream(); - var asciiArt = asciiService.ConvertToAscii(stream); - - user.AvatarAscii = asciiArt; - await db.SaveChangesAsync(); - - return Results.Ok(new { AvatarAscii = asciiArt }); -}) -.RequireAuthorization(); - -// ── File Upload Endpoints ──────────────────────────────────────────────────── -app.MapPost("/api/channels/{channel}/upload", async (string channel, HttpRequest req, EchoHubDbContext db, FileStorageService fileStorage, ImageToAsciiService asciiService, HttpContext ctx) => -{ - var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); - var usernameClaim = ctx.User.FindFirstValue("username"); - if (userIdClaim is null || usernameClaim is null) - return Results.Unauthorized(); - - var userId = Guid.Parse(userIdClaim); - var channelName = channel.ToLowerInvariant().Trim(); - - var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (dbChannel is null) - return Results.NotFound(new { Error = $"Channel '{channelName}' does not exist." }); - - if (!req.HasFormContentType || req.Form.Files.Count == 0) - return Results.BadRequest(new { Error = "No file uploaded." }); - - var file = req.Form.Files[0]; - - if (file.Length > HubConstants.MaxFileSizeBytes) - return Results.BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB." }); - - using var stream = file.OpenReadStream(); - var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName); - - var imageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; - var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); - var isImage = imageExtensions.Contains(extension); - - var messageType = isImage ? MessageType.Image : MessageType.File; - var content = isImage ? asciiService.ConvertToAscii(File.OpenRead(filePath)) : file.FileName; - var attachmentUrl = $"/api/files/{fileId}"; - - var sender = await db.Users.FindAsync(userId); - - var message = new Message - { - Id = Guid.NewGuid(), - Content = content, - Type = messageType, - AttachmentUrl = attachmentUrl, - AttachmentFileName = file.FileName, - SentAt = DateTimeOffset.UtcNow, - ChannelId = dbChannel.Id, - SenderUserId = userId, - SenderUsername = usernameClaim, - }; - - db.Messages.Add(message); - await db.SaveChangesAsync(); - - var messageDto = new MessageDto( - message.Id, - message.Content, - message.SenderUsername, - sender?.NicknameColor, - channelName, - messageType, - attachmentUrl, - file.FileName, - message.SentAt); - - return Results.Ok(messageDto); -}) -.RequireAuthorization(); - -app.MapGet("/api/files/{fileId}", (string fileId, FileStorageService fileStorage) => -{ - var filePath = fileStorage.GetFilePath(fileId); - - if (filePath is null) - return Results.NotFound(new { Error = "File not found." }); - - var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch - { - ".jpg" or ".jpeg" => "image/jpeg", - ".png" => "image/png", - ".gif" => "image/gif", - ".webp" => "image/webp", - ".pdf" => "application/pdf", - ".txt" => "text/plain", - _ => "application/octet-stream" - }; - - var fileName = Path.GetFileName(filePath); - return Results.File(filePath, contentType, fileName); -}); - -// ── SignalR Hub ─────────────────────────────────────────────────────────────── +// ── Routing ─────────────────────────────────────────────────────────────────── +app.MapControllers(); app.MapHub(HubConstants.ChatHubPath); app.Run(); diff --git a/src/EchoHub.Server/Services/ImageToAsciiService.cs b/src/EchoHub.Server/Services/ImageToAsciiService.cs index 7004fbe..921273d 100644 --- a/src/EchoHub.Server/Services/ImageToAsciiService.cs +++ b/src/EchoHub.Server/Services/ImageToAsciiService.cs @@ -18,6 +18,9 @@ public class ImageToAsciiService var sb = new StringBuilder(); + byte lastR = 0, lastG = 0, lastB = 0; + bool hasLastColor = false; + for (int y = 0; y < image.Height; y++) { for (int x = 0; x < image.Width; x++) @@ -25,11 +28,26 @@ public class ImageToAsciiService var pixel = image[x, y]; var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B; - // Map brightness (0-255) to ASCII char index (inverted: dark pixels get dense chars) + // Map brightness (0-255) to ASCII char index var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1)); + + // Emit ANSI 24-bit color only when it changes + if (!hasLastColor || pixel.R != lastR || pixel.G != lastG || pixel.B != lastB) + { + sb.Append($"\x1b[38;2;{pixel.R};{pixel.G};{pixel.B}m"); + lastR = pixel.R; + lastG = pixel.G; + lastB = pixel.B; + hasLastColor = true; + } + sb.Append(AsciiChars[index]); } + // Reset color at end of line + sb.Append("\x1b[0m"); + hasLastColor = false; + if (y < image.Height - 1) { sb.AppendLine();