diff --git a/EchoHub.slnx b/EchoHub.slnx deleted file mode 100644 index b0939c0..0000000 --- a/EchoHub.slnx +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/EchoHub.Api/Data/ServerDirectoryDbContext.cs b/src/EchoHub.Api/Data/ServerDirectoryDbContext.cs deleted file mode 100644 index c31b409..0000000 --- a/src/EchoHub.Api/Data/ServerDirectoryDbContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -using EchoHub.Core.Models; -using Microsoft.EntityFrameworkCore; - -namespace EchoHub.Api.Data; - -public class ServerDirectoryDbContext(DbContextOptions options) : DbContext(options) -{ - public DbSet Servers => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - entity.HasIndex(e => e.Url).IsUnique(); - }); - } -} diff --git a/src/EchoHub.Api/EchoHub.Api.csproj b/src/EchoHub.Api/EchoHub.Api.csproj deleted file mode 100644 index 646dd70..0000000 --- a/src/EchoHub.Api/EchoHub.Api.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/src/EchoHub.Api/Extensions/ServerDirectoryExtensions.cs b/src/EchoHub.Api/Extensions/ServerDirectoryExtensions.cs deleted file mode 100644 index 320bcbe..0000000 --- a/src/EchoHub.Api/Extensions/ServerDirectoryExtensions.cs +++ /dev/null @@ -1,69 +0,0 @@ -using EchoHub.Api.Data; -using EchoHub.Api.Services; -using EchoHub.Core.DTOs; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -namespace EchoHub.Api.Extensions; - -public static class ServerDirectoryExtensions -{ - public static IServiceCollection AddServerDirectory(this IServiceCollection services, string connectionString) - { - services.AddDbContext(options => - options.UseSqlite(connectionString)); - - services.AddScoped(); - - return services; - } - - public static WebApplication MapServerDirectoryApi(this WebApplication app) - { - var group = app.MapGroup("/api/servers"); - - group.MapGet("/", async (ServerDirectoryService service) => - { - var servers = await service.GetAllServersAsync(); - return Results.Ok(servers); - }); - - group.MapGet("/search", async (string q, ServerDirectoryService service) => - { - if (string.IsNullOrWhiteSpace(q)) - return Results.BadRequest("Query parameter 'q' is required."); - - var servers = await service.SearchServersAsync(q); - return Results.Ok(servers); - }); - - group.MapGet("/{id:guid}", async (Guid id, ServerDirectoryService service) => - { - var server = await service.GetServerByIdAsync(id); - return server is null ? Results.NotFound() : Results.Ok(server); - }); - - group.MapPost("/", async (RegisterServerRequest request, ServerDirectoryService service) => - { - try - { - var server = await service.RegisterServerAsync(request); - return Results.Created($"/api/servers/{server.Id}", server); - } - catch (InvalidOperationException ex) - { - return Results.Conflict(new { error = ex.Message }); - } - }); - - group.MapPut("/{id:guid}/status", async (Guid id, ServerStatusDto status, ServerDirectoryService service) => - { - var server = await service.UpdateServerStatusAsync(id, status.OnlineUsers, status.TotalChannels); - return server is null ? Results.NotFound() : Results.Ok(server); - }); - - return app; - } -} diff --git a/src/EchoHub.Api/Services/ServerDirectoryService.cs b/src/EchoHub.Api/Services/ServerDirectoryService.cs deleted file mode 100644 index c6dcf95..0000000 --- a/src/EchoHub.Api/Services/ServerDirectoryService.cs +++ /dev/null @@ -1,90 +0,0 @@ -using EchoHub.Api.Data; -using EchoHub.Core.DTOs; -using EchoHub.Core.Models; -using Microsoft.EntityFrameworkCore; - -namespace EchoHub.Api.Services; - -public class ServerDirectoryService(ServerDirectoryDbContext db) -{ - public async Task> GetAllServersAsync() - { - return await db.Servers - .OrderByDescending(s => s.OnlineUsers) - .Select(s => new ServerInfoDto( - s.Id, s.Name, s.Description, s.Url, - s.OnlineUsers, s.TotalChannels, s.IsOnline, s.LastPingAt)) - .ToListAsync(); - } - - public async Task GetServerByIdAsync(Guid id) - { - var server = await db.Servers.FindAsync(id); - return server is null ? null : MapToDto(server); - } - - public async Task RegisterServerAsync(RegisterServerRequest request) - { - var exists = await db.Servers.AnyAsync(s => s.Url == request.Url); - if (exists) - throw new InvalidOperationException($"A server with URL '{request.Url}' is already registered."); - - var server = new ServerInfo - { - Id = Guid.NewGuid(), - Name = request.Name, - Description = request.Description, - Url = request.Url, - OnlineUsers = 0, - TotalChannels = 0, - RegisteredAt = DateTimeOffset.UtcNow, - LastPingAt = DateTimeOffset.UtcNow, - IsOnline = true - }; - - db.Servers.Add(server); - await db.SaveChangesAsync(); - - return MapToDto(server); - } - - public async Task UpdateServerStatusAsync(Guid id, int onlineUsers, int totalChannels) - { - var server = await db.Servers.FindAsync(id); - if (server is null) - return null; - - server.OnlineUsers = onlineUsers; - server.TotalChannels = totalChannels; - server.LastPingAt = DateTimeOffset.UtcNow; - server.IsOnline = true; - - await db.SaveChangesAsync(); - - return MapToDto(server); - } - - public async Task> SearchServersAsync(string query) - { - var lowerQuery = query.ToLowerInvariant(); - - return await db.Servers - .Where(s => s.Name.ToLower().Contains(lowerQuery) - || (s.Description != null && s.Description.ToLower().Contains(lowerQuery))) - .OrderByDescending(s => s.OnlineUsers) - .Select(s => new ServerInfoDto( - s.Id, s.Name, s.Description, s.Url, - s.OnlineUsers, s.TotalChannels, s.IsOnline, s.LastPingAt)) - .ToListAsync(); - } - - private static ServerInfoDto MapToDto(ServerInfo server) => new( - server.Id, - server.Name, - server.Description, - server.Url, - server.OnlineUsers, - server.TotalChannels, - server.IsOnline, - server.LastPingAt); -} diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index d880ee7..60c6abe 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -129,8 +129,8 @@ public class CommandHandler await OnSendFile(target); var fileName = Path.GetFileName(uri.LocalPath); if (string.IsNullOrWhiteSpace(fileName)) - fileName = "download"; - return new CommandResult(true, $"Downloading & uploading: {fileName}..."); + fileName = "image"; + return new CommandResult(true, $"Sending: {fileName}..."); } if (!File.Exists(target)) diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index 46d4ac1..eba39e2 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -143,35 +143,12 @@ public static class Program if (Uri.TryCreate(target, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) { - // Download from URL then upload - Log.Information("Downloading file from {Url}", target); - using var httpClient = new HttpClient(); - using var response = await httpClient.GetAsync(uri); - response.EnsureSuccessStatusCode(); - - var fileName = Path.GetFileName(uri.LocalPath); - if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) - { - // Try to infer extension from Content-Type - var contentType = response.Content.Headers.ContentType?.MediaType ?? ""; - var ext = contentType switch - { - "image/png" => ".png", - "image/jpeg" => ".jpg", - "image/gif" => ".gif", - "image/webp" => ".webp", - _ => "" - }; - fileName = $"download{ext}"; - } - - await using var stream = await response.Content.ReadAsStreamAsync(); - await _apiClient.UploadFileAsync(channel, stream, fileName); - Log.Information("URL file uploaded: {FileName}", fileName); + // Send URL to server — server handles downloading and conversion + await _apiClient.SendUrlAsync(channel, target); } else { - // Local file + // Local file — upload to server await using var stream = File.OpenRead(target); var fileName = Path.GetFileName(target); await _apiClient.UploadFileAsync(channel, stream, fileName); @@ -179,9 +156,9 @@ public static class Program } catch (Exception ex) { - Log.Error(ex, "File upload failed for {Target}", target); + Log.Error(ex, "File send failed for {Target}", target); _app.Invoke(() => - _mainWindow!.ShowError($"File upload failed: {ex.Message}")); + _mainWindow!.ShowError($"Send failed: {ex.Message}")); } }; diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index ba9ee6a..adeed96 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -173,6 +173,16 @@ public sealed class ApiClient : IDisposable return await response.Content.ReadFromJsonAsync(); } + public async Task SendUrlAsync(string channelName, string url) + { + EnsureAuthenticated(); + var request = new SendUrlRequest(url); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url", request)); + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(); + } + public async Task CreateChannelAsync(string name, string? topic = null) { EnsureAuthenticated(); diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index c13b62c..25a84e3 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -33,3 +33,5 @@ public record SendMessageRequest(string ChannelName, string Content); public record CreateChannelRequest(string Name, string? Topic = null); public record UpdateTopicRequest(string? Topic); + +public record SendUrlRequest(string Url); diff --git a/src/EchoHub.Core/DTOs/ServerDtos.cs b/src/EchoHub.Core/DTOs/ServerDtos.cs index a80d922..216a6a8 100644 --- a/src/EchoHub.Core/DTOs/ServerDtos.cs +++ b/src/EchoHub.Core/DTOs/ServerDtos.cs @@ -1,15 +1,3 @@ namespace EchoHub.Core.DTOs; -public record ServerInfoDto( - Guid Id, - string Name, - string? Description, - string Url, - int OnlineUsers, - int TotalChannels, - bool IsOnline, - DateTimeOffset LastPingAt); - -public record RegisterServerRequest(string Name, string Url, string? Description = null); - public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels); diff --git a/src/EchoHub.Core/Models/ServerInfo.cs b/src/EchoHub.Core/Models/ServerInfo.cs deleted file mode 100644 index 1e97024..0000000 --- a/src/EchoHub.Core/Models/ServerInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace EchoHub.Core.Models; - -public class ServerInfo -{ - public Guid Id { get; set; } - public required string Name { get; set; } - public string? Description { get; set; } - public required string Url { get; set; } - public int OnlineUsers { get; set; } - public int TotalChannels { get; set; } - public DateTimeOffset RegisteredAt { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset LastPingAt { get; set; } = DateTimeOffset.UtcNow; - public bool IsOnline { get; set; } = true; -} diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 00f839f..1b639d5 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -22,6 +22,7 @@ public class ChannelsController( EchoHubDbContext db, FileStorageService fileStorage, ImageToAsciiService asciiService, + IHttpClientFactory httpClientFactory, IHubContext hubContext) : ControllerBase { [HttpGet] @@ -217,4 +218,121 @@ public class ChannelsController( return Ok(messageDto); } + + [HttpPost("{channel}/send-url")] + [EnableRateLimiting("upload")] + public async Task 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); + } } diff --git a/src/EchoHub.Server/Data/Migrations/20260219023113_InitialCreate.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260219023113_InitialCreate.Designer.cs new file mode 100644 index 0000000..ca3edfd --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219023113_InitialCreate.Designer.cs @@ -0,0 +1,210 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260219023113_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260219023113_InitialCreate.cs b/src/EchoHub.Server/Data/Migrations/20260219023113_InitialCreate.cs new file mode 100644 index 0000000..5cb6ab6 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219023113_InitialCreate.cs @@ -0,0 +1,146 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Channels", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Topic = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + CreatedByUserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Channels", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Username = table.Column(type: "TEXT", maxLength: 50, nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + DisplayName = table.Column(type: "TEXT", maxLength: 100, nullable: true), + Bio = table.Column(type: "TEXT", maxLength: 500, nullable: true), + NicknameColor = table.Column(type: "TEXT", maxLength: 7, nullable: true), + AvatarAscii = table.Column(type: "TEXT", maxLength: 10000, nullable: true), + Status = table.Column(type: "INTEGER", nullable: false), + StatusMessage = table.Column(type: "TEXT", maxLength: 100, nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + LastSeenAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Messages", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Content = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + AttachmentUrl = table.Column(type: "TEXT", maxLength: 500, nullable: true), + AttachmentFileName = table.Column(type: "TEXT", maxLength: 255, nullable: true), + SentAt = table.Column(type: "INTEGER", nullable: false), + ChannelId = table.Column(type: "TEXT", nullable: false), + SenderUserId = table.Column(type: "TEXT", nullable: false), + SenderUsername = table.Column(type: "TEXT", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Messages", x => x.Id); + table.ForeignKey( + name: "FK_Messages_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TokenHash = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + RevokedAt = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Channels_Name", + table: "Channels", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Messages_ChannelId", + table: "Messages", + column: "ChannelId"); + + migrationBuilder.CreateIndex( + name: "IX_Messages_SentAt", + table: "Messages", + column: "SentAt"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + table: "RefreshTokens", + column: "TokenHash"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Messages"); + + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "Channels"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs new file mode 100644 index 0000000..c4ed9cb --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -0,0 +1,207 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + partial class EchoHubDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj index e64b4a2..74fe8f0 100644 --- a/src/EchoHub.Server/EchoHub.Server.csproj +++ b/src/EchoHub.Server/EchoHub.Server.csproj @@ -7,6 +7,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 9d23b6d..c1917b7 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -73,6 +73,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHttpClient("ImageDownload", client => +{ + client.Timeout = TimeSpan.FromSeconds(15); + client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB +}); // ── Rate Limiting ──────────────────────────────────────────────────────────── builder.Services.AddRateLimiter(options => @@ -125,8 +130,64 @@ var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.EnsureCreatedAsync(); + var logger = scope.ServiceProvider.GetRequiredService>(); + try + { + // If the DB was created by EnsureCreated (no __EFMigrationsHistory table), + // back it up and recreate so MigrateAsync can manage the schema properly. + if (await db.Database.CanConnectAsync()) + { + var conn = db.Database.GetDbConnection(); + await conn.OpenAsync(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'"; + var hasMigrationTable = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; + + if (!hasMigrationTable) + { + cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Users'"; + var hasLegacyTables = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; + + if (hasLegacyTables) + { + var dbPath = conn.DataSource; + await conn.CloseAsync(); + + // Back up the legacy DB file before deleting + if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) + { + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var backupPath = $"{dbPath}.legacy_{timestamp}"; + File.Copy(dbPath, backupPath, overwrite: false); + logger.LogWarning("Legacy database backed up to '{BackupPath}'.", backupPath); + } + + await db.Database.EnsureDeletedAsync(); + logger.LogWarning("Legacy database removed. A new database will be created with migration support."); + } + else + { + await conn.CloseAsync(); + } + } + else + { + await conn.CloseAsync(); + } + } + + await db.Database.MigrateAsync(); + logger.LogInformation("Database migrated successfully."); + } + catch (Exception ex) + { + logger.LogError(ex, "Database migration failed."); + throw; + } + + // Seed the default channel if it doesn't exist if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) { db.Channels.Add(new Channel @@ -138,6 +199,7 @@ using (var scope = app.Services.CreateScope()) }); await db.SaveChangesAsync(); + logger.LogInformation("Default channel '{Channel}' created.", HubConstants.DefaultChannel); } } diff --git a/src/EchoHub.Web/EchoHub.Web.csproj b/src/EchoHub.Web/EchoHub.Web.csproj deleted file mode 100644 index 989d10e..0000000 --- a/src/EchoHub.Web/EchoHub.Web.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - net10.0 - enable - enable - - - diff --git a/src/EchoHub.Web/Program.cs b/src/EchoHub.Web/Program.cs deleted file mode 100644 index eaed0ae..0000000 --- a/src/EchoHub.Web/Program.cs +++ /dev/null @@ -1,45 +0,0 @@ -using EchoHub.Api.Data; -using EchoHub.Api.Extensions; - -var builder = WebApplication.CreateBuilder(args); - -var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") - ?? "Data Source=serverdirectory.db"; - -builder.Services.AddServerDirectory(connectionString); - -builder.Services.AddCors(options => -{ - options.AddDefaultPolicy(policy => - { - policy.AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader(); - }); -}); - -var app = builder.Build(); - -using (var scope = app.Services.CreateScope()) -{ - var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.EnsureCreatedAsync(); -} - -app.UseCors(); - -app.MapGet("/", () => Results.Content(""" - - - EchoHub - -

Welcome to EchoHub

-

EchoHub is a decentralized IRC-like chat network.

-

Use the Server Directory API to browse available servers.

- - - """, "text/html")); - -app.MapServerDirectoryApi(); - -app.Run(); diff --git a/src/EchoHub.Web/Properties/launchSettings.json b/src/EchoHub.Web/Properties/launchSettings.json deleted file mode 100644 index e262d5b..0000000 --- a/src/EchoHub.Web/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:6000", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7103;http://localhost:5062", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/EchoHub.Web/appsettings.Development.json b/src/EchoHub.Web/appsettings.Development.json deleted file mode 100644 index ff66ba6..0000000 --- a/src/EchoHub.Web/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/EchoHub.slnx b/src/EchoHub.slnx new file mode 100644 index 0000000..55ce494 --- /dev/null +++ b/src/EchoHub.slnx @@ -0,0 +1,5 @@ + + + + +