Compare commits

..
Author SHA1 Message Date
Hue 0c2e8eae87 Merge pull request #62 from HueByte/dev_logs_room
Dev logs room
2026-07-17 21:08:48 +02:00
HueByte 75ff10c5fd fix: remove invisible character from migration file 2026-07-17 21:00:34 +02:00
HueByte 46afd2a195 feat: add unit tests for ChannelService, FileStorageService, and UserService 2026-07-17 20:59:59 +02:00
HueByte 38eca99fb1 feat: add server logs functionality with role-based access and system channel management
- Introduced a new migration to add the IsSystem column to the Channels table.
- Updated the DbContext model snapshot to reflect the new IsSystem property.
- Enhanced the ChannelService to manage system channels, including creation, visibility control, and protection against deletion.
- Implemented ServerLogsService to handle live server logging, including reading from log files and managing access based on user roles.
- Created ServerLogsSink to queue log events for streaming to the live log room.
- Developed ServerLogsStreamService to stream log events to clients in real-time.
- Added configuration options for server logs in appsettings.
- Implemented comprehensive unit tests for channel service system channel behavior and server logs functionality.
2026-07-17 20:44:47 +02:00
25 changed files with 2376 additions and 17 deletions
+42 -1
View File
@@ -98,8 +98,11 @@ Access tokens expire after 15 minutes, refresh tokens after 30 days with rotatio
| `Server:Name` | `My EchoHub Server` | Display name shown to clients |
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
| `Server:PublicServer` | `false` | Register on the [public directory](https://echohub.voidcube.cloud/servers) |
| `Server:PublicHost` | *(empty)* | Public hostname for the directory listing (e.g. `chat.example.com:5000`) |
| `Server:PublicHosts` | `[]` | Hostnames advertised to the directory (array, e.g. `["chat.example.com"]`). Required when `PublicServer` is `true` |
| `Server:Tags` | `[]` | Topic tags surfaced in the directory browser (array, e.g. `["community", "gaming"]`) |
| `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) |
| `Server:Registration` | `open` | Registration mode: `open`, `invite` (codes via `/invite`, Admin+), or `closed` |
| `Server:DirectoryClaimPath` | *(empty)* | Overrides where the directory claim token file is stored. Empty = `directory-claim.json` next to the SQLite database. Treat it as a secret and back it up with the database |
### Uploads
@@ -118,6 +121,26 @@ absent or partial `Uploads` section keeps the built-in defaults. See
The server sizes its request-body limits from these values, so raising a limit here is all
that's needed — no separate Kestrel tuning.
### Spam Protection
Per-user flood, duplicate, join, and channel-create limits with auto-mute escalation. Mods and
above are always exempt, and the defaults are lenient enough that a fast typist never trips them.
An absent or partial `Spam` section keeps these defaults.
| Key | Default | Description |
| --- | --- | --- |
| `Spam:Enabled` | `true` | Master switch for all spam protection |
| `Spam:MaxMessagesPerWindow` | `8` | Max messages per `WindowSeconds` before a send is rejected |
| `Spam:WindowSeconds` | `5` | Sliding window (seconds) for the message-rate check |
| `Spam:MaxDuplicateMessages` | `3` | Identical messages in a row tolerated before rejection (E2E rooms are exempt — ciphertext differs each time) |
| `Spam:AutoMuteMinutes` | `5` | Auto-mute duration once a user hits the violation threshold. `0` disables auto-mute (rejections still apply) |
| `Spam:ViolationThreshold` | `5` | Rejected sends within `ViolationWindowMinutes` that trigger an auto-mute |
| `Spam:ViolationWindowMinutes` | `5` | Window (minutes) over which violations accumulate |
| `Spam:MaxJoinsPerWindow` | `25` | Max first-time channel joins per `JoinWindowSeconds` — keep this above your public channel count |
| `Spam:JoinWindowSeconds` | `30` | Sliding window (seconds) for the join-rate check |
| `Spam:MaxChannelCreatesPerWindow` | `3` | Max channel creations per `ChannelCreateWindowMinutes` |
| `Spam:ChannelCreateWindowMinutes` | `10` | Window (minutes) for the channel-create check |
### Encryption
| Key | Default | Description |
@@ -129,6 +152,7 @@ that's needed — no separate Kestrel tuning.
| Key | Default | Description |
| --- | --- | --- |
| `Storage:Path` | *(empty)* | Directory for uploaded file blobs (attachments, avatars). Empty = `uploads/` in the app directory; the Docker image sets it to `/app/data/uploads` on the persistent volume |
| `Storage:CleanupIntervalHours` | `1` | How often the cleanup job runs (hours) |
| `Storage:RetentionDays` | `30` | Days to keep uploaded files before cleanup |
@@ -145,6 +169,23 @@ that's needed — no separate Kestrel tuning.
| `Irc:ServerName` | `echohub` | IRC server name in protocol messages |
| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day |
### Server Logs Room
When enabled, EchoHub auto-creates a read-only system channel and streams log events to it live,
so operators can watch the server from inside the app. Log lines are **never stored as messages**
the rolling Serilog files remain the only persistence, and the room replays recent lines from those
files when someone opens it.
| Key | Default | Description |
| --- | --- | --- |
| `ServerLogs:Enabled` | `true` | Master switch for the live log room |
| `ServerLogs:RoomName` | `server-logs` | Name of the auto-created channel (reserved — users can't create a channel with this name) |
| `ServerLogs:MinRole` | `Mod` | Minimum server role that can see and join the room (`Member`, `Mod`, `Admin`, `Owner`) |
| `ServerLogs:MinLevel` | `Information` | Minimum log level streamed to the room (`Verbose`, `Debug`, `Information`, `Warning`, `Error`, `Fatal`) — affects only the room, not the file/console sinks |
| `ServerLogs:BacklogLines` | `100` | Recent log lines replayed from file when someone opens the room |
| `ServerLogs:LogDirectory` | `logs` | Directory holding the rolling log files (must match the Serilog file sink path below) |
| `ServerLogs:LogFilePattern` | `echohub-server-*.log` | Filename glob for the rolling log files inside `LogDirectory` |
### Logging
EchoHub uses [Serilog](https://serilog.net/) for structured logging — console output + daily rolling files with 14-day retention by default.
@@ -19,6 +19,7 @@ public class ChannelListSource : IListDataSource
private readonly HashSet<string> _protectedChannels = [];
private readonly HashSet<string> _mentionChannels = [];
private readonly HashSet<string> _privateChannels = [];
private readonly HashSet<string> _systemChannels = [];
private string _activeChannel = string.Empty;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
@@ -31,10 +32,12 @@ public class ChannelListSource : IListDataSource
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None);
// System channels (e.g. the live log room) render in green to set them apart from user rooms.
private static readonly Attribute SystemAttr = new(Color.BrightGreen, Color.None);
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null,
IReadOnlySet<string>? privateChannels = null)
IReadOnlySet<string>? privateChannels = null, IReadOnlySet<string>? systemChannels = null)
{
_channelNames.Clear();
_channelNames.AddRange(channels);
@@ -50,6 +53,9 @@ public class ChannelListSource : IListDataSource
_privateChannels.Clear();
if (privateChannels is not null)
_privateChannels.UnionWith(privateChannels);
_systemChannels.Clear();
if (systemChannels is not null)
_systemChannels.UnionWith(systemChannels);
_activeChannel = activeChannel;
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
if (!SuspendCollectionChangedEvent)
@@ -71,7 +77,9 @@ public class ChannelListSource : IListDataSource
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
var prefix = isActive ? "> " : " ";
var isSystem = _systemChannels.Contains(name);
// System channels get a leading rule so they read as a pinned, separate group at the top.
var prefix = isSystem ? (isActive ? "▌ " : "▎ ") : (isActive ? "> " : " ");
// Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones
var channelText = $"#{name}";
if (_protectedChannels.Contains(name))
@@ -93,12 +101,15 @@ public class ChannelListSource : IListDataSource
}
else
{
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
// System channels are green so they stand apart from user rooms; that green also
// colors the leading rule and (dimmer) prefix.
listView.SetAttribute(Resolve(isSystem ? SystemAttr : isActive ? ActiveAttr : NormalAttr));
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
// Mentions escalate above plain unread: the whole entry turns orange
var hasMention = _mentionChannels.Contains(name);
var nameAttr = isActive ? ActiveAttr
var nameAttr = isSystem ? SystemAttr
: isActive ? ActiveAttr
: hasMention ? MentionAttr
: hasUnread ? UnreadAttr
: NormalAttr;
@@ -107,7 +118,7 @@ public class ChannelListSource : IListDataSource
if (hasUnread)
{
listView.SetAttribute(Resolve(hasMention ? MentionAttr : BadgeAttr));
listView.SetAttribute(Resolve(isSystem ? SystemAttr : hasMention ? MentionAttr : BadgeAttr));
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
}
}
+54 -4
View File
@@ -75,6 +75,7 @@ public sealed partial class MainWindow : Runnable
private readonly Dictionary<string, string?> _channelTopics = [];
private readonly Dictionary<string, bool> _channelPublic = [];
private readonly HashSet<string> _channelProtected = [];
private readonly HashSet<string> _systemChannels = [];
private readonly ChannelListSource _channelListSource;
private readonly ChatMessageManager _messageManager;
private string _connectionStatus = "Disconnected";
@@ -406,6 +407,14 @@ public sealed partial class MainWindow : Runnable
private void UpdateInputTitle()
{
// Read-only channels (the live log room) override any reply/staged hint.
if (IsCurrentChannelReadOnly)
{
_inputFrame.Title = "Read-only channel — you cannot type here";
_inputFrame.SetNeedsDraw();
return;
}
_inputFrame.Title = (_replyTitleFragment, _stagedTitleFragment) switch
{
(null, null) => DefaultInputTitle,
@@ -850,6 +859,8 @@ public sealed partial class MainWindow : Runnable
break;
case EnterKey:
if (IsCurrentChannelReadOnly)
break;
var text = _inputField.Text?.Trim() ?? string.Empty;
// Send when there's text, or when only attachments are staged (empty caption).
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
@@ -876,6 +887,9 @@ public sealed partial class MainWindow : Runnable
case CtrlVKey:
case CtrlYKey:
// Read-only channels can't receive text or attachments.
if (IsCurrentChannelReadOnly)
break;
// Discord-style paste priority. Copied files in the OS file manager put a file
// list (not text) on the clipboard — attach them all. Copied image data (browser
// right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text.
@@ -1102,6 +1116,7 @@ public sealed partial class MainWindow : Runnable
_channelTopics.Clear();
_channelPublic.Clear();
_channelProtected.Clear();
_systemChannels.Clear();
foreach (var ch in channels)
{
_channelNames.Add(ch.Name);
@@ -1109,6 +1124,8 @@ public sealed partial class MainWindow : Runnable
_channelPublic[ch.Name] = ch.IsPublic;
if (ch.IsProtected)
_channelProtected.Add(ch.Name);
if (ch.IsSystem)
_systemChannels.Add(ch.Name);
}
RefreshChannelList();
}
@@ -1116,7 +1133,8 @@ public sealed partial class MainWindow : Runnable
/// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary>
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null)
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null,
bool? isSystem = null)
{
if (isPublic.HasValue)
_channelPublic[channelName] = isPublic.Value;
@@ -1127,9 +1145,15 @@ public sealed partial class MainWindow : Runnable
else _channelProtected.Remove(channelName);
}
if (isSystem.HasValue)
{
if (isSystem.Value) _systemChannels.Add(channelName);
else _systemChannels.Remove(channelName);
}
if (_channelNames.Contains(channelName))
{
if (isProtected.HasValue)
if (isProtected.HasValue || isSystem.HasValue)
RefreshChannelList();
return;
}
@@ -1147,6 +1171,7 @@ public sealed partial class MainWindow : Runnable
_channelTopics.Remove(channelName);
_channelPublic.Remove(channelName);
_channelProtected.Remove(channelName);
_systemChannels.Remove(channelName);
RefreshChannelList();
}
@@ -1337,6 +1362,7 @@ public sealed partial class MainWindow : Runnable
RefreshMessages();
UpdateTopicBar();
UpdateInputReadOnly();
_statusLabel.SetNeedsDraw();
// Update channel list selection
@@ -1345,6 +1371,19 @@ public sealed partial class MainWindow : Runnable
_channelList.SelectedItem = idx;
}
/// <summary>Whether the active channel is read-only (a system channel like the log room).</summary>
private bool IsCurrentChannelReadOnly => _systemChannels.Contains(_messageManager.CurrentChannel);
/// <summary>
/// Disables the input for read-only (system) channels so nothing can be typed there, and
/// reflects the state in the input frame title.
/// </summary>
private void UpdateInputReadOnly()
{
_inputField.ReadOnly = IsCurrentChannelReadOnly;
UpdateInputTitle();
}
/// <summary>
/// Clear all messages and channels (used on disconnect).
/// </summary>
@@ -1355,6 +1394,7 @@ public sealed partial class MainWindow : Runnable
_channelTopics.Clear();
_channelPublic.Clear();
_channelProtected.Clear();
_systemChannels.Clear();
_channelListSource.Update([], [], string.Empty);
_channelList.Source = _channelListSource;
_chatFrame.Title = "Chat";
@@ -1459,11 +1499,21 @@ public sealed partial class MainWindow : Runnable
/// </summary>
private void RefreshChannelList()
{
// Pin system channels (e.g. the live log room) to the very top, keeping the server's
// relative order otherwise. OrderBy is stable, so alphabetical order is preserved within
// each group. Reordering in place keeps _channelNames the source of truth for selection
// lookups. System channels are private by nature but shouldn't get the private (~) glyph,
// so exclude them from the private set.
var ordered = _channelNames.OrderBy(n => _systemChannels.Contains(n) ? 0 : 1).ToList();
_channelNames.Clear();
_channelNames.AddRange(ordered);
var privateChannels = _channelNames
.Where(n => _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
.Where(n => !_systemChannels.Contains(n)
&& _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
.ToHashSet();
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
_channelProtected, _messageManager.MentionChannels, privateChannels);
_channelProtected, _messageManager.MentionChannels, privateChannels, _systemChannels);
_channelList.Source = _channelListSource;
// Restore selection to current channel
@@ -24,6 +24,11 @@ public interface IChannelService
// Membership
Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null);
// System channels (server-managed, e.g. the live server-log room). Creates the channel
// if missing and reclaims a same-named regular channel so server content never lands in
// a user-owned room.
Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null);
}
public record ChannelListItem(string Name, string? Topic, int OnlineCount, bool IsPublic = true, bool IsProtected = false);
+2 -1
View File
@@ -45,7 +45,8 @@ public record ChannelDto(
int MessageCount,
DateTimeOffset CreatedAt,
bool IsProtected = false,
bool IsEncrypted = false);
bool IsEncrypted = false,
bool IsSystem = false);
public record UserDto(
Guid Id,
+4
View File
@@ -6,6 +6,10 @@ public class Channel
public required string Name { get; set; }
public string? Topic { get; set; }
public bool IsPublic { get; set; } = true;
// Server-managed channel (e.g. the live server-log room): auto-created, read-only for
// every role, visible only to roles the owning feature allows. Users can never create one.
public bool IsSystem { get; set; }
public string? PasswordHash { get; set; }
// End-to-end encryption envelope (client-generated; server cannot decrypt room content).
@@ -400,6 +400,16 @@ public sealed class IrcCommandHandler
continue;
}
// System channels (e.g. the live log room) stream over SignalR only — the IRC
// gateway never carries their content, so block joins outright.
var channelInfo = await _channelService.GetChannelByNameAsync(channelName);
if (channelInfo?.IsSystem == true)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
$"#{channelName} :Cannot join channel — server-managed, use the EchoHub client");
continue;
}
var (history, error, passwordRequired) = await _chatService.JoinChannelAsync(
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key);
@@ -0,0 +1,43 @@
using EchoHub.Core.Models;
using Serilog.Events;
namespace EchoHub.Server.Config;
/// <summary>
/// Live server-log room settings, bound from the "ServerLogs" config section (env override:
/// <c>ServerLogs__Enabled</c> etc.). When enabled, a read-only system channel is auto-created
/// and log events are streamed to it live — log lines are never stored as messages in the
/// database; the rolling Serilog log files remain the only persistence.
/// </summary>
public sealed class ServerLogsOptions
{
/// <summary>Master switch for the live log room.</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Name of the auto-created system channel. Must satisfy the normal channel-name rules;
/// the name is reserved — users cannot create a channel with it.
/// </summary>
public string RoomName { get; set; } = "server-logs";
/// <summary>Minimum server role allowed to see and join the log room (Member/Mod/Admin/Owner).</summary>
public ServerRole MinRole { get; set; } = ServerRole.Mod;
/// <summary>
/// Minimum level of log events streamed to the room (Verbose/Debug/Information/Warning/
/// Error/Fatal). Only affects the room — file and console sinks keep their own levels.
/// </summary>
public LogEventLevel MinLevel { get; set; } = LogEventLevel.Information;
/// <summary>How many recent log entries are replayed from the log file when someone opens the room.</summary>
public int BacklogLines { get; set; } = 100;
/// <summary>Directory holding the rolling log files. Must match the Serilog file sink's path.</summary>
public string LogDirectory { get; set; } = "logs";
/// <summary>Filename glob for the rolling log files inside <see cref="LogDirectory"/>.</summary>
public string LogFilePattern { get; set; } = "echohub-server-*.log";
/// <summary>Channel names are stored lowercased; compare against this form.</summary>
public string NormalizedRoomName => RoomName.ToLowerInvariant().Trim();
}
@@ -201,6 +201,9 @@ public class ChannelsController : ControllerBase
if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (channelDto.IsSystem)
return StatusCode(403, new ErrorResponse("This channel is read-only."));
if (!Request.HasFormContentType)
return BadRequest(new ErrorResponse("Expected multipart form data."));
@@ -344,6 +347,9 @@ public class ChannelsController : ControllerBase
if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (channelDto.IsSystem)
return StatusCode(403, new ErrorResponse("This channel is read-only."));
if (channelDto.IsEncrypted)
return BadRequest(new ErrorResponse(
"Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead."));
@@ -0,0 +1,376 @@
// <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("20260717182450_AddChannelIsSystem")]
partial class AddChannelIsSystem
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AsciiPreview")
.HasMaxLength(64000)
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<Guid>("MessageId")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MessageId");
b.ToTable("Attachments");
});
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>("EncryptionSalt")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("WrappedRoomKey")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("JoinedAt")
.HasColumnType("INTEGER");
b.HasKey("UserId", "ChannelId");
b.HasIndex("ChannelId");
b.HasIndex("UserId");
b.ToTable("ChannelMemberships");
});
modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<string>("CreatedByUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<int>("MaxUses")
.HasColumnType("INTEGER");
b.Property<int>("UseCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("InviteCodes");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid?>("ReplyToMessageId")
.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<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
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.Attachment", b =>
{
b.HasOne("EchoHub.Core.Models.Message", "Message")
.WithMany("Attachments")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Message");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", null)
.WithMany()
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EchoHub.Core.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
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");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Navigation("Attachments");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddChannelIsSystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsSystem",
table: "Channels",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsSystem",
table: "Channels");
}
}
}
@@ -72,6 +72,9 @@ namespace EchoHub.Server.Data.Migrations
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
+20 -1
View File
@@ -10,6 +10,7 @@ using EchoHub.Server.Data;
using EchoHub.Server.Hubs;
using EchoHub.Server.Irc;
using EchoHub.Server.Services;
using EchoHub.Server.Services.ServerLogs;
using EchoHub.Server.Setup;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
@@ -41,9 +42,23 @@ while (true)
builder.Services.Configure<HostOptions>(options =>
options.ShutdownTimeout = TimeSpan.FromSeconds(5));
// ── Server log room (admin-configurable via the "ServerLogs" section) ─
// Bound before Serilog so the live-log sink can be wired into the pipeline. The sink
// is a singleton shared between Serilog (producer) and the stream service (consumer).
var serverLogsOptions = builder.Configuration.GetSection("ServerLogs").Get<ServerLogsOptions>() ?? new ServerLogsOptions();
builder.Services.AddSingleton(serverLogsOptions);
builder.Services.AddSingleton<ServerLogsService>();
var serverLogsSink = serverLogsOptions.Enabled ? new ServerLogsSink(serverLogsOptions) : null;
if (serverLogsSink is not null)
builder.Services.AddSingleton(serverLogsSink);
// ── Serilog ──────────────────────────────────────────────────────────
builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration));
{
config.ReadFrom.Configuration(context.Configuration);
if (serverLogsSink is not null)
config.WriteTo.Sink(serverLogsSink);
});
// ── SQLite + EF Core ─────────────────────────────────────────────────
var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
@@ -127,6 +142,10 @@ while (true)
builder.Services.AddHostedService<FileCleanupService>();
builder.Services.AddHostedService<MuteExpirationService>();
// Live server-log streaming (only when the sink is active)
if (serverLogsSink is not null)
builder.Services.AddHostedService<ServerLogsStreamService>();
// ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
+98 -5
View File
@@ -3,6 +3,7 @@ using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services.ServerLogs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -14,17 +15,20 @@ public class ChannelService : IChannelService
private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presenceTracker;
private readonly SpamGuard _spamGuard;
private readonly ServerLogsService _serverLogs;
private readonly ILogger<ChannelService> _logger;
public ChannelService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
SpamGuard spamGuard,
ServerLogsService serverLogs,
ILogger<ChannelService> logger)
{
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_spamGuard = spamGuard;
_serverLogs = serverLogs;
_logger = logger;
}
@@ -35,17 +39,24 @@ public class ChannelService : IChannelService
await EnsureDefaultChannelAsync(db);
var query = db.Channels.Where(c =>
c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
// System channels (the live log room) are visible only to the configured roles,
// regardless of membership; the room sorts above everything else.
var caller = await db.Users.FindAsync(userId);
var canViewSystem = _serverLogs.CanView(caller?.Role ?? ServerRole.Member);
var query = db.Channels.Where(c => c.IsSystem
? canViewSystem
: c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
var total = await query.CountAsync();
var channels = await query
.OrderBy(c => c.Name)
.OrderByDescending(c => c.IsSystem)
.ThenBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt,
c.PasswordHash != null, c.WrappedRoomKey != null))
c.PasswordHash != null, c.WrappedRoomKey != null, c.IsSystem))
.ToListAsync();
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
@@ -64,6 +75,12 @@ public class ChannelService : IChannelService
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
// The log room's name is reserved even while the feature is disabled, so enabling it
// later never turns a user-owned channel into the stream target.
if (channelName == _serverLogs.Options.NormalizedRoomName)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
$"Channel name '{channelName}' is reserved.");
var passwordError = ValidateChannelPassword(ref password);
if (passwordError is not null)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
@@ -166,6 +183,10 @@ public class ChannelService : IChannelService
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
if (dbChannel.IsSystem)
return ChannelOperationResult.Fail(ChannelError.Protected,
"System channels cannot be password protected.");
if (dbChannel.WrappedRoomKey is not null)
return ChannelOperationResult.Fail(ChannelError.Protected,
"This channel is end-to-end encrypted — change its passphrase from the EchoHub client (/passwd).");
@@ -247,6 +268,10 @@ public class ChannelService : IChannelService
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
if (dbChannel.IsSystem)
return ChannelOperationResult.Fail(ChannelError.Protected,
"System channels cannot be deleted.");
var caller = await db.Users.FindAsync(callerUserId);
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
return ChannelOperationResult.Fail(ChannelError.Forbidden,
@@ -298,7 +323,7 @@ public class ChannelService : IChannelService
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt,
c.PasswordHash != null, c.WrappedRoomKey != null);
c.PasswordHash != null, c.WrappedRoomKey != null, c.IsSystem);
}
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
@@ -372,6 +397,16 @@ public class ChannelService : IChannelService
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
// The live log room is role-gated: only the configured roles may join, no matter
// how the join arrives (TUI, REST, IRC).
var isLogsChannel = _serverLogs.IsLogsChannel(channelName);
if (isLogsChannel)
{
var joiner = await db.Users.FindAsync(userId);
if (!_serverLogs.CanView(joiner?.Role ?? ServerRole.Member))
return (false, $"Channel '{channelName}' is restricted to server staff.", false);
}
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
@@ -389,11 +424,31 @@ public class ChannelService : IChannelService
await db.SaveChangesAsync();
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
}
else if (isLogsChannel)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = ServerLogsService.RoomTopic,
IsPublic = false,
IsSystem = true,
CreatedByUserId = Guid.Empty,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
_logger.LogWarning("Log channel '{Channel}' was missing and has been recreated", channelName);
}
else
{
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.", false);
}
}
else if (channel.IsSystem && !isLogsChannel)
{
// A system channel left behind while its feature is disabled stays inaccessible.
return (false, $"Channel '{channelName}' is not available.", false);
}
var hasMembership = await db.ChannelMemberships
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
@@ -442,6 +497,44 @@ public class ChannelService : IChannelService
return null;
}
public async Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = topic,
IsPublic = false,
IsSystem = true,
CreatedByUserId = Guid.Empty,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
_logger.LogInformation("System channel '{Channel}' created", channelName);
}
else if (!channel.IsSystem)
{
// A regular channel squatting on the system name (created while the feature was
// off) is claimed, so server content never streams into a user-owned room.
channel.IsSystem = true;
channel.IsPublic = false;
channel.PasswordHash = null;
await db.SaveChangesAsync();
_logger.LogWarning("Existing channel '{Channel}' was claimed as a system channel", channelName);
}
return new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt,
false, channel.WrappedRoomKey != null, true);
}
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
{
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
@@ -5,6 +5,7 @@ using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
using EchoHub.Server.Data;
using EchoHub.Server.Services.ServerLogs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -21,6 +22,7 @@ public class ChatService : IChatService
private readonly IChannelService _channelService;
private readonly FileStorageService _fileStorage;
private readonly SpamGuard _spamGuard;
private readonly ServerLogsService _serverLogs;
private readonly ILogger<ChatService> _logger;
public ChatService(
@@ -32,6 +34,7 @@ public class ChatService : IChatService
IChannelService channelService,
FileStorageService fileStorage,
SpamGuard spamGuard,
ServerLogsService serverLogs,
ILogger<ChatService> logger)
{
_scopeFactory = scopeFactory;
@@ -42,6 +45,7 @@ public class ChatService : IChatService
_channelService = channelService;
_fileStorage = fileStorage;
_spamGuard = spamGuard;
_serverLogs = serverLogs;
_logger = logger;
}
@@ -183,6 +187,11 @@ public class ChatService : IChatService
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return "Invalid channel name.";
// The live log room is read-only for everyone, every role. Reject by name before any
// work so a streamed log line can never provoke a DB write or another log event.
if (_serverLogs.IsLogsChannel(channelName))
return "This channel is read-only.";
// Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both)
var plaintext = _encryption.Decrypt(content);
@@ -206,6 +215,9 @@ public class ChatService : IChatService
if (channel is null)
return $"Channel '{channelName}' does not exist.";
if (channel.IsSystem)
return "This channel is read-only.";
var sender = await db.Users.FindAsync(userId);
// Check mute status
@@ -309,12 +321,34 @@ public class ChatService : IChatService
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
offset = Math.Max(offset, 0);
// The log room has no DB messages — its backlog is the tail of the rolling log file.
// Only the first page carries the backlog; older pages are empty (files are the archive).
if (_serverLogs.IsLogsChannel(channelName))
return offset > 0 ? [] : BuildLogBacklog(channelName);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
}
/// <summary>
/// Turns the log-file backlog into transport-encrypted <see cref="MessageDto"/>s so clients
/// render past log lines exactly like streamed ones. Never touches the database.
/// </summary>
private List<MessageDto> BuildLogBacklog(string channelName)
{
return _serverLogs.ReadBacklog()
.Select(entry => new MessageDto(
Guid.NewGuid(),
_encryption.Encrypt(entry.Content),
ServerLogsService.SenderName,
null,
channelName,
entry.Timestamp))
.ToList();
}
/// <summary>
/// Builds the wire reference for a reply target. Plaintext snippets are truncated
/// server-side; end-to-end room ciphertext must pass through whole (a truncated blob
@@ -0,0 +1,138 @@
using System.Globalization;
using EchoHub.Core.Models;
using EchoHub.Server.Config;
namespace EchoHub.Server.Services.ServerLogs;
/// <summary>
/// A backlog entry read from the log file: one timestamped log line plus any continuation
/// lines (exception stack traces) that followed it.
/// </summary>
public record LogBacklogEntry(DateTimeOffset Timestamp, string Content);
/// <summary>
/// Shared logic for the live log room: room identity, the role gate, and reading the backlog
/// tail from the current rolling log file. The file stays the only persistence — log lines
/// are never stored as messages.
/// </summary>
public sealed class ServerLogsService
{
/// <summary>Username shown as the sender of streamed log messages.</summary>
public const string SenderName = "server";
public const string RoomTopic = "Live server logs — read-only";
/// <summary>Timestamp prefix of the file sink's output template.</summary>
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";
/// <summary>How far back into the log file the backlog read reaches, at most.</summary>
private const int TailReadBytes = 256 * 1024;
private readonly ServerLogsOptions _options;
public ServerLogsService(ServerLogsOptions options) => _options = options;
public ServerLogsOptions Options => _options;
/// <summary>Whether the given channel is the (enabled) live log room.</summary>
public bool IsLogsChannel(string channelName) =>
_options.Enabled
&& string.Equals(channelName.Trim(), _options.NormalizedRoomName, StringComparison.OrdinalIgnoreCase);
/// <summary>Whether a user with this role may see and join the log room.</summary>
public bool CanView(ServerRole role) => _options.Enabled && role >= _options.MinRole;
/// <summary>
/// Reads the last <see cref="ServerLogsOptions.BacklogLines"/> entries from the newest
/// log file. A line starting with a timestamp begins a new entry; continuation lines
/// (stack traces) stay attached to the entry above them. Best-effort: any I/O problem
/// yields an empty backlog rather than failing the join.
/// </summary>
public IReadOnlyList<LogBacklogEntry> ReadBacklog()
{
try
{
var directory = Path.GetFullPath(_options.LogDirectory);
if (!Directory.Exists(directory))
return [];
var newest = new DirectoryInfo(directory)
.GetFiles(_options.LogFilePattern)
.OrderByDescending(f => f.LastWriteTimeUtc)
.FirstOrDefault();
if (newest is null)
return [];
// Shared read: Serilog keeps the file open for writing (and rolls it daily).
using var stream = new FileStream(newest.FullName, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
var seeked = stream.Length > TailReadBytes;
if (seeked)
stream.Seek(-TailReadBytes, SeekOrigin.End);
using var reader = new StreamReader(stream);
var lines = reader.ReadToEnd().Split('\n');
return GroupIntoEntries(lines, skipLeadingContinuations: seeked, _options.BacklogLines);
}
catch
{
return [];
}
}
/// <summary>
/// Groups raw file lines into entries by their timestamp prefix. Public for tests.
/// </summary>
public static IReadOnlyList<LogBacklogEntry> GroupIntoEntries(
IReadOnlyList<string> lines, bool skipLeadingContinuations, int maxEntries)
{
var entries = new List<LogBacklogEntry>();
LogBacklogEntry? current = null;
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd('\r');
if (line.Length == 0)
continue;
if (TryParseTimestamp(line, out var timestamp, out var rest))
{
if (current is not null)
entries.Add(current);
current = new LogBacklogEntry(timestamp, rest);
}
else if (current is not null)
{
current = current with { Content = current.Content + "\n" + line };
}
else if (!skipLeadingContinuations)
{
// File starts mid-entry only when we seeked into it; otherwise keep the line.
current = new LogBacklogEntry(DateTimeOffset.UtcNow, line);
}
}
if (current is not null)
entries.Add(current);
if (entries.Count > maxEntries)
entries.RemoveRange(0, entries.Count - maxEntries);
return entries;
}
private static bool TryParseTimestamp(string line, out DateTimeOffset timestamp, out string rest)
{
timestamp = default;
rest = string.Empty;
if (line.Length <= TimestampFormat.Length
|| !DateTime.TryParseExact(line[..TimestampFormat.Length], TimestampFormat,
CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
return false;
timestamp = new DateTimeOffset(parsed);
rest = line[TimestampFormat.Length..].TrimStart();
return true;
}
}
@@ -0,0 +1,51 @@
using System.Threading.Channels;
using EchoHub.Server.Config;
using Serilog.Core;
using Serilog.Events;
namespace EchoHub.Server.Services.ServerLogs;
/// <summary>
/// Serilog sink feeding the live log room. Events are queued in a bounded drop-oldest buffer
/// and consumed by <see cref="ServerLogsStreamService"/>; nothing is written to the database.
/// Events emitted by the streaming pipeline itself (the stream service and SignalR transport
/// internals) are dropped here so a broadcast that logs — e.g. a transport warning on a dead
/// connection — can never enter a log → broadcast → log feedback loop.
/// </summary>
public sealed class ServerLogsSink : ILogEventSink
{
private const int QueueCapacity = 512;
private static readonly string[] ExcludedSourcePrefixes =
[
"EchoHub.Server.Services.ServerLogs",
"Microsoft.AspNetCore.SignalR",
"Microsoft.AspNetCore.Http.Connections",
];
private readonly Channel<LogEvent> _queue = Channel.CreateBounded<LogEvent>(
new BoundedChannelOptions(QueueCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
private readonly LogEventLevel _minLevel;
public ServerLogsSink(ServerLogsOptions options) => _minLevel = options.MinLevel;
public ChannelReader<LogEvent> Reader => _queue.Reader;
public void Emit(LogEvent logEvent)
{
if (logEvent.Level < _minLevel)
return;
if (logEvent.Properties.TryGetValue(Constants.SourceContextPropertyName, out var sourceProperty)
&& sourceProperty is ScalarValue { Value: string source }
&& ExcludedSourcePrefixes.Any(source.StartsWith))
return;
_queue.Writer.TryWrite(logEvent);
}
}
@@ -0,0 +1,128 @@
using System.Globalization;
using System.Text;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Server.Config;
using EchoHub.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
using Serilog.Events;
namespace EchoHub.Server.Services.ServerLogs;
/// <summary>
/// Streams queued log events to the live log room as ephemeral messages: SignalR only (the
/// IRC gateway never sees them) and no database rows. Ensures the room exists before
/// streaming, recreating it on the fly if it was deleted.
///
/// This class must never log from its streaming path — its namespace is excluded by
/// <see cref="ServerLogsSink"/> as a second line of defense, but the primary rule is simply
/// not to log per event, otherwise every streamed line would spawn another.
/// </summary>
public sealed class ServerLogsStreamService : BackgroundService
{
private static readonly TimeSpan EnsureInterval = TimeSpan.FromSeconds(15);
private readonly ServerLogsSink _sink;
private readonly ServerLogsOptions _options;
private readonly IChannelService _channelService;
private readonly IMessageEncryptionService _encryption;
private readonly IServiceProvider _serviceProvider;
private IHubContext<ChatHub, IEchoHubClient>? _hubContext;
private DateTimeOffset _lastEnsure = DateTimeOffset.MinValue;
// Resolved lazily: the hub context isn't available while hosted services are constructed.
private IHubContext<ChatHub, IEchoHubClient> HubContext
=> _hubContext ??= _serviceProvider.GetRequiredService<IHubContext<ChatHub, IEchoHubClient>>();
public ServerLogsStreamService(
ServerLogsSink sink,
ServerLogsOptions options,
IChannelService channelService,
IMessageEncryptionService encryption,
IServiceProvider serviceProvider)
{
_sink = sink;
_options = options;
_channelService = channelService;
_encryption = encryption;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Make the room exist from server start, not only once the first event arrives.
await TryEnsureRoomAsync();
await foreach (var logEvent in _sink.Reader.ReadAllAsync(stoppingToken))
{
try
{
await TryEnsureRoomAsync();
var roomName = _options.NormalizedRoomName;
var message = new MessageDto(
Guid.NewGuid(),
_encryption.Encrypt(Format(logEvent)),
ServerLogsService.SenderName,
null,
roomName,
logEvent.Timestamp);
await HubContext.Clients.Group(roomName).ReceiveMessage(message);
}
catch when (!stoppingToken.IsCancellationRequested)
{
// Swallow: logging here would re-enter the pipeline.
}
}
}
/// <summary>
/// Recreates the log room if it disappeared, at most once per <see cref="EnsureInterval"/>.
/// </summary>
private async Task TryEnsureRoomAsync()
{
if (DateTimeOffset.UtcNow - _lastEnsure < EnsureInterval)
return;
_lastEnsure = DateTimeOffset.UtcNow;
try
{
await _channelService.EnsureSystemChannelAsync(_options.NormalizedRoomName, ServerLogsService.RoomTopic);
}
catch
{
// Retried on the next interval; events streamed meanwhile just go to no group members.
}
}
/// <summary>Formats an event like the file sink's template, minus the timestamp (clients render their own). Public for tests.</summary>
public static string Format(LogEvent logEvent)
{
var builder = new StringBuilder("[").Append(ShortLevel(logEvent.Level)).Append("] ")
.Append(logEvent.RenderMessage(CultureInfo.InvariantCulture));
if (logEvent.Exception is not null)
builder.Append('\n').Append(logEvent.Exception);
if (builder.Length > HubConstants.MaxMessageLength)
{
builder.Length = HubConstants.MaxMessageLength - 1;
builder.Append('…');
}
return builder.ToString();
}
private static string ShortLevel(LogEventLevel level) => level switch
{
LogEventLevel.Verbose => "VRB",
LogEventLevel.Debug => "DBG",
LogEventLevel.Information => "INF",
LogEventLevel.Warning => "WRN",
LogEventLevel.Error => "ERR",
LogEventLevel.Fatal => "FTL",
_ => level.ToString().ToUpperInvariant(),
};
}
@@ -47,6 +47,15 @@
"Key": "",
"EncryptDatabase": false
},
"ServerLogs": {
"Enabled": true,
"RoomName": "server-logs",
"MinRole": "Mod",
"MinLevel": "Information",
"BacklogLines": 100,
"LogDirectory": "logs",
"LogFilePattern": "echohub-server-*.log"
},
"Irc": {
"Enabled": false,
"Port": 6667,
@@ -0,0 +1,227 @@
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Config;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using EchoHub.Server.Services.ServerLogs;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// System-channel behavior of <see cref="ChannelService"/> (the live server-log room):
/// name reservation, role-gated visibility + top ordering, delete protection, and the
/// join-time role gate with auto-recreation. Runs against a real SQLite in-memory database.
/// </summary>
public sealed class ChannelServiceSystemChannelTests : IDisposable
{
private const string LogRoom = "server-logs";
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
private readonly ServerLogsService _serverLogs = new(new ServerLogsOptions
{
Enabled = true,
RoomName = LogRoom,
MinRole = ServerRole.Mod,
});
public ChannelServiceSystemChannelTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var services = new ServiceCollection();
services.AddDbContext<EchoHubDbContext>(o => o.UseSqlite(_connection));
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
scope.ServiceProvider.GetRequiredService<EchoHubDbContext>().Database.EnsureCreated();
}
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
private ChannelService CreateService() => new(
_provider.GetRequiredService<IServiceScopeFactory>(),
new PresenceTracker(),
// Disable the spam guard so channel-create throttling never interferes with assertions.
new SpamGuard(new SpamOptions { Enabled = false }),
_serverLogs,
NullLogger<ChannelService>.Instance);
private async Task<Guid> SeedUserAsync(ServerRole role)
{
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = new User
{
Id = Guid.NewGuid(),
Username = "user-" + Guid.NewGuid().ToString("N")[..8],
PasswordHash = "x",
Role = role,
};
db.Users.Add(user);
await db.SaveChangesAsync();
return user.Id;
}
private EchoHubDbContext Db() =>
_provider.GetRequiredService<IServiceScopeFactory>()
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
// ── Name reservation ──────────────────────────────────────────────
[Fact]
public async Task CreateChannel_WithReservedLogRoomName_IsRejected()
{
var service = CreateService();
var userId = await SeedUserAsync(ServerRole.Owner);
var result = await service.CreateChannelAsync(userId, LogRoom, null, isPublic: true);
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.ValidationFailed, result.Error);
}
// ── EnsureSystemChannelAsync ──────────────────────────────────────
[Fact]
public async Task EnsureSystemChannel_CreatesPrivateSystemChannel()
{
var service = CreateService();
var dto = await service.EnsureSystemChannelAsync(LogRoom, "Live server logs");
Assert.True(dto.IsSystem);
Assert.False(dto.IsPublic);
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
Assert.True(stored.IsSystem);
Assert.False(stored.IsPublic);
}
[Fact]
public async Task EnsureSystemChannel_IsIdempotent()
{
var service = CreateService();
var first = await service.EnsureSystemChannelAsync(LogRoom);
var second = await service.EnsureSystemChannelAsync(LogRoom);
Assert.Equal(first.Id, second.Id);
Assert.Equal(1, await Db().Channels.CountAsync(c => c.Name == LogRoom));
}
[Fact]
public async Task EnsureSystemChannel_ClaimsExistingRegularChannelOfSameName()
{
// A regular channel squatting on the name (created while the feature was off) must be
// reclaimed so server content never streams into a user-owned room.
using (var scope = _provider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
db.Channels.Add(new Channel
{
Id = Guid.NewGuid(),
Name = LogRoom,
IsPublic = true,
IsSystem = false,
PasswordHash = "hash",
CreatedByUserId = Guid.NewGuid(),
});
await db.SaveChangesAsync();
}
var service = CreateService();
var dto = await service.EnsureSystemChannelAsync(LogRoom);
Assert.True(dto.IsSystem);
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
Assert.True(stored.IsSystem);
Assert.False(stored.IsPublic);
Assert.Null(stored.PasswordHash);
}
// ── Visibility + ordering ─────────────────────────────────────────
[Fact]
public async Task GetChannels_HidesSystemChannelFromMembers()
{
var service = CreateService();
await service.EnsureSystemChannelAsync(LogRoom);
var memberId = await SeedUserAsync(ServerRole.Member);
var page = await service.GetChannelsAsync(memberId, 0, 50);
Assert.DoesNotContain(page.Items, c => c.Name == LogRoom);
}
[Fact]
public async Task GetChannels_ShowsSystemChannelToMods_PinnedAtTop()
{
var service = CreateService();
await service.EnsureSystemChannelAsync(LogRoom);
var modId = await SeedUserAsync(ServerRole.Mod);
var page = await service.GetChannelsAsync(modId, 0, 50);
var logRoom = Assert.Single(page.Items, c => c.Name == LogRoom);
Assert.True(logRoom.IsSystem);
// Sorts above 'general' (auto-created, alphabetically after 's' would normally lose).
Assert.Equal(LogRoom, page.Items[0].Name);
}
// ── Delete protection ─────────────────────────────────────────────
[Fact]
public async Task DeleteChannel_SystemChannel_IsRefused()
{
var service = CreateService();
await service.EnsureSystemChannelAsync(LogRoom);
var ownerId = await SeedUserAsync(ServerRole.Owner);
var result = await service.DeleteChannelAsync(ownerId, LogRoom);
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.Protected, result.Error);
Assert.True(await Db().Channels.AnyAsync(c => c.Name == LogRoom));
}
// ── Join-time role gate + auto-recreate ───────────────────────────
[Fact]
public async Task EnsureMembership_LogRoom_RejectsMember()
{
var service = CreateService();
await service.EnsureSystemChannelAsync(LogRoom);
var memberId = await SeedUserAsync(ServerRole.Member);
var (success, error, _) = await service.EnsureChannelMembershipAsync(memberId, LogRoom);
Assert.False(success);
Assert.NotNull(error);
}
[Fact]
public async Task EnsureMembership_LogRoom_AllowsModAndRecreatesIfMissing()
{
// No prior EnsureSystemChannelAsync — a Mod joining a missing log room recreates it.
var service = CreateService();
var modId = await SeedUserAsync(ServerRole.Mod);
var (success, error, _) = await service.EnsureChannelMembershipAsync(modId, LogRoom);
Assert.True(success);
Assert.Null(error);
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
Assert.True(stored.IsSystem);
}
}
+441
View File
@@ -0,0 +1,441 @@
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Config;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using EchoHub.Server.Services.ServerLogs;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// General <see cref="ChannelService"/> CRUD, validation, password gates, and role/creator
/// authorization. (System-channel behavior lives in <see cref="ChannelServiceSystemChannelTests"/>.)
/// Runs against a real SQLite in-memory database so the guarded queries and FK relationships
/// behave as in production.
/// </summary>
public sealed class ChannelServiceTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
// Default options → reserved name "server-logs"; feature enabled but never targeted here.
private readonly ServerLogsService _serverLogs = new(new ServerLogsOptions());
public ChannelServiceTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var services = new ServiceCollection();
services.AddDbContext<EchoHubDbContext>(o => o.UseSqlite(_connection));
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
scope.ServiceProvider.GetRequiredService<EchoHubDbContext>().Database.EnsureCreated();
}
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
private ChannelService CreateService() => new(
_provider.GetRequiredService<IServiceScopeFactory>(),
new PresenceTracker(),
new SpamGuard(new SpamOptions { Enabled = false }),
_serverLogs,
NullLogger<ChannelService>.Instance);
private async Task<Guid> SeedUserAsync(ServerRole role = ServerRole.Member)
{
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = new User
{
Id = Guid.NewGuid(),
Username = "user-" + Guid.NewGuid().ToString("N")[..8],
PasswordHash = "x",
Role = role,
};
db.Users.Add(user);
await db.SaveChangesAsync();
return user.Id;
}
private EchoHubDbContext Db() =>
_provider.GetRequiredService<IServiceScopeFactory>()
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
// ── Create: happy path + membership ───────────────────────────────
[Fact]
public async Task CreateChannel_Valid_SucceedsAndAddsCreatorMembership()
{
var service = CreateService();
var creator = await SeedUserAsync();
var result = await service.CreateChannelAsync(creator, "dev-talk", "About dev", isPublic: true);
Assert.True(result.IsSuccess);
Assert.Equal("dev-talk", result.Channel!.Name);
Assert.True(result.Channel.IsPublic);
var channel = await Db().Channels.SingleAsync(c => c.Name == "dev-talk");
Assert.True(await Db().ChannelMemberships.AnyAsync(m => m.ChannelId == channel.Id && m.UserId == creator));
}
[Fact]
public async Task CreateChannel_LowercasesName()
{
var service = CreateService();
var creator = await SeedUserAsync();
var result = await service.CreateChannelAsync(creator, "DevTalk", null, isPublic: true);
Assert.True(result.IsSuccess);
Assert.Equal("devtalk", result.Channel!.Name);
}
// ── Create: validation ────────────────────────────────────────────
[Theory]
[InlineData("a")] // too short (< 2)
[InlineData("has space")] // invalid character
[InlineData("bang!")] // invalid character
public async Task CreateChannel_InvalidName_Rejected(string name)
{
var service = CreateService();
var creator = await SeedUserAsync();
var result = await service.CreateChannelAsync(creator, name, null, isPublic: true);
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.ValidationFailed, result.Error);
}
[Fact]
public async Task CreateChannel_DuplicateName_Rejected()
{
var service = CreateService();
var creator = await SeedUserAsync();
await service.CreateChannelAsync(creator, "dupe", null, isPublic: true);
var result = await service.CreateChannelAsync(creator, "dupe", null, isPublic: true);
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.AlreadyExists, result.Error);
}
[Fact]
public async Task CreateChannel_ShortPassword_Rejected()
{
var service = CreateService();
var creator = await SeedUserAsync();
var result = await service.CreateChannelAsync(creator, "locked", null, isPublic: true, password: "ab");
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.ValidationFailed, result.Error);
}
[Fact]
public async Task CreateChannel_WithPassword_IsMarkedProtectedAndHashed()
{
var service = CreateService();
var creator = await SeedUserAsync();
var result = await service.CreateChannelAsync(creator, "locked", null, isPublic: true, password: "secret");
Assert.True(result.IsSuccess);
Assert.True(result.Channel!.IsProtected);
var stored = await Db().Channels.SingleAsync(c => c.Name == "locked");
Assert.NotNull(stored.PasswordHash);
Assert.NotEqual("secret", stored.PasswordHash); // hashed, not plaintext
}
[Fact]
public async Task CreateChannel_EncryptionEnvelopeWithoutPassword_Rejected()
{
var service = CreateService();
var creator = await SeedUserAsync();
var result = await service.CreateChannelAsync(creator, "e2e", null, isPublic: false,
password: null, encryptionSalt: "salt", wrappedRoomKey: "wrapped");
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.ValidationFailed, result.Error);
}
[Fact]
public async Task CreateChannel_EncryptedChannel_ExposesCryptoMetadataButNotKey()
{
var service = CreateService();
var creator = await SeedUserAsync();
await service.CreateChannelAsync(creator, "e2e", null, isPublic: false,
password: "passphrase", encryptionSalt: "the-salt", wrappedRoomKey: "the-wrapped-key");
var crypto = await service.GetChannelCryptoAsync("e2e");
var (salt, wrapped) = await service.GetChannelKeyEnvelopeAsync("e2e");
Assert.True(crypto!.IsEncrypted);
Assert.Equal("the-salt", crypto.EncryptionSalt);
Assert.Equal("the-salt", salt);
Assert.Equal("the-wrapped-key", wrapped);
}
// ── Visibility ────────────────────────────────────────────────────
[Fact]
public async Task GetChannels_ShowsPublicAndOwnPrivate_HidesOthersPrivate()
{
var service = CreateService();
var owner = await SeedUserAsync();
var outsider = await SeedUserAsync();
await service.CreateChannelAsync(owner, "public-room", null, isPublic: true);
await service.CreateChannelAsync(owner, "private-room", null, isPublic: false);
var outsiderView = await service.GetChannelsAsync(outsider, 0, 50);
Assert.Contains(outsiderView.Items, c => c.Name == "public-room");
Assert.DoesNotContain(outsiderView.Items, c => c.Name == "private-room");
var ownerView = await service.GetChannelsAsync(owner, 0, 50);
Assert.Contains(ownerView.Items, c => c.Name == "private-room");
}
// ── Membership + password gate ────────────────────────────────────
[Fact]
public async Task EnsureMembership_ProtectedChannel_RequiresCorrectPassword()
{
var service = CreateService();
var creator = await SeedUserAsync();
await service.CreateChannelAsync(creator, "vault", null, isPublic: true, password: "opensesame");
var joiner = await SeedUserAsync();
var noPassword = await service.EnsureChannelMembershipAsync(joiner, "vault");
Assert.False(noPassword.Success);
Assert.True(noPassword.PasswordRequired);
var wrongPassword = await service.EnsureChannelMembershipAsync(joiner, "vault", "nope");
Assert.False(wrongPassword.Success);
Assert.True(wrongPassword.PasswordRequired);
var correct = await service.EnsureChannelMembershipAsync(joiner, "vault", "opensesame");
Assert.True(correct.Success);
}
[Fact]
public async Task EnsureMembership_ExistingMember_NoPasswordNeeded()
{
var service = CreateService();
var creator = await SeedUserAsync();
await service.CreateChannelAsync(creator, "vault", null, isPublic: true, password: "opensesame");
// Creator already has membership from creation → re-join needs no password.
var result = await service.EnsureChannelMembershipAsync(creator, "vault");
Assert.True(result.Success);
}
[Fact]
public async Task EnsureMembership_NonexistentChannel_Fails()
{
var service = CreateService();
var user = await SeedUserAsync();
var result = await service.EnsureChannelMembershipAsync(user, "ghost");
Assert.False(result.Success);
Assert.False(result.PasswordRequired);
}
[Fact]
public async Task EnsureMembership_DefaultChannel_AutoRecreatedIfMissing()
{
var service = CreateService();
var user = await SeedUserAsync();
var result = await service.EnsureChannelMembershipAsync(user, HubConstants.DefaultChannel);
Assert.True(result.Success);
Assert.True(await Db().Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel));
}
// ── Topic ─────────────────────────────────────────────────────────
[Fact]
public async Task UpdateTopic_Creator_Succeeds()
{
var service = CreateService();
var creator = await SeedUserAsync();
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
var result = await service.UpdateTopicAsync(creator, "room", "new topic");
Assert.True(result.IsSuccess);
Assert.Equal("new topic", result.Channel!.Topic);
}
[Fact]
public async Task UpdateTopic_NonCreator_Forbidden()
{
var service = CreateService();
var creator = await SeedUserAsync();
var other = await SeedUserAsync(ServerRole.Admin); // even an admin isn't the creator
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
var result = await service.UpdateTopicAsync(other, "room", "hijack");
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.Forbidden, result.Error);
}
[Fact]
public async Task UpdateTopic_TooLong_Rejected()
{
var service = CreateService();
var creator = await SeedUserAsync();
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
var result = await service.UpdateTopicAsync(creator, "room",
new string('x', ValidationConstants.MaxChannelTopicLength + 1));
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.ValidationFailed, result.Error);
}
// ── Password management ───────────────────────────────────────────
[Fact]
public async Task SetChannelPassword_Admin_CanSetOnAnothersChannel()
{
var service = CreateService();
var creator = await SeedUserAsync();
var admin = await SeedUserAsync(ServerRole.Admin);
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
var result = await service.SetChannelPasswordAsync(admin, "room", "newpass");
Assert.True(result.IsSuccess);
Assert.True(result.Channel!.IsProtected);
}
[Fact]
public async Task SetChannelPassword_UnprivilegedNonCreator_Forbidden()
{
var service = CreateService();
var creator = await SeedUserAsync();
var member = await SeedUserAsync(ServerRole.Member);
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
var result = await service.SetChannelPasswordAsync(member, "room", "newpass");
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.Forbidden, result.Error);
}
[Fact]
public async Task SetChannelPassword_EncryptedChannel_Refused()
{
var service = CreateService();
var creator = await SeedUserAsync();
await service.CreateChannelAsync(creator, "e2e", null, isPublic: false,
password: "passphrase", encryptionSalt: "salt", wrappedRoomKey: "wrapped");
var result = await service.SetChannelPasswordAsync(creator, "e2e", "newpass");
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.Protected, result.Error);
}
// ── Delete ────────────────────────────────────────────────────────
[Fact]
public async Task DeleteChannel_DefaultChannel_Protected()
{
var service = CreateService();
var owner = await SeedUserAsync(ServerRole.Owner);
// GetChannels auto-creates #general.
await service.GetChannelsAsync(owner, 0, 50);
var result = await service.DeleteChannelAsync(owner, HubConstants.DefaultChannel);
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.Protected, result.Error);
}
[Fact]
public async Task DeleteChannel_Creator_Succeeds()
{
var service = CreateService();
var creator = await SeedUserAsync();
await service.CreateChannelAsync(creator, "temp", null, isPublic: true);
var result = await service.DeleteChannelAsync(creator, "temp");
Assert.True(result.IsSuccess);
Assert.False(await Db().Channels.AnyAsync(c => c.Name == "temp"));
}
[Fact]
public async Task DeleteChannel_UnprivilegedNonCreator_Forbidden()
{
var service = CreateService();
var creator = await SeedUserAsync();
var member = await SeedUserAsync(ServerRole.Member);
await service.CreateChannelAsync(creator, "temp", null, isPublic: true);
var result = await service.DeleteChannelAsync(member, "temp");
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.Forbidden, result.Error);
}
// ── Queries ───────────────────────────────────────────────────────
[Fact]
public async Task GetChannelByName_UnknownChannel_ReturnsNull()
{
var service = CreateService();
Assert.Null(await service.GetChannelByNameAsync("nope"));
}
[Fact]
public async Task GetChannelMeta_ReturnsMessageCountAndFlags()
{
var service = CreateService();
var creator = await SeedUserAsync();
var create = await service.CreateChannelAsync(creator, "room", null, isPublic: true, password: "secret");
var channelId = create.Channel!.Id;
using (var scope = _provider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
db.Messages.Add(new Message
{
Id = Guid.NewGuid(),
Content = "hello",
SentAt = DateTimeOffset.UtcNow,
ChannelId = channelId,
SenderUserId = creator,
SenderUsername = "someone",
});
await db.SaveChangesAsync();
}
var meta = await service.GetChannelMetaAsync("room");
Assert.NotNull(meta);
Assert.Equal(1, meta!.MessageCount);
Assert.True(meta.IsProtected);
Assert.False(meta.IsEncrypted);
}
}
@@ -0,0 +1,107 @@
using System.Text;
using EchoHub.Server.Services;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// <see cref="FileStorageService"/> round-trips against a real temp directory: save, resolve by
/// id (extension-agnostic), bulk id scan, and delete.
/// </summary>
public sealed class FileStorageServiceTests : IDisposable
{
private readonly string _dir;
private readonly FileStorageService _service;
public FileStorageServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), "echohub-filestore-" + Guid.NewGuid().ToString("N"));
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Storage:Path"] = _dir })
.Build();
_service = new FileStorageService(config);
}
public void Dispose()
{
if (Directory.Exists(_dir))
Directory.Delete(_dir, recursive: true);
}
private static Stream StreamOf(string content) => new MemoryStream(Encoding.UTF8.GetBytes(content));
[Fact]
public void Constructor_CreatesStorageDirectory()
{
Assert.True(Directory.Exists(_dir));
}
[Fact]
public async Task SaveFile_WritesContentAndReturnsResolvablePath()
{
var (fileId, filePath) = await _service.SaveFileAsync(StreamOf("hello world"), "note.txt");
Assert.True(File.Exists(filePath));
Assert.Equal("hello world", await File.ReadAllTextAsync(filePath));
Assert.Equal(filePath, _service.GetFilePath(fileId));
}
[Fact]
public async Task SaveFile_PreservesExtension()
{
var (fileId, _) = await _service.SaveFileAsync(StreamOf("x"), "photo.PNG");
var path = _service.GetFilePath(fileId);
Assert.NotNull(path);
Assert.Equal(".PNG", Path.GetExtension(path));
}
[Fact]
public async Task SaveFile_GeneratesDistinctIdsForSameFileName()
{
var (id1, _) = await _service.SaveFileAsync(StreamOf("a"), "dup.txt");
var (id2, _) = await _service.SaveFileAsync(StreamOf("b"), "dup.txt");
Assert.NotEqual(id1, id2);
}
[Fact]
public void GetFilePath_UnknownId_ReturnsNull()
{
Assert.Null(_service.GetFilePath(Guid.NewGuid().ToString()));
}
[Fact]
public async Task GetStoredFileIds_ReturnsAllSavedIds()
{
var (id1, _) = await _service.SaveFileAsync(StreamOf("a"), "a.txt");
var (id2, _) = await _service.SaveFileAsync(StreamOf("b"), "b.bin");
var ids = _service.GetStoredFileIds();
Assert.Contains(id1, ids);
Assert.Contains(id2, ids);
Assert.Equal(2, ids.Count);
}
[Fact]
public async Task DeleteFile_RemovesFile()
{
var (fileId, filePath) = await _service.SaveFileAsync(StreamOf("gone soon"), "temp.dat");
_service.DeleteFile(fileId);
Assert.False(File.Exists(filePath));
Assert.Null(_service.GetFilePath(fileId));
}
[Fact]
public void DeleteFile_UnknownId_DoesNotThrow()
{
var ex = Record.Exception(() => _service.DeleteFile(Guid.NewGuid().ToString()));
Assert.Null(ex);
}
}
+6
View File
@@ -280,6 +280,12 @@ internal sealed class FakeChannelService : IChannelService
public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) =>
Task.FromResult(MembershipResult);
public ChannelDto? SystemChannelToReturn { get; set; }
public Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null) =>
Task.FromResult(SystemChannelToReturn ?? new ChannelDto(
Guid.NewGuid(), channelName, topic, false, 0, DateTimeOffset.UnixEpoch, false, false, true));
}
/// <summary>
+268
View File
@@ -0,0 +1,268 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Models;
using EchoHub.Server.Config;
using EchoHub.Server.Services.ServerLogs;
using Microsoft.Extensions.Configuration;
using Serilog.Core;
using Serilog.Events;
using Serilog.Parsing;
using Xunit;
namespace EchoHub.Tests;
public class ServerLogsTests
{
private static readonly MessageTemplateParser Parser = new();
private static LogEvent MakeEvent(LogEventLevel level, string message,
string? sourceContext = null, Exception? exception = null)
{
var props = new List<LogEventProperty>();
if (sourceContext is not null)
props.Add(new LogEventProperty(Constants.SourceContextPropertyName, new ScalarValue(sourceContext)));
return new LogEvent(DateTimeOffset.UnixEpoch, level, exception, Parser.Parse(message), props);
}
// ── Options binding ───────────────────────────────────────────────
[Fact]
public void Options_Defaults_EnabledModInformation()
{
var options = new ServerLogsOptions();
Assert.True(options.Enabled);
Assert.Equal("server-logs", options.RoomName);
Assert.Equal(ServerRole.Mod, options.MinRole);
Assert.Equal(LogEventLevel.Information, options.MinLevel);
}
[Fact]
public void Options_BoundFromConfiguration_ParsesEnums()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ServerLogs:Enabled"] = "false",
["ServerLogs:RoomName"] = "audit",
["ServerLogs:MinRole"] = "Admin",
["ServerLogs:MinLevel"] = "Warning",
["ServerLogs:BacklogLines"] = "42",
})
.Build();
var options = config.GetSection("ServerLogs").Get<ServerLogsOptions>()!;
Assert.False(options.Enabled);
Assert.Equal("audit", options.RoomName);
Assert.Equal(ServerRole.Admin, options.MinRole);
Assert.Equal(LogEventLevel.Warning, options.MinLevel);
Assert.Equal(42, options.BacklogLines);
}
// ── ServerLogsService: identity + role gate ───────────────────────
[Fact]
public void IsLogsChannel_MatchesConfiguredNameCaseInsensitively()
{
var service = new ServerLogsService(new ServerLogsOptions { RoomName = "Server-Logs" });
Assert.True(service.IsLogsChannel("server-logs"));
Assert.True(service.IsLogsChannel("SERVER-LOGS"));
Assert.False(service.IsLogsChannel("general"));
}
[Fact]
public void IsLogsChannel_WhenDisabled_AlwaysFalse()
{
var service = new ServerLogsService(new ServerLogsOptions { Enabled = false });
Assert.False(service.IsLogsChannel("server-logs"));
}
[Theory]
[InlineData(ServerRole.Member, false)]
[InlineData(ServerRole.Mod, true)]
[InlineData(ServerRole.Admin, true)]
[InlineData(ServerRole.Owner, true)]
public void CanView_RespectsMinRole(ServerRole role, bool expected)
{
var service = new ServerLogsService(new ServerLogsOptions { MinRole = ServerRole.Mod });
Assert.Equal(expected, service.CanView(role));
}
[Fact]
public void CanView_WhenDisabled_AlwaysFalse()
{
var service = new ServerLogsService(new ServerLogsOptions { Enabled = false, MinRole = ServerRole.Member });
Assert.False(service.CanView(ServerRole.Owner));
}
// ── Backlog grouping ──────────────────────────────────────────────
[Fact]
public void GroupIntoEntries_AttachesContinuationLinesToPrecedingEntry()
{
string[] lines =
[
"2026-07-17 10:00:00.000 [INF] first line",
"2026-07-17 10:00:01.000 [ERR] boom",
"System.Exception: boom",
" at Foo.Bar()",
];
var entries = ServerLogsService.GroupIntoEntries(lines, skipLeadingContinuations: false, maxEntries: 100);
Assert.Equal(2, entries.Count);
Assert.Equal("[INF] first line", entries[0].Content);
Assert.Equal("[ERR] boom\nSystem.Exception: boom\n at Foo.Bar()", entries[1].Content);
}
[Fact]
public void GroupIntoEntries_SkipLeadingContinuations_DropsPartialFirstEntry()
{
// Simulates seeking into the middle of a file: the leading stack-trace fragment has no
// owning timestamped line and must be discarded rather than shown as its own entry.
string[] lines =
[
" at Orphaned.Frame()",
"2026-07-17 10:00:00.000 [INF] real entry",
];
var entries = ServerLogsService.GroupIntoEntries(lines, skipLeadingContinuations: true, maxEntries: 100);
Assert.Single(entries);
Assert.Equal("[INF] real entry", entries[0].Content);
}
[Fact]
public void GroupIntoEntries_CapsToMaxEntries_KeepingNewest()
{
var lines = Enumerable.Range(0, 10)
.Select(i => $"2026-07-17 10:00:0{i}.000 [INF] entry {i}")
.ToArray();
var entries = ServerLogsService.GroupIntoEntries(lines, skipLeadingContinuations: false, maxEntries: 3);
Assert.Equal(3, entries.Count);
Assert.Equal("[INF] entry 7", entries[0].Content);
Assert.Equal("[INF] entry 9", entries[2].Content);
}
[Fact]
public void ReadBacklog_ReadsNewestFile_MostRecentEntries()
{
var dir = Path.Combine(Path.GetTempPath(), "echohub-logtest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "echohub-server-20260717.log"),
"2026-07-17 10:00:00.000 [INF] alpha\n2026-07-17 10:00:01.000 [WRN] beta\n");
var service = new ServerLogsService(new ServerLogsOptions
{
LogDirectory = dir,
LogFilePattern = "echohub-server-*.log",
BacklogLines = 100,
});
var entries = service.ReadBacklog();
Assert.Equal(2, entries.Count);
Assert.Equal("[INF] alpha", entries[0].Content);
Assert.Equal("[WRN] beta", entries[1].Content);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ReadBacklog_MissingDirectory_ReturnsEmpty()
{
var service = new ServerLogsService(new ServerLogsOptions
{
LogDirectory = Path.Combine(Path.GetTempPath(), "echohub-does-not-exist-" + Guid.NewGuid().ToString("N")),
});
Assert.Empty(service.ReadBacklog());
}
// ── Formatting ────────────────────────────────────────────────────
[Fact]
public void Format_RendersLevelAndMessage()
{
var formatted = ServerLogsStreamService.Format(MakeEvent(LogEventLevel.Warning, "disk almost full"));
Assert.Equal("[WRN] disk almost full", formatted);
}
[Fact]
public void Format_AppendsException()
{
var formatted = ServerLogsStreamService.Format(
MakeEvent(LogEventLevel.Error, "failed", exception: new InvalidOperationException("nope")));
Assert.StartsWith("[ERR] failed\n", formatted);
Assert.Contains("nope", formatted);
}
[Fact]
public void Format_TruncatesToMaxMessageLength()
{
var formatted = ServerLogsStreamService.Format(
MakeEvent(LogEventLevel.Information, new string('x', HubConstants.MaxMessageLength * 2)));
Assert.True(formatted.Length <= HubConstants.MaxMessageLength);
Assert.EndsWith("…", formatted);
}
// ── Sink: level + source filtering, no feedback loop ──────────────
[Fact]
public void Sink_DropsEventsBelowMinLevel()
{
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Warning });
sink.Emit(MakeEvent(LogEventLevel.Information, "quiet"));
Assert.False(sink.Reader.TryRead(out _));
}
[Fact]
public void Sink_EnqueuesEventsAtOrAboveMinLevel()
{
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Information });
sink.Emit(MakeEvent(LogEventLevel.Warning, "heads up"));
Assert.True(sink.Reader.TryRead(out var e));
Assert.Equal(LogEventLevel.Warning, e!.Level);
}
[Theory]
[InlineData("EchoHub.Server.Services.ServerLogs.ServerLogsStreamService")]
[InlineData("Microsoft.AspNetCore.SignalR.HubConnectionContext")]
[InlineData("Microsoft.AspNetCore.Http.Connections.Internal.HttpConnectionManager")]
public void Sink_DropsEventsFromPipelineSources_PreventingFeedbackLoop(string source)
{
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Information });
sink.Emit(MakeEvent(LogEventLevel.Error, "would loop", sourceContext: source));
Assert.False(sink.Reader.TryRead(out _));
}
[Fact]
public void Sink_KeepsEventsFromOtherSources()
{
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Information });
sink.Emit(MakeEvent(LogEventLevel.Information, "kept", sourceContext: "EchoHub.Server.Services.ChatService"));
Assert.True(sink.Reader.TryRead(out _));
}
}
+259
View File
@@ -0,0 +1,259 @@
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// <see cref="UserService"/> authentication, profile reads, profile-update validation, and
/// avatar. (Registration-gate behavior is covered by <see cref="UserServiceRegistrationTests"/>.)
/// Runs against a real SQLite in-memory database with real BCrypt hashing.
/// </summary>
public sealed class UserServiceTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
private readonly UserService _service;
public UserServiceTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var services = new ServiceCollection();
services.AddDbContext<EchoHubDbContext>(o => o.UseSqlite(_connection));
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
scope.ServiceProvider.GetRequiredService<EchoHubDbContext>().Database.EnsureCreated();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Server:Registration"] = "open" })
.Build();
_service = new UserService(_provider.GetRequiredService<IServiceScopeFactory>(), config);
}
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
private async Task<UserProfileDto> RegisterAsync(string username = "alice", string password = "password1")
{
var result = await _service.RegisterUserAsync(username, password);
Assert.True(result.IsSuccess, result.ErrorMessage);
return result.User!;
}
private EchoHubDbContext Db() =>
_provider.GetRequiredService<IServiceScopeFactory>()
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
// ── Authentication ────────────────────────────────────────────────
[Fact]
public async Task Authenticate_CorrectCredentials_Succeeds()
{
await RegisterAsync("alice", "password1");
var result = await _service.AuthenticateUserAsync("alice", "password1");
Assert.True(result.IsSuccess);
Assert.Equal("alice", result.User!.Username);
}
[Fact]
public async Task Authenticate_IsCaseInsensitiveOnUsername()
{
await RegisterAsync("alice", "password1");
var result = await _service.AuthenticateUserAsync("ALICE", "password1");
Assert.True(result.IsSuccess);
}
[Fact]
public async Task Authenticate_WrongPassword_InvalidCredentials()
{
await RegisterAsync("alice", "password1");
var result = await _service.AuthenticateUserAsync("alice", "wrong");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.InvalidCredentials, result.Error);
}
[Fact]
public async Task Authenticate_UnknownUser_InvalidCredentials()
{
var result = await _service.AuthenticateUserAsync("nobody", "password1");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.InvalidCredentials, result.Error);
}
[Fact]
public async Task Authenticate_EmptyInput_ValidationFailed()
{
var result = await _service.AuthenticateUserAsync("", "");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.ValidationFailed, result.Error);
}
[Fact]
public async Task Authenticate_BannedUser_ReturnsBanned()
{
var profile = await RegisterAsync("alice", "password1");
using (var scope = _provider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var user = await db.Users.FindAsync(profile.Id);
user!.IsBanned = true;
await db.SaveChangesAsync();
}
var result = await _service.AuthenticateUserAsync("alice", "password1");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.Banned, result.Error);
}
// ── Profile reads ─────────────────────────────────────────────────
[Fact]
public async Task GetUserProfile_KnownUser_ReturnsProfile()
{
await RegisterAsync("alice", "password1");
var profile = await _service.GetUserProfileAsync("alice");
Assert.NotNull(profile);
Assert.Equal("alice", profile!.Username);
}
[Fact]
public async Task GetUserProfile_UnknownUser_ReturnsNull()
{
Assert.Null(await _service.GetUserProfileAsync("ghost"));
}
[Fact]
public async Task GetUserById_RoundTrips()
{
var profile = await RegisterAsync("alice", "password1");
var byId = await _service.GetUserByIdAsync(profile.Id);
Assert.NotNull(byId);
Assert.Equal("alice", byId!.Username);
}
[Fact]
public async Task GetUserById_UnknownId_ReturnsNull()
{
Assert.Null(await _service.GetUserByIdAsync(Guid.NewGuid()));
}
// ── Profile updates ───────────────────────────────────────────────
[Fact]
public async Task UpdateProfile_ValidFields_Persisted()
{
var profile = await RegisterAsync("alice", "password1");
var result = await _service.UpdateProfileAsync(profile.Id, "Alice A", "hi there", "#FF5500");
Assert.True(result.IsSuccess);
Assert.Equal("Alice A", result.User!.DisplayName);
Assert.Equal("hi there", result.User.Bio);
Assert.Equal("#FF5500", result.User.NicknameColor);
}
[Fact]
public async Task UpdateProfile_InvalidHexColor_Rejected()
{
var profile = await RegisterAsync("alice", "password1");
var result = await _service.UpdateProfileAsync(profile.Id, null, null, "red");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.ValidationFailed, result.Error);
}
[Fact]
public async Task UpdateProfile_EmptyColor_ClearsIt()
{
var profile = await RegisterAsync("alice", "password1");
await _service.UpdateProfileAsync(profile.Id, null, null, "#FF5500");
var result = await _service.UpdateProfileAsync(profile.Id, null, null, "");
Assert.True(result.IsSuccess);
Assert.Null(result.User!.NicknameColor);
}
[Fact]
public async Task UpdateProfile_DisplayNameTooLong_Rejected()
{
var profile = await RegisterAsync("alice", "password1");
var result = await _service.UpdateProfileAsync(
profile.Id, new string('x', ValidationConstants.MaxDisplayNameLength + 1), null, null);
Assert.False(result.IsSuccess);
Assert.Equal(UserError.ValidationFailed, result.Error);
}
[Fact]
public async Task UpdateProfile_BioTooLong_Rejected()
{
var profile = await RegisterAsync("alice", "password1");
var result = await _service.UpdateProfileAsync(
profile.Id, null, new string('x', ValidationConstants.MaxBioLength + 1), null);
Assert.False(result.IsSuccess);
Assert.Equal(UserError.ValidationFailed, result.Error);
}
[Fact]
public async Task UpdateProfile_UnknownUser_NotFound()
{
var result = await _service.UpdateProfileAsync(Guid.NewGuid(), "x", null, null);
Assert.False(result.IsSuccess);
Assert.Equal(UserError.NotFound, result.Error);
}
// ── Avatar ────────────────────────────────────────────────────────
[Fact]
public async Task SetAvatar_Persisted()
{
var profile = await RegisterAsync("alice", "password1");
var result = await _service.SetAvatarAsync(profile.Id, "{F:FF0000}art");
Assert.True(result.IsSuccess);
var stored = await Db().Users.FindAsync(profile.Id);
Assert.Equal("{F:FF0000}art", stored!.AvatarAscii);
}
[Fact]
public async Task SetAvatar_UnknownUser_NotFound()
{
var result = await _service.SetAvatarAsync(Guid.NewGuid(), "art");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.NotFound, result.Error);
}
}