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
@@ -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);
}
}