From 38eca99fb1f55f916fbc459418574665fbc1d1af Mon Sep 17 00:00:00 2001 From: HueByte Date: Fri, 17 Jul 2026 20:44:47 +0200 Subject: [PATCH] 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. --- docs/articles/configuration.md | 43 +- .../UI/ListSources/ChannelListSource.cs | 21 +- src/EchoHub.Client/UI/MainWindow.cs | 58 ++- src/EchoHub.Core/Contracts/IChannelService.cs | 5 + src/EchoHub.Core/DTOs/ChatDtos.cs | 3 +- src/EchoHub.Core/Models/Channel.cs | 4 + src/EchoHub.Server.Irc/IrcCommandHandler.cs | 10 + .../Config/ServerLogsOptions.cs | 43 ++ .../Controllers/ChannelsController.cs | 6 + ...60717182450_AddChannelIsSystem.Designer.cs | 376 ++++++++++++++++++ .../20260717182450_AddChannelIsSystem.cs | 29 ++ .../EchoHubDbContextModelSnapshot.cs | 3 + src/EchoHub.Server/Program.cs | 21 +- src/EchoHub.Server/Services/ChannelService.cs | 103 ++++- src/EchoHub.Server/Services/ChatService.cs | 34 ++ .../Services/ServerLogs/ServerLogsService.cs | 138 +++++++ .../Services/ServerLogs/ServerLogsSink.cs | 51 +++ .../ServerLogs/ServerLogsStreamService.cs | 128 ++++++ src/EchoHub.Server/appsettings.example.json | 9 + .../ChannelServiceSystemChannelTests.cs | 227 +++++++++++ src/EchoHub.Tests/Irc/TestHelpers.cs | 6 + src/EchoHub.Tests/ServerLogsTests.cs | 268 +++++++++++++ 22 files changed, 1569 insertions(+), 17 deletions(-) create mode 100644 src/EchoHub.Server/Config/ServerLogsOptions.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260717182450_AddChannelIsSystem.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260717182450_AddChannelIsSystem.cs create mode 100644 src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs create mode 100644 src/EchoHub.Server/Services/ServerLogs/ServerLogsSink.cs create mode 100644 src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs create mode 100644 src/EchoHub.Tests/ChannelServiceSystemChannelTests.cs create mode 100644 src/EchoHub.Tests/ServerLogsTests.cs diff --git a/docs/articles/configuration.md b/docs/articles/configuration.md index af9605e..4b97537 100644 --- a/docs/articles/configuration.md +++ b/docs/articles/configuration.md @@ -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. diff --git a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs index 0b4ea34..deecdf8 100644 --- a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs +++ b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs @@ -19,6 +19,7 @@ public class ChannelListSource : IListDataSource private readonly HashSet _protectedChannels = []; private readonly HashSet _mentionChannels = []; private readonly HashSet _privateChannels = []; + private readonly HashSet _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 channels, Dictionary unread, string activeChannel, IReadOnlySet? protectedChannels = null, IReadOnlySet? mentionChannels = null, - IReadOnlySet? privateChannels = null) + IReadOnlySet? privateChannels = null, IReadOnlySet? 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); } } diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index f05ee5a..6718647 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -75,6 +75,7 @@ public sealed partial class MainWindow : Runnable private readonly Dictionary _channelTopics = []; private readonly Dictionary _channelPublic = []; private readonly HashSet _channelProtected = []; + private readonly HashSet _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 /// /// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// - 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; } + /// Whether the active channel is read-only (a system channel like the log room). + private bool IsCurrentChannelReadOnly => _systemChannels.Contains(_messageManager.CurrentChannel); + + /// + /// Disables the input for read-only (system) channels so nothing can be typed there, and + /// reflects the state in the input frame title. + /// + private void UpdateInputReadOnly() + { + _inputField.ReadOnly = IsCurrentChannelReadOnly; + UpdateInputTitle(); + } + /// /// Clear all messages and channels (used on disconnect). /// @@ -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 /// 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 diff --git a/src/EchoHub.Core/Contracts/IChannelService.cs b/src/EchoHub.Core/Contracts/IChannelService.cs index 604f367..2bbf518 100644 --- a/src/EchoHub.Core/Contracts/IChannelService.cs +++ b/src/EchoHub.Core/Contracts/IChannelService.cs @@ -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 EnsureSystemChannelAsync(string channelName, string? topic = null); } public record ChannelListItem(string Name, string? Topic, int OnlineCount, bool IsPublic = true, bool IsProtected = false); diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 49fc7ab..1f87d65 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -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, diff --git a/src/EchoHub.Core/Models/Channel.cs b/src/EchoHub.Core/Models/Channel.cs index 27d7c06..e5e9b4f 100644 --- a/src/EchoHub.Core/Models/Channel.cs +++ b/src/EchoHub.Core/Models/Channel.cs @@ -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). diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index ee32f6e..b3c07f4 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -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); diff --git a/src/EchoHub.Server/Config/ServerLogsOptions.cs b/src/EchoHub.Server/Config/ServerLogsOptions.cs new file mode 100644 index 0000000..54a6eb1 --- /dev/null +++ b/src/EchoHub.Server/Config/ServerLogsOptions.cs @@ -0,0 +1,43 @@ +using EchoHub.Core.Models; +using Serilog.Events; + +namespace EchoHub.Server.Config; + +/// +/// Live server-log room settings, bound from the "ServerLogs" config section (env override: +/// ServerLogs__Enabled 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. +/// +public sealed class ServerLogsOptions +{ + /// Master switch for the live log room. + public bool Enabled { get; set; } = true; + + /// + /// 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. + /// + public string RoomName { get; set; } = "server-logs"; + + /// Minimum server role allowed to see and join the log room (Member/Mod/Admin/Owner). + public ServerRole MinRole { get; set; } = ServerRole.Mod; + + /// + /// 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. + /// + public LogEventLevel MinLevel { get; set; } = LogEventLevel.Information; + + /// How many recent log entries are replayed from the log file when someone opens the room. + public int BacklogLines { get; set; } = 100; + + /// Directory holding the rolling log files. Must match the Serilog file sink's path. + public string LogDirectory { get; set; } = "logs"; + + /// Filename glob for the rolling log files inside . + public string LogFilePattern { get; set; } = "echohub-server-*.log"; + + /// Channel names are stored lowercased; compare against this form. + public string NormalizedRoomName => RoomName.ToLowerInvariant().Trim(); +} diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index a66f17b..0765e10 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -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.")); diff --git a/src/EchoHub.Server/Data/Migrations/20260717182450_AddChannelIsSystem.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260717182450_AddChannelIsSystem.Designer.cs new file mode 100644 index 0000000..de2faf8 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260717182450_AddChannelIsSystem.Designer.cs @@ -0,0 +1,376 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AsciiPreview") + .HasMaxLength(64000) + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("EncryptionSalt") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WrappedRoomKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedByUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("UseCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("InviteCodes"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("ReplyToMessageId") + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260717182450_AddChannelIsSystem.cs b/src/EchoHub.Server/Data/Migrations/20260717182450_AddChannelIsSystem.cs new file mode 100644 index 0000000..1cc17eb --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260717182450_AddChannelIsSystem.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddChannelIsSystem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsSystem", + table: "Channels", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsSystem", + table: "Channels"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 6f93f9b..8e02d18 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -72,6 +72,9 @@ namespace EchoHub.Server.Data.Migrations b.Property("IsPublic") .HasColumnType("INTEGER"); + b.Property("IsSystem") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasMaxLength(100) diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 2a0fbe8..27b9de9 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -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(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() ?? new ServerLogsOptions(); + builder.Services.AddSingleton(serverLogsOptions); + builder.Services.AddSingleton(); + 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(); builder.Services.AddHostedService(); + // Live server-log streaming (only when the sink is active) + if (serverLogsSink is not null) + builder.Services.AddHostedService(); + // ── Encryption ───────────────────────────────────────────────────── builder.Services.AddSingleton(); diff --git a/src/EchoHub.Server/Services/ChannelService.cs b/src/EchoHub.Server/Services/ChannelService.cs index 3fb48b5..6b3cd54 100644 --- a/src/EchoHub.Server/Services/ChannelService.cs +++ b/src/EchoHub.Server/Services/ChannelService.cs @@ -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 _logger; public ChannelService( IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, SpamGuard spamGuard, + ServerLogsService serverLogs, ILogger 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(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 GetChannelMetaAsync(string channelName) @@ -372,6 +397,16 @@ public class ChannelService : IChannelService using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + // 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 EnsureSystemChannelAsync(string channelName, string? topic = null) + { + channelName = channelName.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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)) diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 8e9985d..9d29b47 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -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 _logger; public ChatService( @@ -32,6 +34,7 @@ public class ChatService : IChatService IChannelService channelService, FileStorageService fileStorage, SpamGuard spamGuard, + ServerLogsService serverLogs, ILogger 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(); return await GetChannelHistoryInternalAsync(db, channelName, count, offset); } + /// + /// Turns the log-file backlog into transport-encrypted s so clients + /// render past log lines exactly like streamed ones. Never touches the database. + /// + private List BuildLogBacklog(string channelName) + { + return _serverLogs.ReadBacklog() + .Select(entry => new MessageDto( + Guid.NewGuid(), + _encryption.Encrypt(entry.Content), + ServerLogsService.SenderName, + null, + channelName, + entry.Timestamp)) + .ToList(); + } + /// /// 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 diff --git a/src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs b/src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs new file mode 100644 index 0000000..9386b5b --- /dev/null +++ b/src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using EchoHub.Core.Models; +using EchoHub.Server.Config; + +namespace EchoHub.Server.Services.ServerLogs; + +/// +/// A backlog entry read from the log file: one timestamped log line plus any continuation +/// lines (exception stack traces) that followed it. +/// +public record LogBacklogEntry(DateTimeOffset Timestamp, string Content); + +/// +/// 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. +/// +public sealed class ServerLogsService +{ + /// Username shown as the sender of streamed log messages. + public const string SenderName = "server"; + + public const string RoomTopic = "Live server logs — read-only"; + + /// Timestamp prefix of the file sink's output template. + private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff"; + + /// How far back into the log file the backlog read reaches, at most. + private const int TailReadBytes = 256 * 1024; + + private readonly ServerLogsOptions _options; + + public ServerLogsService(ServerLogsOptions options) => _options = options; + + public ServerLogsOptions Options => _options; + + /// Whether the given channel is the (enabled) live log room. + public bool IsLogsChannel(string channelName) => + _options.Enabled + && string.Equals(channelName.Trim(), _options.NormalizedRoomName, StringComparison.OrdinalIgnoreCase); + + /// Whether a user with this role may see and join the log room. + public bool CanView(ServerRole role) => _options.Enabled && role >= _options.MinRole; + + /// + /// Reads the last 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. + /// + public IReadOnlyList 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 []; + } + } + + /// + /// Groups raw file lines into entries by their timestamp prefix. Public for tests. + /// + public static IReadOnlyList GroupIntoEntries( + IReadOnlyList lines, bool skipLeadingContinuations, int maxEntries) + { + var entries = new List(); + 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; + } +} diff --git a/src/EchoHub.Server/Services/ServerLogs/ServerLogsSink.cs b/src/EchoHub.Server/Services/ServerLogs/ServerLogsSink.cs new file mode 100644 index 0000000..34f82c9 --- /dev/null +++ b/src/EchoHub.Server/Services/ServerLogs/ServerLogsSink.cs @@ -0,0 +1,51 @@ +using System.Threading.Channels; +using EchoHub.Server.Config; +using Serilog.Core; +using Serilog.Events; + +namespace EchoHub.Server.Services.ServerLogs; + +/// +/// Serilog sink feeding the live log room. Events are queued in a bounded drop-oldest buffer +/// and consumed by ; 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. +/// +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 _queue = Channel.CreateBounded( + new BoundedChannelOptions(QueueCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + }); + + private readonly LogEventLevel _minLevel; + + public ServerLogsSink(ServerLogsOptions options) => _minLevel = options.MinLevel; + + public ChannelReader 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); + } +} diff --git a/src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs b/src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs new file mode 100644 index 0000000..3584399 --- /dev/null +++ b/src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs @@ -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; + +/// +/// 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 +/// as a second line of defense, but the primary rule is simply +/// not to log per event, otherwise every streamed line would spawn another. +/// +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? _hubContext; + private DateTimeOffset _lastEnsure = DateTimeOffset.MinValue; + + // Resolved lazily: the hub context isn't available while hosted services are constructed. + private IHubContext HubContext + => _hubContext ??= _serviceProvider.GetRequiredService>(); + + 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. + } + } + } + + /// + /// Recreates the log room if it disappeared, at most once per . + /// + 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. + } + } + + /// Formats an event like the file sink's template, minus the timestamp (clients render their own). Public for tests. + 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(), + }; +} diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index fa5982f..c9a0d9a 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -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, diff --git a/src/EchoHub.Tests/ChannelServiceSystemChannelTests.cs b/src/EchoHub.Tests/ChannelServiceSystemChannelTests.cs new file mode 100644 index 0000000..c94c597 --- /dev/null +++ b/src/EchoHub.Tests/ChannelServiceSystemChannelTests.cs @@ -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; + +/// +/// System-channel behavior of (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. +/// +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(o => o.UseSqlite(_connection)); + _provider = services.BuildServiceProvider(); + + using var scope = _provider.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + } + + public void Dispose() + { + _provider.Dispose(); + _connection.Dispose(); + } + + private ChannelService CreateService() => new( + _provider.GetRequiredService(), + new PresenceTracker(), + // Disable the spam guard so channel-create throttling never interferes with assertions. + new SpamGuard(new SpamOptions { Enabled = false }), + _serverLogs, + NullLogger.Instance); + + private async Task SeedUserAsync(ServerRole role) + { + using var scope = _provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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() + .CreateScope().ServiceProvider.GetRequiredService(); + + // ── 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(); + 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); + } +} diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 9d954dc..ec1a404 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -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 EnsureSystemChannelAsync(string channelName, string? topic = null) => + Task.FromResult(SystemChannelToReturn ?? new ChannelDto( + Guid.NewGuid(), channelName, topic, false, 0, DateTimeOffset.UnixEpoch, false, false, true)); } /// diff --git a/src/EchoHub.Tests/ServerLogsTests.cs b/src/EchoHub.Tests/ServerLogsTests.cs new file mode 100644 index 0000000..25a926c --- /dev/null +++ b/src/EchoHub.Tests/ServerLogsTests.cs @@ -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(); + 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 + { + ["ServerLogs:Enabled"] = "false", + ["ServerLogs:RoomName"] = "audit", + ["ServerLogs:MinRole"] = "Admin", + ["ServerLogs:MinLevel"] = "Warning", + ["ServerLogs:BacklogLines"] = "42", + }) + .Build(); + + var options = config.GetSection("ServerLogs").Get()!; + + 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 _)); + } +}