mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
Implement chat rendering and message handling with colored segments
This commit is contained in:
@@ -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 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<ChatHub>(HubConstants.ChatHubPath);
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user