Refactor and update various components of EchoHub.Server

- Updated launchSettings.json for consistency.
- Refactored FileValidationHelper to improve image validation logic.
- Enhanced ServerDirectoryService for better connection handling and user count updates.
- Improved DatabaseSetup for legacy database handling and seeding default channels.
- Refined FirstRunSetup to ensure JWT secret generation.
- Removed appsettings.Development.json as it is no longer needed.
- Updated EchoHub.Tests project file for consistency.
- Added unit tests for FileValidationHelper and PresenceTracker with improved assertions.
- Updated ValidationConstantsTests to ensure regex validations are correct.
- Cleaned up solution file formatting for better readability.
This commit is contained in:
HueByte
2026-02-19 10:12:28 +01:00
parent 32e01e8d71
commit 9975f4e4be
57 changed files with 4401 additions and 4378 deletions
+7 -7
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
</Project>
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
</Project>
File diff suppressed because it is too large Load Diff
+32 -32
View File
@@ -1,32 +1,32 @@
using Serilog;
using Terminal.Gui.App;
namespace EchoHub.Client;
/// <summary>
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
/// Runs async work on a background thread and routes exceptions to the UI.
/// </summary>
public static class AsyncRunner
{
public static void Run(
IApplication app,
Func<Task> work,
Action<string> showError,
string errorPrefix,
string? logContext = null)
{
Task.Run(async () =>
{
try
{
await work();
}
catch (Exception ex)
{
Log.Error(ex, "{Context} failed", logContext ?? errorPrefix);
app.Invoke(() => showError($"{errorPrefix}: {ex.Message}"));
}
});
}
}
using Serilog;
using Terminal.Gui.App;
namespace EchoHub.Client;
/// <summary>
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
/// Runs async work on a background thread and routes exceptions to the UI.
/// </summary>
public static class AsyncRunner
{
public static void Run(
IApplication app,
Func<Task> work,
Action<string> showError,
string errorPrefix,
string? logContext = null)
{
Task.Run(async () =>
{
try
{
await work();
}
catch (Exception ex)
{
Log.Error(ex, "{Context} failed", logContext ?? errorPrefix);
app.Invoke(() => showError($"{errorPrefix}: {ex.Message}"));
}
});
}
}
+32 -32
View File
@@ -1,32 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<EmbeddedResource Include="appsettings.example.json">
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
</EmbeddedResource>
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<EmbeddedResource Include="appsettings.example.json">
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
</EmbeddedResource>
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+56 -56
View File
@@ -1,56 +1,56 @@
using EchoHub.Client;
using EchoHub.Client.Config;
using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration;
using Serilog;
using Terminal.Gui.App;
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
using var stream = typeof(AppOrchestrator).Assembly
.GetManifestResourceStream("EchoHub.Client.appsettings.example.json");
if (stream is not null)
{
using var file = File.Create(appSettingsPath);
stream.CopyTo(file);
}
}
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.Build();
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.CreateLogger();
Log.Information("EchoHub client starting");
try
{
var config = ConfigManager.Load();
Log.Information("Configuration loaded, active theme: {Theme}", config.ActiveTheme);
var app = Application.Create().Init();
var theme = ThemeManager.GetTheme(config.ActiveTheme);
ThemeManager.ApplyTheme(theme);
using var orchestrator = new AppOrchestrator(app, config);
app.Run(orchestrator.MainWindow);
app.Dispose();
}
catch (Exception ex)
{
Log.Fatal(ex, "EchoHub client crashed");
throw;
}
finally
{
Log.Information("EchoHub client shutting down");
Log.CloseAndFlush();
}
using EchoHub.Client;
using EchoHub.Client.Config;
using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration;
using Serilog;
using Terminal.Gui.App;
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
using var stream = typeof(AppOrchestrator).Assembly
.GetManifestResourceStream("EchoHub.Client.appsettings.example.json");
if (stream is not null)
{
using var file = File.Create(appSettingsPath);
stream.CopyTo(file);
}
}
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.Build();
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.CreateLogger();
Log.Information("EchoHub client starting");
try
{
var config = ConfigManager.Load();
Log.Information("Configuration loaded, active theme: {Theme}", config.ActiveTheme);
var app = Application.Create().Init();
var theme = ThemeManager.GetTheme(config.ActiveTheme);
ThemeManager.ApplyTheme(theme);
using var orchestrator = new AppOrchestrator(app, config);
app.Run(orchestrator.MainWindow);
app.Dispose();
}
catch (Exception ex)
{
Log.Fatal(ex, "EchoHub client crashed");
throw;
}
finally
{
Log.Information("EchoHub client shutting down");
Log.CloseAndFlush();
}
+280 -280
View File
@@ -1,280 +1,280 @@
using System.Collections;
using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions;
using Terminal.Gui.Drawing;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// A colored text segment within a chat line.
/// </summary>
public record ChatSegment(string Text, Attribute? Color);
/// <summary>
/// A single line in the chat, composed of colored segments.
/// </summary>
public partial class ChatLine
{
public List<ChatSegment> Segments { get; }
public int TextLength { get; }
public ChatLine(string plainText)
{
Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.Length;
}
public ChatLine(List<ChatSegment> segments)
{
Segments = segments;
TextLength = segments.Sum(s => s.Text.Length);
}
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
/// <summary>
/// Wrap this line into multiple lines that fit within the given width.
/// Continuation lines are indented with the specified number of spaces.
/// </summary>
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
{
if (width <= 0 || TextLength <= width)
return [this];
var results = new List<ChatLine>();
var currentSegments = new List<ChatSegment>();
int col = 0;
foreach (var segment in Segments)
{
int segPos = 0;
while (segPos < segment.Text.Length)
{
int remaining = width - col;
if (remaining <= 0)
{
// Emit current line and start a new one
results.Add(new ChatLine(currentSegments));
currentSegments = [];
// Add indent for continuation
if (continuationIndent > 0)
{
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent;
}
else
{
col = 0;
}
remaining = width - col;
}
int take = Math.Min(segment.Text.Length - segPos, remaining);
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color));
col += take;
segPos += take;
}
}
if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments));
return results;
}
/// <summary>
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
/// </summary>
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
{
var segments = new List<ChatSegment>();
var regex = AnsiColorRegex();
int lastIndex = 0;
Attribute? currentColor = defaultAttr;
foreach (Match match in regex.Matches(ansiText))
{
// Add any text before this escape sequence
if (match.Index > lastIndex)
{
var text = ansiText[lastIndex..match.Index];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
// Parse the escape sequence
if (match.Groups[1].Value == "0")
{
// Reset
currentColor = defaultAttr;
}
else if (match.Groups[2].Success)
{
// 38;2;R;G;B — 24-bit foreground color
var r = int.Parse(match.Groups[3].Value);
var g = int.Parse(match.Groups[4].Value);
var b = int.Parse(match.Groups[5].Value);
currentColor = new Attribute(new Color(r, g, b), Color.Black);
}
lastIndex = match.Index + match.Length;
}
// Add remaining text
if (lastIndex < ansiText.Length)
{
var text = ansiText[lastIndex..];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
}
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
}
/// <summary>
/// Custom list data source for chat messages with per-character coloring.
/// </summary>
public class ChatListSource : IListDataSource
{
private readonly List<ChatLine> _lines = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _lines.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Add(ChatLine line)
{
_lines.Add(line);
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void AddRange(IEnumerable<ChatLine> lines)
{
foreach (var line in lines)
{
_lines.Add(line);
UpdateMaxLength(line);
}
RaiseCollectionChanged();
}
public void InsertRange(int index, IEnumerable<ChatLine> lines)
{
var items = lines.ToList();
_lines.InsertRange(index, items);
foreach (var line in items)
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void Clear()
{
_lines.Clear();
MaxItemLength = 0;
RaiseCollectionChanged();
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
int charPos = 0;
int drawnChars = 0;
foreach (var segment in chatLine.Segments)
{
var attr = segment.Color ?? normalAttr;
listView.SetAttribute(attr);
foreach (var ch in segment.Text)
{
if (charPos >= viewportX && drawnChars < width)
{
listView.AddRune(new Rune(ch));
drawnChars++;
}
charPos++;
}
}
// Fill remaining width with spaces using default colors
listView.SetAttribute(normalAttr);
while (drawnChars < width)
{
listView.AddRune(new Rune(' '));
drawnChars++;
}
}
private void UpdateMaxLength(ChatLine line)
{
if (line.TextLength > MaxItemLength)
MaxItemLength = line.TextLength;
}
private void RaiseCollectionChanged()
{
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public void Dispose() { }
}
/// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
public static class ChatColors
{
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
}
/// <summary>
/// Helper to parse hex colors to Terminal.Gui Attributes.
/// </summary>
public static class ColorHelper
{
public static Attribute? ParseHexColor(string? hex)
{
if (string.IsNullOrWhiteSpace(hex))
return null;
hex = hex.TrimStart('#');
if (hex.Length != 6)
return null;
try
{
var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16);
return new Attribute(new Color(r, g, b), Color.Black);
}
catch
{
return null;
}
}
}
using System.Collections;
using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions;
using Terminal.Gui.Drawing;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// A colored text segment within a chat line.
/// </summary>
public record ChatSegment(string Text, Attribute? Color);
/// <summary>
/// A single line in the chat, composed of colored segments.
/// </summary>
public partial class ChatLine
{
public List<ChatSegment> Segments { get; }
public int TextLength { get; }
public ChatLine(string plainText)
{
Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.Length;
}
public ChatLine(List<ChatSegment> segments)
{
Segments = segments;
TextLength = segments.Sum(s => s.Text.Length);
}
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
/// <summary>
/// Wrap this line into multiple lines that fit within the given width.
/// Continuation lines are indented with the specified number of spaces.
/// </summary>
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
{
if (width <= 0 || TextLength <= width)
return [this];
var results = new List<ChatLine>();
var currentSegments = new List<ChatSegment>();
int col = 0;
foreach (var segment in Segments)
{
int segPos = 0;
while (segPos < segment.Text.Length)
{
int remaining = width - col;
if (remaining <= 0)
{
// Emit current line and start a new one
results.Add(new ChatLine(currentSegments));
currentSegments = [];
// Add indent for continuation
if (continuationIndent > 0)
{
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent;
}
else
{
col = 0;
}
remaining = width - col;
}
int take = Math.Min(segment.Text.Length - segPos, remaining);
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color));
col += take;
segPos += take;
}
}
if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments));
return results;
}
/// <summary>
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
/// </summary>
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
{
var segments = new List<ChatSegment>();
var regex = AnsiColorRegex();
int lastIndex = 0;
Attribute? currentColor = defaultAttr;
foreach (Match match in regex.Matches(ansiText))
{
// Add any text before this escape sequence
if (match.Index > lastIndex)
{
var text = ansiText[lastIndex..match.Index];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
// Parse the escape sequence
if (match.Groups[1].Value == "0")
{
// Reset
currentColor = defaultAttr;
}
else if (match.Groups[2].Success)
{
// 38;2;R;G;B — 24-bit foreground color
var r = int.Parse(match.Groups[3].Value);
var g = int.Parse(match.Groups[4].Value);
var b = int.Parse(match.Groups[5].Value);
currentColor = new Attribute(new Color(r, g, b), Color.Black);
}
lastIndex = match.Index + match.Length;
}
// Add remaining text
if (lastIndex < ansiText.Length)
{
var text = ansiText[lastIndex..];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
}
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
}
/// <summary>
/// Custom list data source for chat messages with per-character coloring.
/// </summary>
public class ChatListSource : IListDataSource
{
private readonly List<ChatLine> _lines = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _lines.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Add(ChatLine line)
{
_lines.Add(line);
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void AddRange(IEnumerable<ChatLine> lines)
{
foreach (var line in lines)
{
_lines.Add(line);
UpdateMaxLength(line);
}
RaiseCollectionChanged();
}
public void InsertRange(int index, IEnumerable<ChatLine> lines)
{
var items = lines.ToList();
_lines.InsertRange(index, items);
foreach (var line in items)
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void Clear()
{
_lines.Clear();
MaxItemLength = 0;
RaiseCollectionChanged();
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
int charPos = 0;
int drawnChars = 0;
foreach (var segment in chatLine.Segments)
{
var attr = segment.Color ?? normalAttr;
listView.SetAttribute(attr);
foreach (var ch in segment.Text)
{
if (charPos >= viewportX && drawnChars < width)
{
listView.AddRune(new Rune(ch));
drawnChars++;
}
charPos++;
}
}
// Fill remaining width with spaces using default colors
listView.SetAttribute(normalAttr);
while (drawnChars < width)
{
listView.AddRune(new Rune(' '));
drawnChars++;
}
}
private void UpdateMaxLength(ChatLine line)
{
if (line.TextLength > MaxItemLength)
MaxItemLength = line.TextLength;
}
private void RaiseCollectionChanged()
{
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public void Dispose() { }
}
/// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
public static class ChatColors
{
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
}
/// <summary>
/// Helper to parse hex colors to Terminal.Gui Attributes.
/// </summary>
public static class ColorHelper
{
public static Attribute? ParseHexColor(string? hex)
{
if (string.IsNullOrWhiteSpace(hex))
return null;
hex = hex.TrimStart('#');
if (hex.Length != 6)
return null;
try
{
var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16);
return new Attribute(new Color(r, g, b), Color.Black);
}
catch
{
return null;
}
}
}
+77 -77
View File
@@ -1,77 +1,77 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
public record CreateChannelResult(string Name, string? Topic);
public sealed class CreateChannelDialog
{
public static CreateChannelResult? Show(IApplication app)
{
CreateChannelResult? result = null;
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 };
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
var hintLabel = new Label
{
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
X = 1,
Y = 5,
};
var createButton = new Button
{
Text = "Create",
IsDefault = true,
X = Pos.Center() - 10,
Y = 7
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 7
};
createButton.Accepting += (s, e) =>
{
var name = nameField.Text?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(name))
{
MessageBox.ErrorQuery(app, "Error", "Channel name is required.", "OK");
return;
}
var topic = topicField.Text?.Trim();
if (string.IsNullOrWhiteSpace(topic))
topic = null;
result = new CreateChannelResult(name, topic);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton);
nameField.SetFocus();
app.Run(dialog);
return result;
}
}
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
public record CreateChannelResult(string Name, string? Topic);
public sealed class CreateChannelDialog
{
public static CreateChannelResult? Show(IApplication app)
{
CreateChannelResult? result = null;
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 };
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
var hintLabel = new Label
{
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
X = 1,
Y = 5,
};
var createButton = new Button
{
Text = "Create",
IsDefault = true,
X = Pos.Center() - 10,
Y = 7
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 7
};
createButton.Accepting += (s, e) =>
{
var name = nameField.Text?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(name))
{
MessageBox.ErrorQuery(app, "Error", "Channel name is required.", "OK");
return;
}
var topic = topicField.Text?.Trim();
if (string.IsNullOrWhiteSpace(topic))
topic = null;
result = new CreateChannelResult(name, topic);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton);
nameField.SetFocus();
app.Run(dialog);
return result;
}
}
+246 -246
View File
@@ -1,246 +1,246 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// Action selected by the user in their own profile dialog.
/// </summary>
public enum ProfileAction
{
Close,
EditProfile,
SetStatus
}
/// <summary>
/// Dialog for viewing a user's server profile.
/// Shows Edit Profile / Set Status buttons when viewing own profile.
/// </summary>
public sealed class ProfileViewDialog
{
/// <summary>
/// Show a read-only profile view for another user.
/// </summary>
public static void Show(IApplication app, UserProfileDto? profile)
{
ShowInternal(app, profile, isOwnProfile: false);
}
/// <summary>
/// Show the profile view for the current user with action buttons.
/// Returns the action the user selected.
/// </summary>
public static ProfileAction ShowOwn(
IApplication app,
UserProfileDto? profile,
UserStatus currentStatus,
string? currentStatusMessage)
{
return ShowInternal(app, profile, isOwnProfile: true, currentStatus, currentStatusMessage);
}
private static ProfileAction ShowInternal(
IApplication app,
UserProfileDto? profile,
bool isOwnProfile,
UserStatus? currentStatus = null,
string? currentStatusMessage = null)
{
if (profile is null)
{
MessageBox.ErrorQuery(app, "Profile", "User not found.", "OK");
return ProfileAction.Close;
}
var action = ProfileAction.Close;
var dialog = new Dialog
{
Title = isOwnProfile ? "My Profile" : $"Profile \u2014 {profile.Username}",
Width = 50,
Height = 20
};
int row = 0;
// Username
var usernameLabel = new Label { Text = "Username:", X = 1, Y = row };
var usernameValue = new Label { Text = profile.Username, X = 14, Y = row };
usernameValue.SetScheme(new Scheme
{
Normal = new Attribute(Color.BrightYellow, Color.Blue)
});
dialog.Add(usernameLabel, usernameValue);
row++;
// Display Name
var nameLabel = new Label { Text = "Name:", X = 1, Y = row };
var nameValue = new Label { Text = profile.DisplayName ?? "-", X = 14, Y = row };
dialog.Add(nameLabel, nameValue);
row++;
// Status — use live status for own profile, stored status for others
var displayStatus = isOwnProfile && currentStatus.HasValue ? currentStatus.Value : profile.Status;
var displayStatusMsg = isOwnProfile ? currentStatusMessage : profile.StatusMessage;
var statusLabel = new Label { Text = "Status:", X = 1, Y = row };
var statusText = FormatStatus(displayStatus);
var statusValue = new Label { Text = statusText, X = 14, Y = row };
statusValue.SetScheme(new Scheme
{
Normal = new Attribute(GetStatusColor(displayStatus), Color.Blue)
});
dialog.Add(statusLabel, statusValue);
row++;
// Status Message
if (!string.IsNullOrWhiteSpace(displayStatusMsg))
{
var msgLabel = new Label { Text = "Message:", X = 1, Y = row };
var msgValue = new Label { Text = displayStatusMsg, X = 14, Y = row, Width = Dim.Fill(2) };
dialog.Add(msgLabel, msgValue);
row++;
}
// Color
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
colorValue.SetScheme(new Scheme { Normal = colorAttr });
dialog.Add(colorLabel, colorValue);
row++;
// Bio
row++;
var bioLabel = new Label { Text = "Bio:", X = 1, Y = row };
dialog.Add(bioLabel);
row++;
var bioView = new TextView
{
X = 1,
Y = row,
Width = Dim.Fill(2),
Height = 3,
Text = profile.Bio ?? "-",
ReadOnly = true,
WordWrap = true
};
bioView.SetScheme(new Scheme
{
Normal = new Attribute(Color.White, Color.DarkGray),
Focus = new Attribute(Color.White, Color.DarkGray)
});
dialog.Add(bioView);
row += 3;
// ASCII Avatar
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
{
row++;
var avatarLines = profile.AvatarAscii.Split('\n').Length;
var avatarHeight = Math.Min(avatarLines + 2, 6);
var avatarFrame = new FrameView
{
Title = "Avatar",
X = 1,
Y = row,
Width = Dim.Fill(2),
Height = avatarHeight
};
avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 });
dialog.Add(avatarFrame);
// Grow dialog to fit avatar
dialog.Height = row + avatarHeight + 4;
}
// Buttons
if (isOwnProfile)
{
var editButton = new Button
{
Text = "Edit Profile",
X = Pos.Center() - 20,
Y = Pos.AnchorEnd(2)
};
editButton.Accepting += (s, e) =>
{
action = ProfileAction.EditProfile;
e.Handled = true;
app.RequestStop();
};
var statusButton = new Button
{
Text = "Set Status",
X = Pos.Center() - 4,
Y = Pos.AnchorEnd(2)
};
statusButton.Accepting += (s, e) =>
{
action = ProfileAction.SetStatus;
e.Handled = true;
app.RequestStop();
};
var closeButton = new Button
{
Text = "Close",
IsDefault = true,
X = Pos.Center() + 13,
Y = Pos.AnchorEnd(2)
};
closeButton.Accepting += (s, e) =>
{
action = ProfileAction.Close;
e.Handled = true;
app.RequestStop();
};
dialog.Add(editButton, statusButton, closeButton);
}
else
{
var closeButton = new Button
{
Text = "Close",
IsDefault = true,
X = Pos.Center(),
Y = Pos.AnchorEnd(2)
};
closeButton.Accepting += (s, e) =>
{
e.Handled = true;
app.RequestStop();
};
dialog.Add(closeButton);
}
app.Run(dialog);
return action;
}
private static string FormatStatus(UserStatus status) => status switch
{
UserStatus.Online => "\u25cf Online",
UserStatus.Away => "\u25cf Away",
UserStatus.DoNotDisturb => "\u25cf Do Not Disturb",
UserStatus.Invisible => "\u25cb Invisible",
_ => "\u25cf Unknown"
};
private static Color GetStatusColor(UserStatus status) => status switch
{
UserStatus.Online => Color.BrightGreen,
UserStatus.Away => Color.BrightYellow,
UserStatus.DoNotDisturb => Color.BrightRed,
UserStatus.Invisible => Color.Gray,
_ => Color.White
};
}
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// Action selected by the user in their own profile dialog.
/// </summary>
public enum ProfileAction
{
Close,
EditProfile,
SetStatus
}
/// <summary>
/// Dialog for viewing a user's server profile.
/// Shows Edit Profile / Set Status buttons when viewing own profile.
/// </summary>
public sealed class ProfileViewDialog
{
/// <summary>
/// Show a read-only profile view for another user.
/// </summary>
public static void Show(IApplication app, UserProfileDto? profile)
{
ShowInternal(app, profile, isOwnProfile: false);
}
/// <summary>
/// Show the profile view for the current user with action buttons.
/// Returns the action the user selected.
/// </summary>
public static ProfileAction ShowOwn(
IApplication app,
UserProfileDto? profile,
UserStatus currentStatus,
string? currentStatusMessage)
{
return ShowInternal(app, profile, isOwnProfile: true, currentStatus, currentStatusMessage);
}
private static ProfileAction ShowInternal(
IApplication app,
UserProfileDto? profile,
bool isOwnProfile,
UserStatus? currentStatus = null,
string? currentStatusMessage = null)
{
if (profile is null)
{
MessageBox.ErrorQuery(app, "Profile", "User not found.", "OK");
return ProfileAction.Close;
}
var action = ProfileAction.Close;
var dialog = new Dialog
{
Title = isOwnProfile ? "My Profile" : $"Profile \u2014 {profile.Username}",
Width = 50,
Height = 20
};
int row = 0;
// Username
var usernameLabel = new Label { Text = "Username:", X = 1, Y = row };
var usernameValue = new Label { Text = profile.Username, X = 14, Y = row };
usernameValue.SetScheme(new Scheme
{
Normal = new Attribute(Color.BrightYellow, Color.Blue)
});
dialog.Add(usernameLabel, usernameValue);
row++;
// Display Name
var nameLabel = new Label { Text = "Name:", X = 1, Y = row };
var nameValue = new Label { Text = profile.DisplayName ?? "-", X = 14, Y = row };
dialog.Add(nameLabel, nameValue);
row++;
// Status — use live status for own profile, stored status for others
var displayStatus = isOwnProfile && currentStatus.HasValue ? currentStatus.Value : profile.Status;
var displayStatusMsg = isOwnProfile ? currentStatusMessage : profile.StatusMessage;
var statusLabel = new Label { Text = "Status:", X = 1, Y = row };
var statusText = FormatStatus(displayStatus);
var statusValue = new Label { Text = statusText, X = 14, Y = row };
statusValue.SetScheme(new Scheme
{
Normal = new Attribute(GetStatusColor(displayStatus), Color.Blue)
});
dialog.Add(statusLabel, statusValue);
row++;
// Status Message
if (!string.IsNullOrWhiteSpace(displayStatusMsg))
{
var msgLabel = new Label { Text = "Message:", X = 1, Y = row };
var msgValue = new Label { Text = displayStatusMsg, X = 14, Y = row, Width = Dim.Fill(2) };
dialog.Add(msgLabel, msgValue);
row++;
}
// Color
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
colorValue.SetScheme(new Scheme { Normal = colorAttr });
dialog.Add(colorLabel, colorValue);
row++;
// Bio
row++;
var bioLabel = new Label { Text = "Bio:", X = 1, Y = row };
dialog.Add(bioLabel);
row++;
var bioView = new TextView
{
X = 1,
Y = row,
Width = Dim.Fill(2),
Height = 3,
Text = profile.Bio ?? "-",
ReadOnly = true,
WordWrap = true
};
bioView.SetScheme(new Scheme
{
Normal = new Attribute(Color.White, Color.DarkGray),
Focus = new Attribute(Color.White, Color.DarkGray)
});
dialog.Add(bioView);
row += 3;
// ASCII Avatar
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
{
row++;
var avatarLines = profile.AvatarAscii.Split('\n').Length;
var avatarHeight = Math.Min(avatarLines + 2, 6);
var avatarFrame = new FrameView
{
Title = "Avatar",
X = 1,
Y = row,
Width = Dim.Fill(2),
Height = avatarHeight
};
avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 });
dialog.Add(avatarFrame);
// Grow dialog to fit avatar
dialog.Height = row + avatarHeight + 4;
}
// Buttons
if (isOwnProfile)
{
var editButton = new Button
{
Text = "Edit Profile",
X = Pos.Center() - 20,
Y = Pos.AnchorEnd(2)
};
editButton.Accepting += (s, e) =>
{
action = ProfileAction.EditProfile;
e.Handled = true;
app.RequestStop();
};
var statusButton = new Button
{
Text = "Set Status",
X = Pos.Center() - 4,
Y = Pos.AnchorEnd(2)
};
statusButton.Accepting += (s, e) =>
{
action = ProfileAction.SetStatus;
e.Handled = true;
app.RequestStop();
};
var closeButton = new Button
{
Text = "Close",
IsDefault = true,
X = Pos.Center() + 13,
Y = Pos.AnchorEnd(2)
};
closeButton.Accepting += (s, e) =>
{
action = ProfileAction.Close;
e.Handled = true;
app.RequestStop();
};
dialog.Add(editButton, statusButton, closeButton);
}
else
{
var closeButton = new Button
{
Text = "Close",
IsDefault = true,
X = Pos.Center(),
Y = Pos.AnchorEnd(2)
};
closeButton.Accepting += (s, e) =>
{
e.Handled = true;
app.RequestStop();
};
dialog.Add(closeButton);
}
app.Run(dialog);
return action;
}
private static string FormatStatus(UserStatus status) => status switch
{
UserStatus.Online => "\u25cf Online",
UserStatus.Away => "\u25cf Away",
UserStatus.DoNotDisturb => "\u25cf Do Not Disturb",
UserStatus.Invisible => "\u25cb Invisible",
_ => "\u25cf Unknown"
};
private static Color GetStatusColor(UserStatus status) => status switch
{
UserStatus.Online => Color.BrightGreen,
UserStatus.Away => Color.BrightYellow,
UserStatus.DoNotDisturb => Color.BrightRed,
UserStatus.Invisible => Color.Gray,
_ => Color.White
};
}
+22 -22
View File
@@ -1,22 +1,22 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "logs/echohub-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
]
}
}
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "logs/echohub-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
]
}
}
@@ -1,26 +1,26 @@
using System.Text.RegularExpressions;
namespace EchoHub.Core.Constants;
public static partial class ValidationConstants
{
public const string UsernamePattern = @"^[a-zA-Z0-9_-]{3,50}$";
public const string ChannelNamePattern = @"^[a-zA-Z0-9_-]{2,100}$";
public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$";
public const int MaxPasswordLength = 128;
public const int MaxDisplayNameLength = 100;
public const int MaxBioLength = 500;
public const int MaxStatusMessageLength = 100;
public const int MaxChannelTopicLength = 500;
public const int MaxHistoryCount = 100;
[GeneratedRegex(UsernamePattern)]
public static partial Regex UsernameRegex();
[GeneratedRegex(ChannelNamePattern)]
public static partial Regex ChannelNameRegex();
[GeneratedRegex(HexColorPattern)]
public static partial Regex HexColorRegex();
}
using System.Text.RegularExpressions;
namespace EchoHub.Core.Constants;
public static partial class ValidationConstants
{
public const string UsernamePattern = @"^[a-zA-Z0-9_-]{3,50}$";
public const string ChannelNamePattern = @"^[a-zA-Z0-9_-]{2,100}$";
public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$";
public const int MaxPasswordLength = 128;
public const int MaxDisplayNameLength = 100;
public const int MaxBioLength = 500;
public const int MaxStatusMessageLength = 100;
public const int MaxChannelTopicLength = 500;
public const int MaxHistoryCount = 100;
[GeneratedRegex(UsernamePattern)]
public static partial Regex UsernameRegex();
[GeneratedRegex(ChannelNamePattern)]
public static partial Regex ChannelNameRegex();
[GeneratedRegex(HexColorPattern)]
public static partial Regex HexColorRegex();
}
+5 -5
View File
@@ -1,5 +1,5 @@
namespace EchoHub.Core.DTOs;
public record ErrorResponse(string Error, string? Detail = null);
public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit);
namespace EchoHub.Core.DTOs;
public record ErrorResponse(string Error, string? Detail = null);
public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit);
+9 -9
View File
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+17 -17
View File
@@ -1,17 +1,17 @@
namespace EchoHub.Core.Models;
public class RefreshToken
{
public Guid Id { get; set; }
public required string TokenHash { get; set; }
public Guid UserId { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? RevokedAt { get; set; }
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
public bool IsRevoked => RevokedAt is not null;
public bool IsActive => !IsExpired && !IsRevoked;
public User? User { get; set; }
}
namespace EchoHub.Core.Models;
public class RefreshToken
{
public Guid Id { get; set; }
public required string TokenHash { get; set; }
public Guid UserId { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? RevokedAt { get; set; }
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
public bool IsRevoked => RevokedAt is not null;
public bool IsActive => !IsExpired && !IsRevoked;
public User? User { get; set; }
}
+146 -146
View File
@@ -1,146 +1,146 @@
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Auth;
using EchoHub.Server.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/auth")]
[EnableRateLimiting("auth")]
public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase
{
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required."));
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
if (request.Password.Length < 6)
return BadRequest(new ErrorResponse("Password must be at least 6 characters."));
if (request.Password.Length > ValidationConstants.MaxPasswordLength)
return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters."));
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
return Conflict(new ErrorResponse("Username is already taken."));
var user = new User
{
Id = Guid.NewGuid(),
Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
DisplayName = request.DisplayName?.Trim(),
};
db.Users.Add(user);
await db.SaveChangesAsync();
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
var refreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required."));
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ErrorResponse("Invalid username or password."));
user.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
var refreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
return BadRequest(new ErrorResponse("Refresh token is required."));
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
var storedToken = await db.RefreshTokens
.Include(r => r.User)
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
if (storedToken is null || !storedToken.IsActive || storedToken.User is null)
return Unauthorized(new ErrorResponse("Invalid or expired refresh token."));
// Revoke old refresh token (rotation)
storedToken.RevokedAt = DateTimeOffset.UtcNow;
var user = storedToken.User;
user.LastSeenAt = DateTimeOffset.UtcNow;
// Issue new token pair
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
var newRefreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(newRefreshToken),
UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
}
[HttpPost("logout")]
public async Task<IActionResult> Logout([FromBody] RefreshRequest request)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
return BadRequest(new ErrorResponse("Refresh token is required."));
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
if (storedToken is not null && storedToken.IsActive)
{
storedToken.RevokedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
}
return Ok();
}
}
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Auth;
using EchoHub.Server.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/auth")]
[EnableRateLimiting("auth")]
public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase
{
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required."));
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
if (request.Password.Length < 6)
return BadRequest(new ErrorResponse("Password must be at least 6 characters."));
if (request.Password.Length > ValidationConstants.MaxPasswordLength)
return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters."));
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
return Conflict(new ErrorResponse("Username is already taken."));
var user = new User
{
Id = Guid.NewGuid(),
Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
DisplayName = request.DisplayName?.Trim(),
};
db.Users.Add(user);
await db.SaveChangesAsync();
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
var refreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required."));
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ErrorResponse("Invalid username or password."));
user.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
var refreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
return BadRequest(new ErrorResponse("Refresh token is required."));
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
var storedToken = await db.RefreshTokens
.Include(r => r.User)
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
if (storedToken is null || !storedToken.IsActive || storedToken.User is null)
return Unauthorized(new ErrorResponse("Invalid or expired refresh token."));
// Revoke old refresh token (rotation)
storedToken.RevokedAt = DateTimeOffset.UtcNow;
var user = storedToken.User;
user.LastSeenAt = DateTimeOffset.UtcNow;
// Issue new token pair
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
var newRefreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(newRefreshToken),
UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
});
await db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
}
[HttpPost("logout")]
public async Task<IActionResult> Logout([FromBody] RefreshRequest request)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
return BadRequest(new ErrorResponse("Refresh token is required."));
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
if (storedToken is not null && storedToken.IsActive)
{
storedToken.RevokedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
}
return Ok();
}
}
@@ -1,338 +1,338 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Hubs;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/channels")]
[Authorize]
[EnableRateLimiting("general")]
public class ChannelsController(
EchoHubDbContext db,
FileStorageService fileStorage,
ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory,
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
{
offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100);
var total = await db.Channels.CountAsync();
var channels = await db.Channels
.OrderBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id,
c.Name,
c.Topic,
c.Messages.Count,
c.CreatedAt))
.ToListAsync();
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
}
[HttpPost]
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new ErrorResponse("Channel name is required."));
var channelName = request.Name.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
if (await db.Channels.AnyAsync(c => c.Name == channelName))
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = request.Topic?.Trim(),
CreatedByUserId = Guid.Parse(userIdClaim),
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
await hubContext.Clients.All.ChannelUpdated(dto);
return Created($"/api/channels/{channelName}", dto);
}
[HttpPut("{channel}/topic")]
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
dbChannel.Topic = request.Topic?.Trim();
await db.SaveChangesAsync();
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
await hubContext.Clients.Group(channelName).ChannelUpdated(dto);
return Ok(dto);
}
[HttpDelete("{channel}")]
public async Task<IActionResult> DeleteChannel(string channel)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim();
if (channelName == HubConstants.DefaultChannel)
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
db.Channels.Remove(dbChannel);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("{channel}/upload")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> Upload(string channel)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username");
if (userIdClaim is null || usernameClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var channelName = channel.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
return BadRequest(new ErrorResponse("No file uploaded."));
var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
// Detect if file is an image by checking magic bytes
using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream);
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
var messageType = isImage ? MessageType.Image : MessageType.File;
string content;
if (isImage)
{
using var imageStream = System.IO.File.OpenRead(filePath);
content = asciiService.ConvertToAscii(imageStream);
}
else
{
content = file.FileName;
}
var attachmentUrl = $"/api/files/{fileId}";
var sender = await db.Users.FindAsync(userId);
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Type = messageType,
AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName,
SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
db.Messages.Add(message);
await db.SaveChangesAsync();
var messageDto = new MessageDto(
message.Id,
message.Content,
message.SenderUsername,
sender?.NicknameColor,
channelName,
messageType,
attachmentUrl,
file.FileName,
message.SentAt);
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
return Ok(messageDto);
}
[HttpPost("{channel}/send-url")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username");
if (userIdClaim is null || usernameClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var channelName = channel.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (string.IsNullOrWhiteSpace(request.Url))
return BadRequest(new ErrorResponse("URL is required."));
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)
|| (uri.Scheme != "http" && uri.Scheme != "https"))
return BadRequest(new ErrorResponse("Invalid URL. Only http and https are supported."));
// Download image from URL
byte[] imageBytes;
string fileName;
try
{
using var client = httpClientFactory.CreateClient("ImageDownload");
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength;
if (contentLength > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
imageBytes = await response.Content.ReadAsByteArrayAsync();
if (imageBytes.Length > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
{
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
var ext = contentType switch
{
"image/png" => ".png",
"image/jpeg" or "image/jpg" => ".jpg",
"image/gif" => ".gif",
"image/webp" => ".webp",
_ => ".bin"
};
fileName = $"download{ext}";
}
}
catch (TaskCanceledException)
{
return BadRequest(new ErrorResponse("Download timed out. The URL may be unreachable."));
}
catch (HttpRequestException ex)
{
return BadRequest(new ErrorResponse($"Failed to download from URL: {ex.Message}"));
}
// Validate it's actually an image
using var memoryStream = new MemoryStream(imageBytes);
if (!FileValidationHelper.IsValidImage(memoryStream))
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
// Save file and convert to ASCII
var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName);
string content;
using (var imageStream = System.IO.File.OpenRead(filePath))
{
content = asciiService.ConvertToAscii(imageStream);
}
var attachmentUrl = $"/api/files/{fileId}";
var sender = await db.Users.FindAsync(userId);
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Type = MessageType.Image,
AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName,
SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
db.Messages.Add(message);
await db.SaveChangesAsync();
var messageDto = new MessageDto(
message.Id,
message.Content,
message.SenderUsername,
sender?.NicknameColor,
channelName,
MessageType.Image,
attachmentUrl,
fileName,
message.SentAt);
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
return Ok(messageDto);
}
}
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Hubs;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/channels")]
[Authorize]
[EnableRateLimiting("general")]
public class ChannelsController(
EchoHubDbContext db,
FileStorageService fileStorage,
ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory,
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
{
offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100);
var total = await db.Channels.CountAsync();
var channels = await db.Channels
.OrderBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id,
c.Name,
c.Topic,
c.Messages.Count,
c.CreatedAt))
.ToListAsync();
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
}
[HttpPost]
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new ErrorResponse("Channel name is required."));
var channelName = request.Name.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
if (await db.Channels.AnyAsync(c => c.Name == channelName))
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = request.Topic?.Trim(),
CreatedByUserId = Guid.Parse(userIdClaim),
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
await hubContext.Clients.All.ChannelUpdated(dto);
return Created($"/api/channels/{channelName}", dto);
}
[HttpPut("{channel}/topic")]
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
dbChannel.Topic = request.Topic?.Trim();
await db.SaveChangesAsync();
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
await hubContext.Clients.Group(channelName).ChannelUpdated(dto);
return Ok(dto);
}
[HttpDelete("{channel}")]
public async Task<IActionResult> DeleteChannel(string channel)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim();
if (channelName == HubConstants.DefaultChannel)
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
db.Channels.Remove(dbChannel);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("{channel}/upload")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> Upload(string channel)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username");
if (userIdClaim is null || usernameClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var channelName = channel.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
return BadRequest(new ErrorResponse("No file uploaded."));
var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
// Detect if file is an image by checking magic bytes
using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream);
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
var messageType = isImage ? MessageType.Image : MessageType.File;
string content;
if (isImage)
{
using var imageStream = System.IO.File.OpenRead(filePath);
content = asciiService.ConvertToAscii(imageStream);
}
else
{
content = file.FileName;
}
var attachmentUrl = $"/api/files/{fileId}";
var sender = await db.Users.FindAsync(userId);
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Type = messageType,
AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName,
SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
db.Messages.Add(message);
await db.SaveChangesAsync();
var messageDto = new MessageDto(
message.Id,
message.Content,
message.SenderUsername,
sender?.NicknameColor,
channelName,
messageType,
attachmentUrl,
file.FileName,
message.SentAt);
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
return Ok(messageDto);
}
[HttpPost("{channel}/send-url")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username");
if (userIdClaim is null || usernameClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var channelName = channel.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (string.IsNullOrWhiteSpace(request.Url))
return BadRequest(new ErrorResponse("URL is required."));
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)
|| (uri.Scheme != "http" && uri.Scheme != "https"))
return BadRequest(new ErrorResponse("Invalid URL. Only http and https are supported."));
// Download image from URL
byte[] imageBytes;
string fileName;
try
{
using var client = httpClientFactory.CreateClient("ImageDownload");
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength;
if (contentLength > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
imageBytes = await response.Content.ReadAsByteArrayAsync();
if (imageBytes.Length > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
{
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
var ext = contentType switch
{
"image/png" => ".png",
"image/jpeg" or "image/jpg" => ".jpg",
"image/gif" => ".gif",
"image/webp" => ".webp",
_ => ".bin"
};
fileName = $"download{ext}";
}
}
catch (TaskCanceledException)
{
return BadRequest(new ErrorResponse("Download timed out. The URL may be unreachable."));
}
catch (HttpRequestException ex)
{
return BadRequest(new ErrorResponse($"Failed to download from URL: {ex.Message}"));
}
// Validate it's actually an image
using var memoryStream = new MemoryStream(imageBytes);
if (!FileValidationHelper.IsValidImage(memoryStream))
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
// Save file and convert to ASCII
var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName);
string content;
using (var imageStream = System.IO.File.OpenRead(filePath))
{
content = asciiService.ConvertToAscii(imageStream);
}
var attachmentUrl = $"/api/files/{fileId}";
var sender = await db.Users.FindAsync(userId);
var message = new Message
{
Id = Guid.NewGuid(),
Content = content,
Type = MessageType.Image,
AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName,
SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
db.Messages.Add(message);
await db.SaveChangesAsync();
var messageDto = new MessageDto(
message.Id,
message.Content,
message.SenderUsername,
sender?.NicknameColor,
channelName,
MessageType.Image,
attachmentUrl,
fileName,
message.SentAt);
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
return Ok(messageDto);
}
}
@@ -1,40 +1,40 @@
using EchoHub.Core.DTOs;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/files")]
[Authorize]
[EnableRateLimiting("general")]
public class FilesController(FileStorageService fileStorage) : ControllerBase
{
[HttpGet("{fileId}")]
public IActionResult GetFile(string fileId)
{
if (!Guid.TryParse(fileId, out _))
return BadRequest(new ErrorResponse("Invalid file identifier."));
var filePath = fileStorage.GetFilePath(fileId);
if (filePath is null)
return NotFound(new ErrorResponse("File not found."));
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
".txt" => "text/plain",
_ => "application/octet-stream"
};
var fileName = Path.GetFileName(filePath);
return PhysicalFile(filePath, contentType, fileName);
}
}
using EchoHub.Core.DTOs;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/files")]
[Authorize]
[EnableRateLimiting("general")]
public class FilesController(FileStorageService fileStorage) : ControllerBase
{
[HttpGet("{fileId}")]
public IActionResult GetFile(string fileId)
{
if (!Guid.TryParse(fileId, out _))
return BadRequest(new ErrorResponse("Invalid file identifier."));
var filePath = fileStorage.GetFilePath(fileId);
if (filePath is null)
return NotFound(new ErrorResponse("File not found."));
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
".txt" => "text/plain",
_ => "application/octet-stream"
};
var fileName = Path.GetFileName(filePath);
return PhysicalFile(filePath, contentType, fileName);
}
}
@@ -1,26 +1,26 @@
using EchoHub.Core.DTOs;
using EchoHub.Server.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/server")]
public class ServerController(EchoHubDbContext db, IConfiguration config) : ControllerBase
{
[HttpGet("info")]
public async Task<IActionResult> GetInfo()
{
var userCount = await db.Users.CountAsync();
var channelCount = await db.Channels.CountAsync();
var status = new ServerStatusDto(
config["Server:Name"] ?? "EchoHub Server",
config["Server:Description"],
userCount,
channelCount);
return Ok(status);
}
}
using EchoHub.Core.DTOs;
using EchoHub.Server.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/server")]
public class ServerController(EchoHubDbContext db, IConfiguration config) : ControllerBase
{
[HttpGet("info")]
public async Task<IActionResult> GetInfo()
{
var userCount = await db.Users.CountAsync();
var channelCount = await db.Channels.CountAsync();
var status = new ServerStatusDto(
config["Server:Name"] ?? "EchoHub Server",
config["Server:Description"],
userCount,
channelCount);
return Ok(status);
}
}
+117 -117
View File
@@ -1,117 +1,117 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/users")]
[Authorize]
[EnableRateLimiting("general")]
public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase
{
[HttpGet("{username}/profile")]
public async Task<IActionResult> GetProfile(string username)
{
var normalizedUsername = username.ToLowerInvariant().Trim();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
return Ok(ToProfileDto(user));
}
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var user = await db.Users.FindAsync(userId);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
if (request.DisplayName is not null)
{
if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength)
return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."));
user.DisplayName = request.DisplayName.Trim();
}
if (request.Bio is not null)
{
if (request.Bio.Length > ValidationConstants.MaxBioLength)
return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters."));
user.Bio = request.Bio.Trim();
}
if (request.NicknameColor is not null)
{
var color = request.NicknameColor.Trim();
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500)."));
user.NicknameColor = color.Length > 0 ? color : null;
}
await db.SaveChangesAsync();
return Ok(ToProfileDto(user));
}
[HttpPost("avatar")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> UploadAvatar()
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var user = await db.Users.FindAsync(userId);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
return BadRequest(new ErrorResponse("No file uploaded."));
var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxAvatarSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
using var stream = file.OpenReadStream();
if (!FileValidationHelper.IsValidImage(stream))
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
var asciiArt = asciiService.ConvertToAscii(stream);
user.AvatarAscii = asciiArt;
await db.SaveChangesAsync();
return Ok(new AvatarUploadResponse(asciiArt));
}
private static UserProfileDto ToProfileDto(Core.Models.User user) => new(
user.Id,
user.Username,
user.DisplayName,
user.Bio,
user.NicknameColor,
user.AvatarAscii,
user.Status,
user.StatusMessage,
user.CreatedAt,
user.LastSeenAt);
}
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
[ApiController]
[Route("api/users")]
[Authorize]
[EnableRateLimiting("general")]
public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase
{
[HttpGet("{username}/profile")]
public async Task<IActionResult> GetProfile(string username)
{
var normalizedUsername = username.ToLowerInvariant().Trim();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
return Ok(ToProfileDto(user));
}
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var user = await db.Users.FindAsync(userId);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
if (request.DisplayName is not null)
{
if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength)
return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."));
user.DisplayName = request.DisplayName.Trim();
}
if (request.Bio is not null)
{
if (request.Bio.Length > ValidationConstants.MaxBioLength)
return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters."));
user.Bio = request.Bio.Trim();
}
if (request.NicknameColor is not null)
{
var color = request.NicknameColor.Trim();
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500)."));
user.NicknameColor = color.Length > 0 ? color : null;
}
await db.SaveChangesAsync();
return Ok(ToProfileDto(user));
}
[HttpPost("avatar")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> UploadAvatar()
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var user = await db.Users.FindAsync(userId);
if (user is null)
return NotFound(new ErrorResponse("User not found."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
return BadRequest(new ErrorResponse("No file uploaded."));
var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxAvatarSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
using var stream = file.OpenReadStream();
if (!FileValidationHelper.IsValidImage(stream))
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
var asciiArt = asciiService.ConvertToAscii(stream);
user.AvatarAscii = asciiArt;
await db.SaveChangesAsync();
return Ok(new AvatarUploadResponse(asciiArt));
}
private static UserProfileDto ToProfileDto(Core.Models.User user) => new(
user.Id,
user.Username,
user.DisplayName,
user.Bio,
user.NicknameColor,
user.AvatarAscii,
user.Status,
user.StatusMessage,
user.CreatedAt,
user.LastSeenAt);
}
@@ -1,210 +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
}
}
}
// <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
}
}
}
@@ -1,146 +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");
}
}
}
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");
}
}
}
@@ -1,207 +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
}
}
}
// <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
}
}
}
+26 -26
View File
@@ -1,26 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<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="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<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="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+200 -200
View File
@@ -1,200 +1,200 @@
using System.Text;
using System.Threading.RateLimiting;
using EchoHub.Core.Constants;
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 (once) ──────────────────────────────────────────────────
FirstRunSetup.EnsureAppSettings();
// ── Bootstrap logger (replaced by full Serilog once host starts) ────────────
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
// ── Auto-restart loop ───────────────────────────────────────────────────────
const int maxConsecutiveFailures = 5;
var consecutiveFailures = 0;
while (true)
{
var startTime = DateTimeOffset.UtcNow;
try
{
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 configured = builder.Configuration.GetConnectionString("DefaultConnection");
var connectionString = string.IsNullOrWhiteSpace(configured)
? $"Data Source={defaultDbPath}"
: configured;
builder.Services.AddDbContext<EchoHubDbContext>(options =>
options.UseSqlite(connectionString));
// ── JWT Authentication ───────────────────────────────────────────────
var jwtSecret = builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException("Jwt:Secret must be configured.");
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "EchoHub.Server";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "EchoHub.Client";
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
};
// Allow SignalR clients to send the JWT via query string
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(HubConstants.ChatHubPath))
{
context.Token = accessToken;
}
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization();
// ── Controllers + SignalR ────────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddSignalR();
// ── Services ─────────────────────────────────────────────────────────
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<PresenceTracker>();
builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHttpClient("ImageDownload", client =>
{
client.Timeout = TimeSpan.FromSeconds(15);
client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB
});
// ── Rate Limiting ────────────────────────────────────────────────────
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("auth", limiter =>
{
limiter.PermitLimit = 10;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.AddFixedWindowLimiter("upload", limiter =>
{
limiter.PermitLimit = 5;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.AddFixedWindowLimiter("general", limiter =>
{
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
});
// ── CORS ─────────────────────────────────────────────────────────────
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
if (allowedOrigins is { Length: > 0 })
policy.WithOrigins(allowedOrigins);
else
policy.SetIsOriginAllowed(_ => true);
});
});
await using var app = builder.Build();
// ── Database initialization ──────────────────────────────────────────
await DatabaseSetup.InitializeAsync(app.Services);
// ── Middleware ────────────────────────────────────────────────────────
app.UseCors();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// ── Routing ──────────────────────────────────────────────────────────
app.MapControllers();
app.MapHub<ChatHub>(HubConstants.ChatHubPath);
await app.RunAsync();
// Graceful shutdown (Ctrl+C) — exit the loop
Log.Information("Server shut down gracefully");
break;
}
catch (Exception ex)
{
var uptime = DateTimeOffset.UtcNow - startTime;
// If server ran for over 60 seconds, it's a runtime crash — reset failure count
if (uptime.TotalSeconds > 60)
consecutiveFailures = 0;
consecutiveFailures++;
Log.Fatal(ex, "Server crashed after {Uptime:g} (failure {Count}/{Max})",
uptime, consecutiveFailures, maxConsecutiveFailures);
if (consecutiveFailures >= maxConsecutiveFailures)
{
Log.Fatal("Too many consecutive failures, server will not restart");
break;
}
var delaySeconds = Math.Min(Math.Pow(2, consecutiveFailures), 30);
Log.Information("Restarting server in {Delay}s...", delaySeconds);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
}
}
Log.CloseAndFlush();
using System.Text;
using System.Threading.RateLimiting;
using EchoHub.Core.Constants;
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 (once) ──────────────────────────────────────────────────
FirstRunSetup.EnsureAppSettings();
// ── Bootstrap logger (replaced by full Serilog once host starts) ────────────
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
// ── Auto-restart loop ───────────────────────────────────────────────────────
const int maxConsecutiveFailures = 5;
var consecutiveFailures = 0;
while (true)
{
var startTime = DateTimeOffset.UtcNow;
try
{
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 configured = builder.Configuration.GetConnectionString("DefaultConnection");
var connectionString = string.IsNullOrWhiteSpace(configured)
? $"Data Source={defaultDbPath}"
: configured;
builder.Services.AddDbContext<EchoHubDbContext>(options =>
options.UseSqlite(connectionString));
// ── JWT Authentication ───────────────────────────────────────────────
var jwtSecret = builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException("Jwt:Secret must be configured.");
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "EchoHub.Server";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "EchoHub.Client";
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
};
// Allow SignalR clients to send the JWT via query string
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(HubConstants.ChatHubPath))
{
context.Token = accessToken;
}
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization();
// ── Controllers + SignalR ────────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddSignalR();
// ── Services ─────────────────────────────────────────────────────────
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<PresenceTracker>();
builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHttpClient("ImageDownload", client =>
{
client.Timeout = TimeSpan.FromSeconds(15);
client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB
});
// ── Rate Limiting ────────────────────────────────────────────────────
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("auth", limiter =>
{
limiter.PermitLimit = 10;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.AddFixedWindowLimiter("upload", limiter =>
{
limiter.PermitLimit = 5;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.AddFixedWindowLimiter("general", limiter =>
{
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
});
// ── CORS ─────────────────────────────────────────────────────────────
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
if (allowedOrigins is { Length: > 0 })
policy.WithOrigins(allowedOrigins);
else
policy.SetIsOriginAllowed(_ => true);
});
});
await using var app = builder.Build();
// ── Database initialization ──────────────────────────────────────────
await DatabaseSetup.InitializeAsync(app.Services);
// ── Middleware ────────────────────────────────────────────────────────
app.UseCors();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// ── Routing ──────────────────────────────────────────────────────────
app.MapControllers();
app.MapHub<ChatHub>(HubConstants.ChatHubPath);
await app.RunAsync();
// Graceful shutdown (Ctrl+C) — exit the loop
Log.Information("Server shut down gracefully");
break;
}
catch (Exception ex)
{
var uptime = DateTimeOffset.UtcNow - startTime;
// If server ran for over 60 seconds, it's a runtime crash — reset failure count
if (uptime.TotalSeconds > 60)
consecutiveFailures = 0;
consecutiveFailures++;
Log.Fatal(ex, "Server crashed after {Uptime:g} (failure {Count}/{Max})",
uptime, consecutiveFailures, maxConsecutiveFailures);
if (consecutiveFailures >= maxConsecutiveFailures)
{
Log.Fatal("Too many consecutive failures, server will not restart");
break;
}
var delaySeconds = Math.Min(Math.Pow(2, consecutiveFailures), 30);
Log.Information("Restarting server in {Delay}s...", delaySeconds);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
}
}
Log.CloseAndFlush();
@@ -1,23 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7171;http://localhost:5189",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7171;http://localhost:5189",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -1,68 +1,68 @@
namespace EchoHub.Server.Services;
public static class FileValidationHelper
{
private static readonly byte[] JpegMagic = [0xFF, 0xD8, 0xFF];
private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47];
private static readonly byte[] GifMagic = [0x47, 0x49, 0x46];
private static readonly byte[] WebpRiff = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
private static readonly byte[] WebpTag = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
/// <summary>
/// Validates that a stream contains a recognized image format by checking magic bytes.
/// The stream position is reset to the beginning after validation.
/// </summary>
public static bool IsValidImage(Stream stream)
{
if (!stream.CanSeek)
return false;
var originalPosition = stream.Position;
try
{
var header = new byte[12];
var bytesRead = stream.Read(header, 0, header.Length);
if (bytesRead < 3)
return false;
// JPEG: FF D8 FF
if (StartsWith(header, bytesRead, JpegMagic))
return true;
// PNG: 89 50 4E 47
if (bytesRead >= 4 && StartsWith(header, bytesRead, PngMagic))
return true;
// GIF: 47 49 46 (GIF87a or GIF89a)
if (StartsWith(header, bytesRead, GifMagic))
return true;
// WebP: RIFF....WEBP
if (bytesRead >= 12 && StartsWith(header, bytesRead, WebpRiff)
&& header[8] == WebpTag[0] && header[9] == WebpTag[1]
&& header[10] == WebpTag[2] && header[11] == WebpTag[3])
return true;
return false;
}
finally
{
stream.Position = originalPosition;
}
}
private static bool StartsWith(byte[] buffer, int length, byte[] magic)
{
if (length < magic.Length)
return false;
for (int i = 0; i < magic.Length; i++)
{
if (buffer[i] != magic[i])
return false;
}
return true;
}
}
namespace EchoHub.Server.Services;
public static class FileValidationHelper
{
private static readonly byte[] JpegMagic = [0xFF, 0xD8, 0xFF];
private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47];
private static readonly byte[] GifMagic = [0x47, 0x49, 0x46];
private static readonly byte[] WebpRiff = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
private static readonly byte[] WebpTag = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
/// <summary>
/// Validates that a stream contains a recognized image format by checking magic bytes.
/// The stream position is reset to the beginning after validation.
/// </summary>
public static bool IsValidImage(Stream stream)
{
if (!stream.CanSeek)
return false;
var originalPosition = stream.Position;
try
{
var header = new byte[12];
var bytesRead = stream.Read(header, 0, header.Length);
if (bytesRead < 3)
return false;
// JPEG: FF D8 FF
if (StartsWith(header, bytesRead, JpegMagic))
return true;
// PNG: 89 50 4E 47
if (bytesRead >= 4 && StartsWith(header, bytesRead, PngMagic))
return true;
// GIF: 47 49 46 (GIF87a or GIF89a)
if (StartsWith(header, bytesRead, GifMagic))
return true;
// WebP: RIFF....WEBP
if (bytesRead >= 12 && StartsWith(header, bytesRead, WebpRiff)
&& header[8] == WebpTag[0] && header[9] == WebpTag[1]
&& header[10] == WebpTag[2] && header[11] == WebpTag[3])
return true;
return false;
}
finally
{
stream.Position = originalPosition;
}
}
private static bool StartsWith(byte[] buffer, int length, byte[] magic)
{
if (length < magic.Length)
return false;
for (int i = 0; i < magic.Length; i++)
{
if (buffer[i] != magic[i])
return false;
}
return true;
}
}
@@ -1,137 +1,137 @@
using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Server.Services;
public sealed class ServerDirectoryService(
IConfiguration configuration,
PresenceTracker presenceTracker,
ILogger<ServerDirectoryService> logger) : BackgroundService
{
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
private HubConnection? _connection;
private int _lastReportedUserCount = -1;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Yield to let the host finish starting before we log or connect
await Task.Yield();
var isPublic = configuration.GetValue<bool>("Server:PublicServer");
if (!isPublic)
{
logger.LogInformation("PublicServer is disabled — not registering with directory");
return;
}
var host = configuration["Server:PublicHost"];
if (string.IsNullOrWhiteSpace(host))
{
logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration");
return;
}
var serverName = configuration["Server:Name"] ?? "EchoHub Server";
var description = configuration["Server:Description"];
logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
_connection = new HubConnectionBuilder()
.WithUrl(DirectoryHubUrl)
.WithAutomaticReconnect()
.Build();
_connection.Reconnected += async _ =>
{
logger.LogInformation("Reconnected to directory — re-registering server");
await RegisterAsync(serverName, description, host);
};
_connection.Closed += ex =>
{
if (ex is not null)
logger.LogWarning(ex, "Directory connection closed with error");
return Task.CompletedTask;
};
// Initial connection with retry
while (!stoppingToken.IsCancellationRequested)
{
try
{
await _connection.StartAsync(stoppingToken);
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
break;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to connect to directory — retrying in 30s");
await Task.Delay(UpdateInterval, stoppingToken);
}
}
if (stoppingToken.IsCancellationRequested)
return;
// Register on first connect
await RegisterAsync(serverName, description, host);
// Poll user count and send updates
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(UpdateInterval, stoppingToken);
if (_connection.State != HubConnectionState.Connected)
continue;
var currentCount = presenceTracker.GetOnlineUserCount();
if (currentCount == _lastReportedUserCount)
continue;
try
{
await _connection.InvokeAsync("UpdateUserCount", currentCount, stoppingToken);
_lastReportedUserCount = currentCount;
logger.LogDebug("Updated directory user count to {Count}", currentCount);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update user count on directory");
}
}
}
private async Task RegisterAsync(string name, string? description, string host)
{
if (_connection?.State != HubConnectionState.Connected)
return;
try
{
var userCount = presenceTracker.GetOnlineUserCount();
var dto = new RegisterServerDto(name, description, host, userCount);
await _connection.InvokeAsync("RegisterServer", dto);
_lastReportedUserCount = userCount;
logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to register with directory");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
if (_connection is not null)
{
await _connection.DisposeAsync();
_connection = null;
}
await base.StopAsync(cancellationToken);
}
}
internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount);
using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Server.Services;
public sealed class ServerDirectoryService(
IConfiguration configuration,
PresenceTracker presenceTracker,
ILogger<ServerDirectoryService> logger) : BackgroundService
{
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
private HubConnection? _connection;
private int _lastReportedUserCount = -1;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Yield to let the host finish starting before we log or connect
await Task.Yield();
var isPublic = configuration.GetValue<bool>("Server:PublicServer");
if (!isPublic)
{
logger.LogInformation("PublicServer is disabled — not registering with directory");
return;
}
var host = configuration["Server:PublicHost"];
if (string.IsNullOrWhiteSpace(host))
{
logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration");
return;
}
var serverName = configuration["Server:Name"] ?? "EchoHub Server";
var description = configuration["Server:Description"];
logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
_connection = new HubConnectionBuilder()
.WithUrl(DirectoryHubUrl)
.WithAutomaticReconnect()
.Build();
_connection.Reconnected += async _ =>
{
logger.LogInformation("Reconnected to directory — re-registering server");
await RegisterAsync(serverName, description, host);
};
_connection.Closed += ex =>
{
if (ex is not null)
logger.LogWarning(ex, "Directory connection closed with error");
return Task.CompletedTask;
};
// Initial connection with retry
while (!stoppingToken.IsCancellationRequested)
{
try
{
await _connection.StartAsync(stoppingToken);
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
break;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to connect to directory — retrying in 30s");
await Task.Delay(UpdateInterval, stoppingToken);
}
}
if (stoppingToken.IsCancellationRequested)
return;
// Register on first connect
await RegisterAsync(serverName, description, host);
// Poll user count and send updates
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(UpdateInterval, stoppingToken);
if (_connection.State != HubConnectionState.Connected)
continue;
var currentCount = presenceTracker.GetOnlineUserCount();
if (currentCount == _lastReportedUserCount)
continue;
try
{
await _connection.InvokeAsync("UpdateUserCount", currentCount, stoppingToken);
_lastReportedUserCount = currentCount;
logger.LogDebug("Updated directory user count to {Count}", currentCount);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update user count on directory");
}
}
}
private async Task RegisterAsync(string name, string? description, string host)
{
if (_connection?.State != HubConnectionState.Connected)
return;
try
{
var userCount = presenceTracker.GetOnlineUserCount();
var dto = new RegisterServerDto(name, description, host, userCount);
await _connection.InvokeAsync("RegisterServer", dto);
_lastReportedUserCount = userCount;
logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to register with directory");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
if (_connection is not null)
{
await _connection.DisposeAsync();
_connection = null;
}
await base.StopAsync(cancellationToken);
}
}
internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount);
+90 -90
View File
@@ -1,90 +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);
}
}
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);
}
}
+48 -48
View File
@@ -1,48 +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.");
}
}
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,12 +0,0 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
}
}
}
+25 -25
View File
@@ -1,25 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
</ItemGroup>
</Project>
+56 -56
View File
@@ -1,56 +1,56 @@
using EchoHub.Server.Services;
using Xunit;
namespace EchoHub.Tests;
public class FileValidationHelperTests
{
[Fact]
public void IsValidImage_JpegMagicBytes_ReturnsTrue()
{
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
using var stream = new MemoryStream(jpeg);
Assert.True(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_PngMagicBytes_ReturnsTrue()
{
byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
using var stream = new MemoryStream(png);
Assert.True(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_GifMagicBytes_ReturnsTrue()
{
byte[] gif = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
using var stream = new MemoryStream(gif);
Assert.True(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_WebpMagicBytes_ReturnsTrue()
{
byte[] webp = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50];
using var stream = new MemoryStream(webp);
Assert.True(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_RandomBytes_ReturnsFalse()
{
byte[] random = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05];
using var stream = new MemoryStream(random);
Assert.False(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_ResetsStreamPosition()
{
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
using var stream = new MemoryStream(jpeg);
FileValidationHelper.IsValidImage(stream);
Assert.Equal(0, stream.Position);
}
}
using EchoHub.Server.Services;
using Xunit;
namespace EchoHub.Tests;
public class FileValidationHelperTests
{
[Fact]
public void IsValidImage_JpegMagicBytes_ReturnsTrue()
{
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
using var stream = new MemoryStream(jpeg);
Assert.True(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_PngMagicBytes_ReturnsTrue()
{
byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
using var stream = new MemoryStream(png);
Assert.True(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_GifMagicBytes_ReturnsTrue()
{
byte[] gif = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
using var stream = new MemoryStream(gif);
Assert.True(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_WebpMagicBytes_ReturnsTrue()
{
byte[] webp = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50];
using var stream = new MemoryStream(webp);
Assert.True(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_RandomBytes_ReturnsFalse()
{
byte[] random = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05];
using var stream = new MemoryStream(random);
Assert.False(FileValidationHelper.IsValidImage(stream));
}
[Fact]
public void IsValidImage_ResetsStreamPosition()
{
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
using var stream = new MemoryStream(jpeg);
FileValidationHelper.IsValidImage(stream);
Assert.Equal(0, stream.Position);
}
}
+68 -68
View File
@@ -1,68 +1,68 @@
using EchoHub.Server.Services;
using Xunit;
namespace EchoHub.Tests;
public class PresenceTrackerTests
{
[Fact]
public void UserConnected_IsOnline_ReturnsTrue()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
Assert.True(tracker.IsOnline("alice"));
}
[Fact]
public void UserDisconnected_LastConnection_IsOnlineReturnsFalse()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.UserDisconnected("conn1");
Assert.False(tracker.IsOnline("alice"));
}
[Fact]
public void MultipleConnections_DisconnectOne_StillOnline()
{
var tracker = new PresenceTracker();
var userId = Guid.NewGuid();
tracker.UserConnected("conn1", userId, "alice");
tracker.UserConnected("conn2", userId, "alice");
tracker.UserDisconnected("conn1");
Assert.True(tracker.IsOnline("alice"));
}
[Fact]
public void JoinChannel_GetOnlineUsersInChannel_ReturnsUser()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.JoinChannel("alice", "general");
var users = tracker.GetOnlineUsersInChannel("general");
Assert.Contains("alice", users);
}
[Fact]
public void LeaveChannel_UserNoLongerInChannel()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.JoinChannel("alice", "general");
tracker.LeaveChannel("alice", "general");
var users = tracker.GetOnlineUsersInChannel("general");
Assert.DoesNotContain("alice", users);
}
[Fact]
public void GetChannelsForUser_ReturnsJoinedChannels()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.JoinChannel("alice", "general");
tracker.JoinChannel("alice", "random");
var channels = tracker.GetChannelsForUser("alice");
Assert.Contains("general", channels);
Assert.Contains("random", channels);
}
}
using EchoHub.Server.Services;
using Xunit;
namespace EchoHub.Tests;
public class PresenceTrackerTests
{
[Fact]
public void UserConnected_IsOnline_ReturnsTrue()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
Assert.True(tracker.IsOnline("alice"));
}
[Fact]
public void UserDisconnected_LastConnection_IsOnlineReturnsFalse()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.UserDisconnected("conn1");
Assert.False(tracker.IsOnline("alice"));
}
[Fact]
public void MultipleConnections_DisconnectOne_StillOnline()
{
var tracker = new PresenceTracker();
var userId = Guid.NewGuid();
tracker.UserConnected("conn1", userId, "alice");
tracker.UserConnected("conn2", userId, "alice");
tracker.UserDisconnected("conn1");
Assert.True(tracker.IsOnline("alice"));
}
[Fact]
public void JoinChannel_GetOnlineUsersInChannel_ReturnsUser()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.JoinChannel("alice", "general");
var users = tracker.GetOnlineUsersInChannel("general");
Assert.Contains("alice", users);
}
[Fact]
public void LeaveChannel_UserNoLongerInChannel()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.JoinChannel("alice", "general");
tracker.LeaveChannel("alice", "general");
var users = tracker.GetOnlineUsersInChannel("general");
Assert.DoesNotContain("alice", users);
}
[Fact]
public void GetChannelsForUser_ReturnsJoinedChannels()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.JoinChannel("alice", "general");
tracker.JoinChannel("alice", "random");
var channels = tracker.GetChannelsForUser("alice");
Assert.Contains("general", channels);
Assert.Contains("random", channels);
}
}
+46 -46
View File
@@ -1,46 +1,46 @@
using EchoHub.Core.Constants;
using Xunit;
namespace EchoHub.Tests;
public class ValidationConstantsTests
{
[Theory]
[InlineData("alice", true)]
[InlineData("Bob_123", true)]
[InlineData("user-name", true)]
[InlineData("abc", true)]
[InlineData("ab", false)]
[InlineData("", false)]
[InlineData("has space", false)]
[InlineData("has@symbol", false)]
public void UsernameRegex_ValidatesCorrectly(string input, bool expected)
{
var result = ValidationConstants.UsernameRegex().IsMatch(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("general", true)]
[InlineData("my-channel_01", true)]
[InlineData("ab", true)]
[InlineData("a", false)]
[InlineData("has space", false)]
public void ChannelNameRegex_ValidatesCorrectly(string input, bool expected)
{
var result = ValidationConstants.ChannelNameRegex().IsMatch(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("#FF0000", true)]
[InlineData("#aabbcc", true)]
[InlineData("FF0000", false)]
[InlineData("#FFF", false)]
[InlineData("#GGGGGG", false)]
public void HexColorRegex_ValidatesCorrectly(string input, bool expected)
{
var result = ValidationConstants.HexColorRegex().IsMatch(input);
Assert.Equal(expected, result);
}
}
using EchoHub.Core.Constants;
using Xunit;
namespace EchoHub.Tests;
public class ValidationConstantsTests
{
[Theory]
[InlineData("alice", true)]
[InlineData("Bob_123", true)]
[InlineData("user-name", true)]
[InlineData("abc", true)]
[InlineData("ab", false)]
[InlineData("", false)]
[InlineData("has space", false)]
[InlineData("has@symbol", false)]
public void UsernameRegex_ValidatesCorrectly(string input, bool expected)
{
var result = ValidationConstants.UsernameRegex().IsMatch(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("general", true)]
[InlineData("my-channel_01", true)]
[InlineData("ab", true)]
[InlineData("a", false)]
[InlineData("has space", false)]
public void ChannelNameRegex_ValidatesCorrectly(string input, bool expected)
{
var result = ValidationConstants.ChannelNameRegex().IsMatch(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("#FF0000", true)]
[InlineData("#aabbcc", true)]
[InlineData("FF0000", false)]
[InlineData("#FFF", false)]
[InlineData("#GGGGGG", false)]
public void HexColorRegex_ValidatesCorrectly(string input, bool expected)
{
var result = ValidationConstants.HexColorRegex().IsMatch(input);
Assert.Equal(expected, result);
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
<Solution>
<Project Path="EchoHub.Client/EchoHub.Client.csproj" />
<Project Path="EchoHub.Core/EchoHub.Core.csproj" />
<Project Path="EchoHub.Server/EchoHub.Server.csproj" />
<Project Path="EchoHub.Tests/EchoHub.Tests.csproj" />
</Solution>
<Solution>
<Project Path="EchoHub.Client/EchoHub.Client.csproj" />
<Project Path="EchoHub.Core/EchoHub.Core.csproj" />
<Project Path="EchoHub.Server/EchoHub.Server.csproj" />
<Project Path="EchoHub.Tests/EchoHub.Tests.csproj" />
</Solution>