mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
feat: Add Create Channel dialog and related events
- Introduced CreateChannelDialog for user to create new channels with name and topic. - Added OnCreateChannelRequested event in MainWindow for channel creation. - Enhanced EchoHubConnection with OnReconnected event for connection state handling. - Updated EchoHubDbContext to use application base directory for SQLite database path. - Integrated Serilog for logging in EchoHub.Server. - Refactored database initialization and migration logic into DatabaseSetup class. - Implemented FirstRunSetup to ensure appsettings.json and generate JWT secret if needed. - Updated appsettings files to configure Serilog logging. - Enhanced error handling and logging in ChatHub methods for better traceability.
This commit is contained in:
@@ -15,7 +15,8 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
|
||||
{
|
||||
if (!optionsBuilder.IsConfigured)
|
||||
{
|
||||
optionsBuilder.UseSqlite("Data Source=echohub.db");
|
||||
var dbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
|
||||
optionsBuilder.UseSqlite($"Data Source={dbPath}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+252
-185
@@ -24,246 +24,313 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
||||
|
||||
var user = await db.Users.FindAsync(CurrentUserId);
|
||||
if (user is not null)
|
||||
try
|
||||
{
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
user.Status = UserStatus.Online;
|
||||
await db.SaveChangesAsync();
|
||||
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
||||
|
||||
var user = await db.Users.FindAsync(CurrentUserId);
|
||||
if (user is not null)
|
||||
{
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
user.Status = UserStatus.Online;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
|
||||
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error in OnConnectedAsync for {ConnectionId}", Context.ConnectionId);
|
||||
throw;
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
|
||||
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
||||
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
||||
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
||||
: [];
|
||||
|
||||
var username = presenceTracker.UserDisconnected(Context.ConnectionId);
|
||||
|
||||
if (username is not null && !presenceTracker.IsOnline(username))
|
||||
try
|
||||
{
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||
if (user is not null)
|
||||
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
||||
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
||||
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
||||
: [];
|
||||
|
||||
var username = presenceTracker.UserDisconnected(Context.ConnectionId);
|
||||
|
||||
if (username is not null && !presenceTracker.IsOnline(username))
|
||||
{
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
user.Status = UserStatus.Invisible;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var presence = new UserPresenceDto(
|
||||
username,
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
UserStatus.Invisible,
|
||||
user.StatusMessage);
|
||||
|
||||
foreach (var channel in channelsBeforeDisconnect)
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||
if (user is not null)
|
||||
{
|
||||
await Clients.Group(channel).UserStatusChanged(presence);
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
user.Status = UserStatus.Invisible;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var presence = new UserPresenceDto(
|
||||
username,
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
UserStatus.Invisible,
|
||||
user.StatusMessage);
|
||||
|
||||
foreach (var channel in channelsBeforeDisconnect)
|
||||
{
|
||||
await Clients.Group(channel).UserStatusChanged(presence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
|
||||
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error in OnDisconnectedAsync for {ConnectionId}", Context.ConnectionId);
|
||||
throw;
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
|
||||
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> JoinChannel(string channelName)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
try
|
||||
{
|
||||
await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
{
|
||||
await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
||||
return [];
|
||||
}
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
||||
return [];
|
||||
}
|
||||
|
||||
presenceTracker.JoinChannel(CurrentUsername, channelName);
|
||||
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, channelName);
|
||||
await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername);
|
||||
|
||||
logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName);
|
||||
|
||||
var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount);
|
||||
return history;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
|
||||
await Clients.Caller.Error($"Failed to join channel: {ex.Message}");
|
||||
return [];
|
||||
}
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
||||
return [];
|
||||
}
|
||||
|
||||
presenceTracker.JoinChannel(CurrentUsername, channelName);
|
||||
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, channelName);
|
||||
await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername);
|
||||
|
||||
logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName);
|
||||
|
||||
var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount);
|
||||
return history;
|
||||
}
|
||||
|
||||
public async Task LeaveChannel(string channelName)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
try
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
presenceTracker.LeaveChannel(CurrentUsername, channelName);
|
||||
presenceTracker.LeaveChannel(CurrentUsername, channelName);
|
||||
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
||||
await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername);
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
||||
await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername);
|
||||
|
||||
logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName);
|
||||
logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error leaving channel '{Channel}' for {User}", channelName, CurrentUsername);
|
||||
await Clients.Caller.Error($"Failed to leave channel: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendMessage(string channelName, string content)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
try
|
||||
{
|
||||
await Clients.Caller.Error("Invalid channel name.");
|
||||
return;
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
{
|
||||
await Clients.Caller.Error("Invalid channel name.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
await Clients.Caller.Error("Message content cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.Length > HubConstants.MaxMessageLength)
|
||||
{
|
||||
await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
await Clients.Caller.Error($"Channel '{channelName}' does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await db.Users.FindAsync(CurrentUserId);
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = content,
|
||||
Type = MessageType.Text,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = channel.Id,
|
||||
SenderUserId = CurrentUserId,
|
||||
SenderUsername = CurrentUsername,
|
||||
};
|
||||
|
||||
db.Messages.Add(message);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var messageDto = new MessageDto(
|
||||
message.Id,
|
||||
message.Content,
|
||||
message.SenderUsername,
|
||||
sender?.NicknameColor,
|
||||
channelName,
|
||||
MessageType.Text,
|
||||
null,
|
||||
null,
|
||||
message.SentAt);
|
||||
|
||||
await Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||
|
||||
logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Clients.Caller.Error("Message content cannot be empty.");
|
||||
return;
|
||||
logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername);
|
||||
await Clients.Caller.Error($"Failed to send message: {ex.Message}");
|
||||
}
|
||||
|
||||
if (content.Length > HubConstants.MaxMessageLength)
|
||||
{
|
||||
await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
await Clients.Caller.Error($"Channel '{channelName}' does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await db.Users.FindAsync(CurrentUserId);
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = content,
|
||||
Type = MessageType.Text,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = channel.Id,
|
||||
SenderUserId = CurrentUserId,
|
||||
SenderUsername = CurrentUsername,
|
||||
};
|
||||
|
||||
db.Messages.Add(message);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var messageDto = new MessageDto(
|
||||
message.Id,
|
||||
message.Content,
|
||||
message.SenderUsername,
|
||||
sender?.NicknameColor,
|
||||
channelName,
|
||||
MessageType.Text,
|
||||
null,
|
||||
null,
|
||||
message.SentAt);
|
||||
|
||||
await Clients.Group(channelName).ReceiveMessage(messageDto);
|
||||
|
||||
logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName);
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||
try
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
|
||||
if (channel is null)
|
||||
if (channel is null)
|
||||
return [];
|
||||
|
||||
var messages = await db.Messages
|
||||
.Where(m => m.ChannelId == channel.Id)
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(count)
|
||||
.Join(db.Users,
|
||||
m => m.SenderUserId,
|
||||
u => u.Id,
|
||||
(m, u) => new MessageDto(
|
||||
m.Id,
|
||||
m.Content,
|
||||
m.SenderUsername,
|
||||
u.NicknameColor,
|
||||
channelName,
|
||||
m.Type,
|
||||
m.AttachmentUrl,
|
||||
m.AttachmentFileName,
|
||||
m.SentAt))
|
||||
.ToListAsync();
|
||||
|
||||
messages.Reverse();
|
||||
return messages;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error fetching history for '{Channel}'", channelName);
|
||||
await Clients.Caller.Error($"Failed to load history: {ex.Message}");
|
||||
return [];
|
||||
|
||||
var messages = await db.Messages
|
||||
.Where(m => m.ChannelId == channel.Id)
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Take(count)
|
||||
.Join(db.Users,
|
||||
m => m.SenderUserId,
|
||||
u => u.Id,
|
||||
(m, u) => new MessageDto(
|
||||
m.Id,
|
||||
m.Content,
|
||||
m.SenderUsername,
|
||||
u.NicknameColor,
|
||||
channelName,
|
||||
m.Type,
|
||||
m.AttachmentUrl,
|
||||
m.AttachmentFileName,
|
||||
m.SentAt))
|
||||
.ToListAsync();
|
||||
|
||||
messages.Reverse();
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateStatus(UserStatus status, string? statusMessage)
|
||||
{
|
||||
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||
try
|
||||
{
|
||||
await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.");
|
||||
return;
|
||||
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||
{
|
||||
await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await db.Users.FindAsync(CurrentUserId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await Clients.Caller.Error("User not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
user.Status = status;
|
||||
user.StatusMessage = statusMessage?.Trim();
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var presence = new UserPresenceDto(
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
status,
|
||||
statusMessage);
|
||||
|
||||
var channels = presenceTracker.GetChannelsForUser(CurrentUsername);
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await Clients.Group(channel).UserStatusChanged(presence);
|
||||
}
|
||||
}
|
||||
|
||||
var user = await db.Users.FindAsync(CurrentUserId);
|
||||
|
||||
if (user is null)
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Clients.Caller.Error("User not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
user.Status = status;
|
||||
user.StatusMessage = statusMessage?.Trim();
|
||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var presence = new UserPresenceDto(
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.NicknameColor,
|
||||
status,
|
||||
statusMessage);
|
||||
|
||||
var channels = presenceTracker.GetChannelsForUser(CurrentUsername);
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await Clients.Group(channel).UserStatusChanged(presence);
|
||||
logger.LogError(ex, "Error updating status for {User}", CurrentUsername);
|
||||
await Clients.Caller.Error($"Failed to update status: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<UserPresenceDto>> GetOnlineUsers(string channelName)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
try
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
|
||||
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
|
||||
|
||||
var users = await db.Users
|
||||
.Where(u => onlineUsernames.Contains(u.Username))
|
||||
.Select(u => new UserPresenceDto(
|
||||
u.Username,
|
||||
u.DisplayName,
|
||||
u.NicknameColor,
|
||||
u.Status,
|
||||
u.StatusMessage))
|
||||
.ToListAsync();
|
||||
var users = await db.Users
|
||||
.Where(u => onlineUsernames.Contains(u.Username))
|
||||
.Select(u => new UserPresenceDto(
|
||||
u.Username,
|
||||
u.DisplayName,
|
||||
u.NicknameColor,
|
||||
u.Status,
|
||||
u.StatusMessage))
|
||||
.ToListAsync();
|
||||
|
||||
return users;
|
||||
return users;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error listing users in '{Channel}'", channelName);
|
||||
await Clients.Caller.Error($"Failed to list users: {ex.Message}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using EchoHub.Core.Constants;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Auth;
|
||||
using EchoHub.Server.Data;
|
||||
using EchoHub.Server.Hubs;
|
||||
using EchoHub.Server.Services;
|
||||
using EchoHub.Server.Setup;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Serilog;
|
||||
|
||||
// ── First-run setup ──────────────────────────────────────────────────────────
|
||||
FirstRunSetup.EnsureAppSettings();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ── Serilog ──────────────────────────────────────────────────────────────────
|
||||
builder.Host.UseSerilog((context, config) =>
|
||||
config.ReadFrom.Configuration(context.Configuration));
|
||||
|
||||
// ── SQLite + EF Core ──────────────────────────────────────────────────────────
|
||||
var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? "Data Source=echohub.db";
|
||||
?? $"Data Source={defaultDbPath}";
|
||||
|
||||
builder.Services.AddDbContext<EchoHubDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
@@ -127,81 +137,7 @@ builder.Services.AddCors(options =>
|
||||
var app = builder.Build();
|
||||
|
||||
// ── Database initialization ───────────────────────────────────────────────────
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
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
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = HubConstants.DefaultChannel,
|
||||
Topic = "General discussion",
|
||||
CreatedByUserId = Guid.Empty,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Default channel '{Channel}' created.", HubConstants.DefaultChannel);
|
||||
}
|
||||
}
|
||||
await DatabaseSetup.InitializeAsync(app.Services);
|
||||
|
||||
// ── Middleware ─────────────────────────────────────────────────────────────────
|
||||
app.UseCors();
|
||||
|
||||
@@ -6,7 +6,8 @@ public class FileStorageService
|
||||
|
||||
public FileStorageService(IConfiguration configuration)
|
||||
{
|
||||
_storagePath = configuration["Storage:Path"] ?? "./uploads";
|
||||
_storagePath = configuration["Storage:Path"]
|
||||
?? Path.Combine(AppContext.BaseDirectory, "uploads");
|
||||
|
||||
if (!Directory.Exists(_storagePath))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Setup;
|
||||
|
||||
public static class DatabaseSetup
|
||||
{
|
||||
public static async Task InitializeAsync(IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("EchoHub.Server.Setup.DatabaseSetup");
|
||||
|
||||
await MigrateAsync(db, logger);
|
||||
await SeedDefaultChannelAsync(db, logger);
|
||||
}
|
||||
|
||||
private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await db.Database.CanConnectAsync())
|
||||
await HandleLegacyDatabaseAsync(db, logger);
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
logger.LogInformation("Database migrated successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Database migration failed.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleLegacyDatabaseAsync(EchoHubDbContext db, ILogger logger)
|
||||
{
|
||||
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();
|
||||
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedDefaultChannelAsync(EchoHubDbContext db, ILogger logger)
|
||||
{
|
||||
if (await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
|
||||
return;
|
||||
|
||||
db.Channels.Add(new Channel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = HubConstants.DefaultChannel,
|
||||
Topic = "General discussion",
|
||||
CreatedByUserId = Guid.Empty,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Default channel '{Channel}' created.", HubConstants.DefaultChannel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace EchoHub.Server.Setup;
|
||||
|
||||
public static class FirstRunSetup
|
||||
{
|
||||
public static void EnsureAppSettings()
|
||||
{
|
||||
var contentRoot = Directory.GetCurrentDirectory();
|
||||
var settingsPath = Path.Combine(contentRoot, "appsettings.json");
|
||||
var examplePath = Path.Combine(contentRoot, "appsettings.example.json");
|
||||
|
||||
if (!File.Exists(settingsPath) && File.Exists(examplePath))
|
||||
{
|
||||
File.Copy(examplePath, settingsPath);
|
||||
Console.WriteLine("Created appsettings.json from example config.");
|
||||
}
|
||||
|
||||
if (!File.Exists(settingsPath))
|
||||
return;
|
||||
|
||||
EnsureJwtSecret(settingsPath);
|
||||
}
|
||||
|
||||
private static void EnsureJwtSecret(string settingsPath)
|
||||
{
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
var root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip });
|
||||
if (root is null)
|
||||
return;
|
||||
|
||||
var currentSecret = root["Jwt"]?["Secret"]?.GetValue<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(currentSecret) && !currentSecret.StartsWith("CHANGE_ME"))
|
||||
return;
|
||||
|
||||
var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
|
||||
root["Jwt"] ??= new JsonObject();
|
||||
root["Jwt"]!["Secret"] = secret;
|
||||
|
||||
var writeOptions = new JsonSerializerOptions { WriteIndented = true };
|
||||
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
|
||||
Console.WriteLine("Generated new JWT secret in appsettings.json.");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,28 @@
|
||||
"Name": "My EchoHub Server",
|
||||
"Description": "A self-hosted EchoHub chat server"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" },
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "logs/echohub-server-.log",
|
||||
"rollingInterval": "Day",
|
||||
"retainedFileCountLimit": 14,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user