mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
- Deleted EchoHub.slnx and related project files for EchoHub.Web and EchoHub.Api. - Removed ServerDirectoryDbContext, ServerDirectoryService, and related extensions and DTOs. - Updated CommandHandler and Program.cs in EchoHub.Client to handle URL sending instead of file downloading. - Added SendUrlRequest DTO and corresponding API endpoint in ChannelsController for handling URL uploads. - Implemented database migration support in EchoHub.Server with initial migration setup. - Adjusted Program.cs to include HTTP client configuration for image downloading. - Cleaned up unnecessary files and references to streamline the project.
339 lines
13 KiB
C#
339 lines
13 KiB
C#
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);
|
|
}
|
|
}
|