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();