Refactor EchoHub solution structure and remove unused components

- 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.
This commit is contained in:
HueByte
2026-02-19 03:51:38 +01:00
parent 287270b26d
commit ebc8fffec8
22 changed files with 772 additions and 358 deletions
-9
View File
@@ -1,9 +0,0 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/EchoHub.Api/EchoHub.Api.csproj" />
<Project Path="src/EchoHub.Client/EchoHub.Client.csproj" />
<Project Path="src/EchoHub.Core/EchoHub.Core.csproj" />
<Project Path="src/EchoHub.Server/EchoHub.Server.csproj" />
<Project Path="src/EchoHub.Web/EchoHub.Web.csproj" />
</Folder>
</Solution>
@@ -1,18 +0,0 @@
using EchoHub.Core.Models;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Api.Data;
public class ServerDirectoryDbContext(DbContextOptions<ServerDirectoryDbContext> options) : DbContext(options)
{
public DbSet<ServerInfo> Servers => Set<ServerInfo>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ServerInfo>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Url).IsUnique();
});
}
}
-21
View File
@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
</Project>
@@ -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<ServerDirectoryDbContext>(options =>
options.UseSqlite(connectionString));
services.AddScoped<ServerDirectoryService>();
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;
}
}
@@ -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<List<ServerInfoDto>> 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<ServerInfoDto?> GetServerByIdAsync(Guid id)
{
var server = await db.Servers.FindAsync(id);
return server is null ? null : MapToDto(server);
}
public async Task<ServerInfoDto> 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<ServerInfoDto?> 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<List<ServerInfoDto>> 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);
}
@@ -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))
+5 -28
View File
@@ -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}"));
}
};
+10
View File
@@ -173,6 +173,16 @@ public sealed class ApiClient : IDisposable
return await response.Content.ReadFromJsonAsync<MessageDto>();
}
public async Task<MessageDto?> 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<MessageDto>();
}
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null)
{
EnsureAuthenticated();
+2
View File
@@ -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);
-12
View File
@@ -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);
-14
View File
@@ -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;
}
@@ -22,6 +22,7 @@ public class ChannelsController(
EchoHubDbContext db,
FileStorageService fileStorage,
ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory,
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase
{
[HttpGet]
@@ -217,4 +218,121 @@ public class ChannelsController(
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);
}
}
@@ -0,0 +1,210 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,146 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Channels",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Topic = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
CreatedByUserId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Channels", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Username = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
DisplayName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
Bio = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NicknameColor = table.Column<string>(type: "TEXT", maxLength: 7, nullable: true),
AvatarAscii = table.Column<string>(type: "TEXT", maxLength: 10000, nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
StatusMessage = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
LastSeenAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Messages",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Content = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
AttachmentUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
AttachmentFileName = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
SentAt = table.Column<long>(type: "INTEGER", nullable: false),
ChannelId = table.Column<Guid>(type: "TEXT", nullable: false),
SenderUserId = table.Column<Guid>(type: "TEXT", nullable: false),
SenderUsername = table.Column<string>(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<Guid>(type: "TEXT", nullable: false),
TokenHash = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
ExpiresAt = table.Column<long>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
RevokedAt = table.Column<long>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Messages");
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(
name: "Channels");
migrationBuilder.DropTable(
name: "Users");
}
}
}
@@ -0,0 +1,207 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
+4
View File
@@ -7,6 +7,10 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
+63 -1
View File
@@ -73,6 +73,11 @@ builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<PresenceTracker>();
builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>();
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<EchoHubDbContext>();
await db.Database.EnsureCreatedAsync();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
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);
}
}
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\EchoHub.Api\EchoHub.Api.csproj" />
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
-45
View File
@@ -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<ServerDirectoryDbContext>();
await db.Database.EnsureCreatedAsync();
}
app.UseCors();
app.MapGet("/", () => Results.Content("""
<!DOCTYPE html>
<html>
<head><title>EchoHub</title></head>
<body>
<h1>Welcome to EchoHub</h1>
<p>EchoHub is a decentralized IRC-like chat network.</p>
<p>Use the <a href="/api/servers">Server Directory API</a> to browse available servers.</p>
</body>
</html>
""", "text/html"));
app.MapServerDirectoryApi();
app.Run();
@@ -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"
}
}
}
}
@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
<Solution>
<Project Path="EchoHub.Client/EchoHub.Client.csproj" />
<Project Path="EchoHub.Core/EchoHub.Core.csproj" />
<Project Path="EchoHub.Server/EchoHub.Server.csproj" />
</Solution>