mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Implement chat rendering and message handling with colored segments
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A colored text segment within a chat line.
|
||||||
|
/// </summary>
|
||||||
|
public record ChatSegment(string Text, Attribute? Color);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single line in the chat, composed of colored segments.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ChatLine
|
||||||
|
{
|
||||||
|
public List<ChatSegment> Segments { get; }
|
||||||
|
public int TextLength { get; }
|
||||||
|
|
||||||
|
public ChatLine(string plainText)
|
||||||
|
{
|
||||||
|
Segments = [new ChatSegment(plainText, null)];
|
||||||
|
TextLength = plainText.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatLine(List<ChatSegment> segments)
|
||||||
|
{
|
||||||
|
Segments = segments;
|
||||||
|
TextLength = segments.Sum(s => s.Text.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
|
/// </summary>
|
||||||
|
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for chat messages with per-character coloring.
|
||||||
|
/// </summary>
|
||||||
|
public class ChatListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<ChatLine> _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<ChatLine> lines)
|
||||||
|
{
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
_lines.Add(line);
|
||||||
|
UpdateMaxLength(line);
|
||||||
|
}
|
||||||
|
RaiseCollectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void InsertRange(int index, IEnumerable<ChatLine> 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() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to parse hex colors to Terminal.Gui Attributes.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ using Terminal.Gui.Configuration;
|
|||||||
using Terminal.Gui.Input;
|
using Terminal.Gui.Input;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ public sealed class MainWindow : Runnable
|
|||||||
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
||||||
|
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, List<string>> _channelMessages = [];
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
private string _currentChannel = string.Empty;
|
private string _currentChannel = string.Empty;
|
||||||
private string _currentUser = string.Empty;
|
private string _currentUser = string.Empty;
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ public sealed class MainWindow : Runnable
|
|||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(),
|
||||||
Height = Dim.Fill()
|
Height = Dim.Fill()
|
||||||
};
|
};
|
||||||
_messageList.SetSource(new ObservableCollection<string>(new List<string>()));
|
_messageList.Source = new ChatListSource();
|
||||||
_chatFrame.Add(_messageList);
|
_chatFrame.Add(_messageList);
|
||||||
Add(_chatFrame);
|
Add(_chatFrame);
|
||||||
|
|
||||||
@@ -341,7 +342,7 @@ public sealed class MainWindow : Runnable
|
|||||||
messages = [];
|
messages = [];
|
||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
messages.Add(formatted);
|
messages.Add(new ChatLine(formatted));
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
{
|
{
|
||||||
@@ -360,7 +361,7 @@ public sealed class MainWindow : Runnable
|
|||||||
messages = [];
|
messages = [];
|
||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
messages.Add(formatted);
|
messages.Add(new ChatLine(formatted));
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
{
|
{
|
||||||
@@ -454,6 +455,7 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clear all messages and channels (used on disconnect).
|
/// Clear all messages and channels (used on disconnect).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -480,38 +482,44 @@ public sealed class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
if (_channelMessages.TryGetValue(_currentChannel, out var messages))
|
if (_channelMessages.TryGetValue(_currentChannel, out var messages))
|
||||||
{
|
{
|
||||||
_messageList.SetSource(new ObservableCollection<string>(messages));
|
var source = new ChatListSource();
|
||||||
|
source.AddRange(messages);
|
||||||
|
_messageList.Source = source;
|
||||||
if (messages.Count > 0)
|
if (messages.Count > 0)
|
||||||
_messageList.SelectedItem = messages.Count - 1;
|
_messageList.SelectedItem = messages.Count - 1;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_messageList.SetSource(new ObservableCollection<string>(new List<string>()));
|
_messageList.Source = new ChatListSource();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a message DTO into one or more display lines based on its MessageType.
|
/// Format a message DTO into one or more display lines based on its MessageType.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<string> FormatMessage(MessageDto message)
|
private static List<ChatLine> FormatMessage(MessageDto message)
|
||||||
{
|
{
|
||||||
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||||
var sender = message.SenderNicknameColor is not null
|
var senderName = message.SenderUsername + ":";
|
||||||
? $"<{message.SenderUsername}>"
|
var senderColor = ColorHelper.ParseHexColor(message.SenderNicknameColor);
|
||||||
: message.SenderUsername + ":";
|
|
||||||
|
|
||||||
var lines = new List<string>();
|
var lines = new List<ChatLine>();
|
||||||
|
|
||||||
switch (message.Type)
|
switch (message.Type)
|
||||||
{
|
{
|
||||||
case MessageType.Image:
|
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
|
// Content IS the ASCII art — add each line as a separate list item
|
||||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||||
{
|
{
|
||||||
foreach (var artLine in message.Content.Split('\n'))
|
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;
|
break;
|
||||||
@@ -519,21 +527,41 @@ public sealed class MainWindow : Runnable
|
|||||||
case MessageType.File:
|
case MessageType.File:
|
||||||
var fileName = message.AttachmentFileName ?? "unknown";
|
var fileName = message.AttachmentFileName ?? "unknown";
|
||||||
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
|
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;
|
break;
|
||||||
|
|
||||||
case MessageType.Text:
|
case MessageType.Text:
|
||||||
default:
|
default:
|
||||||
var contentLines = message.Content.Split('\n');
|
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
|
// 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++)
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
{
|
{
|
||||||
lines.Add($" {contentLines[i]}");
|
lines.Add(new ChatLine($"{indent}{contentLines[i].TrimEnd('\r')}"));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a chat line with an optionally colored sender name.
|
||||||
|
/// </summary>
|
||||||
|
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<ChatSegment>
|
||||||
|
{
|
||||||
|
new(prefix, null),
|
||||||
|
new(senderName, senderColor.Value),
|
||||||
|
new(suffix, null)
|
||||||
|
};
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<IActionResult> 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<IActionResult> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChatHub, IEchoHubClient> hubContext) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Auth;
|
using EchoHub.Server.Auth;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
@@ -64,7 +62,8 @@ builder.Services.AddAuthentication(options =>
|
|||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
// ── SignalR ───────────────────────────────────────────────────────────────────
|
// ── Controllers + SignalR ───────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────────────
|
||||||
@@ -112,288 +111,8 @@ app.UseCors();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
// ── Auth Endpoints ────────────────────────────────────────────────────────────
|
// ── Routing ───────────────────────────────────────────────────────────────────
|
||||||
var auth = app.MapGroup("/api/auth");
|
app.MapControllers();
|
||||||
|
|
||||||
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 ───────────────────────────────────────────────────────────────
|
|
||||||
app.MapHub<ChatHub>(HubConstants.ChatHubPath);
|
app.MapHub<ChatHub>(HubConstants.ChatHubPath);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ public class ImageToAsciiService
|
|||||||
|
|
||||||
var sb = new StringBuilder();
|
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++)
|
||||||
{
|
{
|
||||||
for (int x = 0; x < image.Width; x++)
|
for (int x = 0; x < image.Width; x++)
|
||||||
@@ -25,11 +28,26 @@ public class ImageToAsciiService
|
|||||||
var pixel = image[x, y];
|
var pixel = image[x, y];
|
||||||
var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B;
|
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));
|
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]);
|
sb.Append(AsciiChars[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset color at end of line
|
||||||
|
sb.Append("\x1b[0m");
|
||||||
|
hasLastColor = false;
|
||||||
|
|
||||||
if (y < image.Height - 1)
|
if (y < image.Height - 1)
|
||||||
{
|
{
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|||||||
Reference in New Issue
Block a user