mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
Refactor and update various components of EchoHub.Server
- Updated launchSettings.json for consistency. - Refactored FileValidationHelper to improve image validation logic. - Enhanced ServerDirectoryService for better connection handling and user count updates. - Improved DatabaseSetup for legacy database handling and seeding default channels. - Refined FirstRunSetup to ensure JWT secret generation. - Removed appsettings.Development.json as it is no longer needed. - Updated EchoHub.Tests project file for consistency. - Added unit tests for FileValidationHelper and PresenceTracker with improved assertions. - Updated ValidationConstantsTests to ensure regex validations are correct. - Cleaned up solution file formatting for better readability.
This commit is contained in:
@@ -1,146 +1,146 @@
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Auth;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
[EnableRateLimiting("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 ErrorResponse("Username and password are required."));
|
||||
|
||||
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
|
||||
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
|
||||
|
||||
if (request.Password.Length < 6)
|
||||
return BadRequest(new ErrorResponse("Password must be at least 6 characters."));
|
||||
|
||||
if (request.Password.Length > ValidationConstants.MaxPasswordLength)
|
||||
return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters."));
|
||||
|
||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||
|
||||
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
||||
return Conflict(new ErrorResponse("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 (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, 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 ErrorResponse("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(new ErrorResponse("Invalid username or password."));
|
||||
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||
|
||||
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||
var storedToken = await db.RefreshTokens
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||
|
||||
if (storedToken is null || !storedToken.IsActive || storedToken.User is null)
|
||||
return Unauthorized(new ErrorResponse("Invalid or expired refresh token."));
|
||||
|
||||
// Revoke old refresh token (rotation)
|
||||
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var user = storedToken.User;
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Issue new token pair
|
||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
||||
var newRefreshToken = JwtTokenService.GenerateRefreshToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = JwtTokenService.HashToken(newRefreshToken),
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout([FromBody] RefreshRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||
|
||||
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||
var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||
|
||||
if (storedToken is not null && storedToken.IsActive)
|
||||
{
|
||||
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Auth;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
[EnableRateLimiting("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 ErrorResponse("Username and password are required."));
|
||||
|
||||
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
|
||||
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
|
||||
|
||||
if (request.Password.Length < 6)
|
||||
return BadRequest(new ErrorResponse("Password must be at least 6 characters."));
|
||||
|
||||
if (request.Password.Length > ValidationConstants.MaxPasswordLength)
|
||||
return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters."));
|
||||
|
||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||
|
||||
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
||||
return Conflict(new ErrorResponse("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 (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, 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 ErrorResponse("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(new ErrorResponse("Invalid username or password."));
|
||||
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||
|
||||
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||
var storedToken = await db.RefreshTokens
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||
|
||||
if (storedToken is null || !storedToken.IsActive || storedToken.User is null)
|
||||
return Unauthorized(new ErrorResponse("Invalid or expired refresh token."));
|
||||
|
||||
// Revoke old refresh token (rotation)
|
||||
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var user = storedToken.User;
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Issue new token pair
|
||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
||||
var newRefreshToken = JwtTokenService.GenerateRefreshToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = JwtTokenService.HashToken(newRefreshToken),
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout([FromBody] RefreshRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||
|
||||
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||
var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||
|
||||
if (storedToken is not null && storedToken.IsActive)
|
||||
{
|
||||
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,338 +1,338 @@
|
||||
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.RateLimiting;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/channels")]
|
||||
[Authorize]
|
||||
[EnableRateLimiting("general")]
|
||||
public class ChannelsController(
|
||||
EchoHubDbContext db,
|
||||
FileStorageService fileStorage,
|
||||
ImageToAsciiService asciiService,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
||||
{
|
||||
offset = Math.Max(0, offset);
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
var total = await db.Channels.CountAsync();
|
||||
|
||||
var channels = await db.Channels
|
||||
.OrderBy(c => c.Name)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(c => new ChannelDto(
|
||||
c.Id,
|
||||
c.Name,
|
||||
c.Topic,
|
||||
c.Messages.Count,
|
||||
c.CreatedAt))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new ErrorResponse("Channel name is required."));
|
||||
|
||||
var channelName = request.Name.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
|
||||
|
||||
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
||||
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
|
||||
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channel = new Channel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = channelName,
|
||||
Topic = request.Topic?.Trim(),
|
||||
CreatedByUserId = Guid.Parse(userIdClaim),
|
||||
};
|
||||
|
||||
db.Channels.Add(channel);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
|
||||
await hubContext.Clients.All.ChannelUpdated(dto);
|
||||
|
||||
return Created($"/api/channels/{channelName}", dto);
|
||||
}
|
||||
|
||||
[HttpPut("{channel}/topic")]
|
||||
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
||||
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
|
||||
|
||||
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
|
||||
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
|
||||
|
||||
dbChannel.Topic = request.Topic?.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
|
||||
await hubContext.Clients.Group(channelName).ChannelUpdated(dto);
|
||||
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpDelete("{channel}")]
|
||||
public async Task<IActionResult> DeleteChannel(string channel)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
|
||||
if (channelName == HubConstants.DefaultChannel)
|
||||
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
|
||||
|
||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
||||
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
|
||||
|
||||
db.Channels.Remove(dbChannel);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{channel}/upload")]
|
||||
[EnableRateLimiting("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(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||
|
||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||
return BadRequest(new ErrorResponse("No file uploaded."));
|
||||
|
||||
var file = Request.Form.Files[0];
|
||||
|
||||
if (file.Length > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
// Detect if file is an image by checking magic bytes
|
||||
using var stream = file.OpenReadStream();
|
||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||
|
||||
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
|
||||
|
||||
var messageType = isImage ? MessageType.Image : MessageType.File;
|
||||
string content;
|
||||
|
||||
if (isImage)
|
||||
{
|
||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||
content = asciiService.ConvertToAscii(imageStream);
|
||||
}
|
||||
else
|
||||
{
|
||||
content = 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);
|
||||
|
||||
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||
|
||||
return Ok(messageDto);
|
||||
}
|
||||
|
||||
[HttpPost("{channel}/send-url")]
|
||||
[EnableRateLimiting("upload")]
|
||||
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var usernameClaim = User.FindFirstValue("username");
|
||||
if (userIdClaim is null || usernameClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||
|
||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
return BadRequest(new ErrorResponse("URL is required."));
|
||||
|
||||
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
return BadRequest(new ErrorResponse("Invalid URL. Only http and https are supported."));
|
||||
|
||||
// Download image from URL
|
||||
byte[] imageBytes;
|
||||
string fileName;
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient("ImageDownload");
|
||||
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentLength = response.Content.Headers.ContentLength;
|
||||
if (contentLength > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
|
||||
if (imageBytes.Length > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
fileName = Path.GetFileName(uri.LocalPath);
|
||||
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||
{
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
|
||||
var ext = contentType switch
|
||||
{
|
||||
"image/png" => ".png",
|
||||
"image/jpeg" or "image/jpg" => ".jpg",
|
||||
"image/gif" => ".gif",
|
||||
"image/webp" => ".webp",
|
||||
_ => ".bin"
|
||||
};
|
||||
fileName = $"download{ext}";
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return BadRequest(new ErrorResponse("Download timed out. The URL may be unreachable."));
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return BadRequest(new ErrorResponse($"Failed to download from URL: {ex.Message}"));
|
||||
}
|
||||
|
||||
// Validate it's actually an image
|
||||
using var memoryStream = new MemoryStream(imageBytes);
|
||||
if (!FileValidationHelper.IsValidImage(memoryStream))
|
||||
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
||||
|
||||
// Save file and convert to ASCII
|
||||
var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName);
|
||||
|
||||
string content;
|
||||
using (var imageStream = System.IO.File.OpenRead(filePath))
|
||||
{
|
||||
content = asciiService.ConvertToAscii(imageStream);
|
||||
}
|
||||
|
||||
var attachmentUrl = $"/api/files/{fileId}";
|
||||
var sender = await db.Users.FindAsync(userId);
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = content,
|
||||
Type = MessageType.Image,
|
||||
AttachmentUrl = attachmentUrl,
|
||||
AttachmentFileName = 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.Image,
|
||||
attachmentUrl,
|
||||
fileName,
|
||||
message.SentAt);
|
||||
|
||||
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||
|
||||
return Ok(messageDto);
|
||||
}
|
||||
}
|
||||
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.RateLimiting;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/channels")]
|
||||
[Authorize]
|
||||
[EnableRateLimiting("general")]
|
||||
public class ChannelsController(
|
||||
EchoHubDbContext db,
|
||||
FileStorageService fileStorage,
|
||||
ImageToAsciiService asciiService,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
||||
{
|
||||
offset = Math.Max(0, offset);
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
var total = await db.Channels.CountAsync();
|
||||
|
||||
var channels = await db.Channels
|
||||
.OrderBy(c => c.Name)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(c => new ChannelDto(
|
||||
c.Id,
|
||||
c.Name,
|
||||
c.Topic,
|
||||
c.Messages.Count,
|
||||
c.CreatedAt))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new ErrorResponse("Channel name is required."));
|
||||
|
||||
var channelName = request.Name.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
|
||||
|
||||
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
||||
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
|
||||
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channel = new Channel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = channelName,
|
||||
Topic = request.Topic?.Trim(),
|
||||
CreatedByUserId = Guid.Parse(userIdClaim),
|
||||
};
|
||||
|
||||
db.Channels.Add(channel);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
|
||||
await hubContext.Clients.All.ChannelUpdated(dto);
|
||||
|
||||
return Created($"/api/channels/{channelName}", dto);
|
||||
}
|
||||
|
||||
[HttpPut("{channel}/topic")]
|
||||
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
||||
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
|
||||
|
||||
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
|
||||
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
|
||||
|
||||
dbChannel.Topic = request.Topic?.Trim();
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
|
||||
await hubContext.Clients.Group(channelName).ChannelUpdated(dto);
|
||||
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpDelete("{channel}")]
|
||||
public async Task<IActionResult> DeleteChannel(string channel)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
|
||||
if (channelName == HubConstants.DefaultChannel)
|
||||
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
|
||||
|
||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
||||
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
|
||||
|
||||
db.Channels.Remove(dbChannel);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{channel}/upload")]
|
||||
[EnableRateLimiting("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(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||
|
||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||
return BadRequest(new ErrorResponse("No file uploaded."));
|
||||
|
||||
var file = Request.Form.Files[0];
|
||||
|
||||
if (file.Length > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
// Detect if file is an image by checking magic bytes
|
||||
using var stream = file.OpenReadStream();
|
||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||
|
||||
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
|
||||
|
||||
var messageType = isImage ? MessageType.Image : MessageType.File;
|
||||
string content;
|
||||
|
||||
if (isImage)
|
||||
{
|
||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||
content = asciiService.ConvertToAscii(imageStream);
|
||||
}
|
||||
else
|
||||
{
|
||||
content = 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);
|
||||
|
||||
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||
|
||||
return Ok(messageDto);
|
||||
}
|
||||
|
||||
[HttpPost("{channel}/send-url")]
|
||||
[EnableRateLimiting("upload")]
|
||||
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var usernameClaim = User.FindFirstValue("username");
|
||||
if (userIdClaim is null || usernameClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||
|
||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
return BadRequest(new ErrorResponse("URL is required."));
|
||||
|
||||
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
return BadRequest(new ErrorResponse("Invalid URL. Only http and https are supported."));
|
||||
|
||||
// Download image from URL
|
||||
byte[] imageBytes;
|
||||
string fileName;
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient("ImageDownload");
|
||||
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentLength = response.Content.Headers.ContentLength;
|
||||
if (contentLength > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
|
||||
if (imageBytes.Length > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
fileName = Path.GetFileName(uri.LocalPath);
|
||||
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||
{
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
|
||||
var ext = contentType switch
|
||||
{
|
||||
"image/png" => ".png",
|
||||
"image/jpeg" or "image/jpg" => ".jpg",
|
||||
"image/gif" => ".gif",
|
||||
"image/webp" => ".webp",
|
||||
_ => ".bin"
|
||||
};
|
||||
fileName = $"download{ext}";
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return BadRequest(new ErrorResponse("Download timed out. The URL may be unreachable."));
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return BadRequest(new ErrorResponse($"Failed to download from URL: {ex.Message}"));
|
||||
}
|
||||
|
||||
// Validate it's actually an image
|
||||
using var memoryStream = new MemoryStream(imageBytes);
|
||||
if (!FileValidationHelper.IsValidImage(memoryStream))
|
||||
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
||||
|
||||
// Save file and convert to ASCII
|
||||
var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName);
|
||||
|
||||
string content;
|
||||
using (var imageStream = System.IO.File.OpenRead(filePath))
|
||||
{
|
||||
content = asciiService.ConvertToAscii(imageStream);
|
||||
}
|
||||
|
||||
var attachmentUrl = $"/api/files/{fileId}";
|
||||
var sender = await db.Users.FindAsync(userId);
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = content,
|
||||
Type = MessageType.Image,
|
||||
AttachmentUrl = attachmentUrl,
|
||||
AttachmentFileName = 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.Image,
|
||||
attachmentUrl,
|
||||
fileName,
|
||||
message.SentAt);
|
||||
|
||||
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||
|
||||
return Ok(messageDto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Server.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/files")]
|
||||
[Authorize]
|
||||
[EnableRateLimiting("general")]
|
||||
public class FilesController(FileStorageService fileStorage) : ControllerBase
|
||||
{
|
||||
[HttpGet("{fileId}")]
|
||||
public IActionResult GetFile(string fileId)
|
||||
{
|
||||
if (!Guid.TryParse(fileId, out _))
|
||||
return BadRequest(new ErrorResponse("Invalid file identifier."));
|
||||
|
||||
var filePath = fileStorage.GetFilePath(fileId);
|
||||
|
||||
if (filePath is null)
|
||||
return NotFound(new ErrorResponse("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);
|
||||
}
|
||||
}
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Server.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/files")]
|
||||
[Authorize]
|
||||
[EnableRateLimiting("general")]
|
||||
public class FilesController(FileStorageService fileStorage) : ControllerBase
|
||||
{
|
||||
[HttpGet("{fileId}")]
|
||||
public IActionResult GetFile(string fileId)
|
||||
{
|
||||
if (!Guid.TryParse(fileId, out _))
|
||||
return BadRequest(new ErrorResponse("Invalid file identifier."));
|
||||
|
||||
var filePath = fileStorage.GetFilePath(fileId);
|
||||
|
||||
if (filePath is null)
|
||||
return NotFound(new ErrorResponse("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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
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.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize]
|
||||
[EnableRateLimiting("general")]
|
||||
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 ErrorResponse("User not found."));
|
||||
|
||||
return Ok(ToProfileDto(user));
|
||||
}
|
||||
|
||||
[HttpPut("profile")]
|
||||
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
|
||||
if (user is null)
|
||||
return NotFound(new ErrorResponse("User not found."));
|
||||
|
||||
if (request.DisplayName is not null)
|
||||
{
|
||||
if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength)
|
||||
return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."));
|
||||
user.DisplayName = request.DisplayName.Trim();
|
||||
}
|
||||
|
||||
if (request.Bio is not null)
|
||||
{
|
||||
if (request.Bio.Length > ValidationConstants.MaxBioLength)
|
||||
return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters."));
|
||||
user.Bio = request.Bio.Trim();
|
||||
}
|
||||
|
||||
if (request.NicknameColor is not null)
|
||||
{
|
||||
var color = request.NicknameColor.Trim();
|
||||
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
|
||||
return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500)."));
|
||||
user.NicknameColor = color.Length > 0 ? color : null;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(ToProfileDto(user));
|
||||
}
|
||||
|
||||
[HttpPost("avatar")]
|
||||
[EnableRateLimiting("upload")]
|
||||
public async Task<IActionResult> UploadAvatar()
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
|
||||
if (user is null)
|
||||
return NotFound(new ErrorResponse("User not found."));
|
||||
|
||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||
return BadRequest(new ErrorResponse("No file uploaded."));
|
||||
|
||||
var file = Request.Form.Files[0];
|
||||
|
||||
if (file.Length > HubConstants.MaxAvatarSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
|
||||
if (!FileValidationHelper.IsValidImage(stream))
|
||||
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
||||
|
||||
var asciiArt = asciiService.ConvertToAscii(stream);
|
||||
|
||||
user.AvatarAscii = asciiArt;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new AvatarUploadResponse(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);
|
||||
}
|
||||
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.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize]
|
||||
[EnableRateLimiting("general")]
|
||||
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 ErrorResponse("User not found."));
|
||||
|
||||
return Ok(ToProfileDto(user));
|
||||
}
|
||||
|
||||
[HttpPut("profile")]
|
||||
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
|
||||
if (user is null)
|
||||
return NotFound(new ErrorResponse("User not found."));
|
||||
|
||||
if (request.DisplayName is not null)
|
||||
{
|
||||
if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength)
|
||||
return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."));
|
||||
user.DisplayName = request.DisplayName.Trim();
|
||||
}
|
||||
|
||||
if (request.Bio is not null)
|
||||
{
|
||||
if (request.Bio.Length > ValidationConstants.MaxBioLength)
|
||||
return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters."));
|
||||
user.Bio = request.Bio.Trim();
|
||||
}
|
||||
|
||||
if (request.NicknameColor is not null)
|
||||
{
|
||||
var color = request.NicknameColor.Trim();
|
||||
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
|
||||
return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500)."));
|
||||
user.NicknameColor = color.Length > 0 ? color : null;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(ToProfileDto(user));
|
||||
}
|
||||
|
||||
[HttpPost("avatar")]
|
||||
[EnableRateLimiting("upload")]
|
||||
public async Task<IActionResult> UploadAvatar()
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
|
||||
if (user is null)
|
||||
return NotFound(new ErrorResponse("User not found."));
|
||||
|
||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||
return BadRequest(new ErrorResponse("No file uploaded."));
|
||||
|
||||
var file = Request.Form.Files[0];
|
||||
|
||||
if (file.Length > HubConstants.MaxAvatarSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
|
||||
if (!FileValidationHelper.IsValidImage(stream))
|
||||
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
||||
|
||||
var asciiArt = asciiService.ConvertToAscii(stream);
|
||||
|
||||
user.AvatarAscii = asciiArt;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new AvatarUploadResponse(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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user