Compare commits

..
Author SHA1 Message Date
Hue 6e57247a45 Merge pull request #64 from HueByte/dev_logs_extended
feat: add periodic server stats reporting and logging
2026-07-17 22:23:13 +02:00
HueByte fd61d9cb9c fix: remove invisible character from migration file 2026-07-17 22:18:49 +02:00
HueByte 79e5a1191f feat: update changelog for v0.2.16 release with new features and improvements 2026-07-17 22:17:21 +02:00
HueByte 7525f8b1d8 feat: add periodic server stats reporting and logging
- Introduced ServerStatsReport and ServerStatsCollector for tracking server activity.
- Implemented ServerStatsReportService to generate and persist stats reports periodically.
- Added configuration options for stats reporting in appsettings and .env.example.
- Enhanced logging in ChannelsController and ModerationController to include stats-related actions.
- Updated database context and migrations to support new ServerStatsReport entity.
- Adjusted logging levels in IrcGatewayService and ChatService for better performance.
2026-07-17 22:14:55 +02:00
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
39 changed files with 3387 additions and 29 deletions
+16
View File
@@ -75,3 +75,19 @@ Irc__Enabled=false
# ── Logging ────────────────────────────────────────────────────────── # ── Logging ──────────────────────────────────────────────────────────
# Serilog__MinimumLevel__Default=Information # Serilog__MinimumLevel__Default=Information
# ── Server logs room ─────────────────────────────────────────────────
# Read-only system channel that live-streams Serilog events to Mod+ users.
# ServerLogs__Enabled=true
# ServerLogs__RoomName=server-logs
# ServerLogs__MinRole=Mod
# ServerLogs__MinLevel=Information
# ServerLogs__BacklogLines=100
# ServerLogs__LogDirectory=logs
# ServerLogs__LogFilePattern=echohub-server-*.log
# ── Periodic stats report ────────────────────────────────────────────
# Aggregate activity snapshot logged as JSON and persisted to the DB.
# Stats__Enabled=true
# Stats__IntervalHours=6
# Stats__RetentionDays=90
+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:Name` | `My EchoHub Server` | Display name shown to clients |
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description | | `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
| `Server:PublicServer` | `false` | Register on the [public directory](https://echohub.voidcube.cloud/servers) | | `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: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 ### 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 The server sizes its request-body limits from these values, so raising a limit here is all
that's needed — no separate Kestrel tuning. 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 ### Encryption
| Key | Default | Description | | Key | Default | Description |
@@ -129,6 +152,7 @@ that's needed — no separate Kestrel tuning.
| Key | Default | Description | | 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:CleanupIntervalHours` | `1` | How often the cleanup job runs (hours) |
| `Storage:RetentionDays` | `30` | Days to keep uploaded files before cleanup | | `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:ServerName` | `echohub` | IRC server name in protocol messages |
| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day | | `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 ### Logging
EchoHub uses [Serilog](https://serilog.net/) for structured logging — console output + daily rolling files with 14-day retention by default. EchoHub uses [Serilog](https://serilog.net/) for structured logging — console output + daily rolling files with 14-day retention by default.
+1
View File
@@ -4,6 +4,7 @@ Release history for EchoHub.
## Releases ## Releases
- [v0.2.16](v0.2.16.md) - Periodic Server-Stats Report, Upload & Moderation Logging & Quieter Connection Logs
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links - [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
- [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish - [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions - [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
+2
View File
@@ -1,5 +1,7 @@
- name: Overview - name: Overview
href: index.md href: index.md
- name: v0.2.16
href: v0.2.16.md
- name: v0.2.15 - name: v0.2.15
href: v0.2.15.md href: v0.2.15.md
- name: v0.2.14 - name: v0.2.14
+43
View File
@@ -0,0 +1,43 @@
# v0.2.16
A logging and observability pass built on the new server-logs room. Busy servers stop drowning
in connect/disconnect noise, the events operators actually care about — uploads, moderation
actions — now get logged, and a new periodic report summarizes server activity as pretty-printed
JSON that streams into the logs room and is saved to the database for history.
## New Features
- **Periodic server-stats report** — every 6 hours (configurable), the server logs an aggregate
activity snapshot as pretty-printed JSON and saves it to the database. Each report covers the
window since the previous one: messages sent, files uploaded (and total bytes), new members,
active members (distinct senders), session connections/disconnections, kicks, bans, total
registered members, users online now, and peak concurrent online. Reports also stream into the
live server-logs room, and old ones are pruned past a configurable retention. New `Stats` config
section (`Stats:Enabled`, `Stats:IntervalHours` = 6, `Stats:RetentionDays` = 90).
- **Upload logging** — every file, image, and audio upload is now logged with its resource URL
(`/api/files/{id}`), kind, size, uploader, and channel. In end-to-end encrypted rooms the
filename is client-side ciphertext, so it's logged as `[encrypted]` — the server still never
sees room content.
- **Moderation & user-action logging** — role changes, kicks, bans, unbans, mutes, unmutes,
moderator message removals, and channel nukes are now logged with the actor, target, and reason.
Bans and channel nukes log at Warning level so they stand out.
## Improvements
- **Quieter connection logs** — user connect, disconnect, join-channel, and leave-channel events
(over both SignalR and IRC) dropped from Information to Debug. On a busy server these fired
constantly and buried everything else; the aggregate counts now live in the periodic stats
report instead. They still show in Development, where the default level is Debug.
- **Config examples stay in sync** — `.env.example` gained the `ServerLogs` and `Stats` sections
(the former had been missing), alongside `appsettings.json` / `appsettings.example.json`.
## Notes for server operators
- New config section: `Stats``Enabled` (default true), `IntervalHours` (default 6),
`RetentionDays` (default 90, `0` keeps reports forever). See `appsettings.example.json` and
`.env.example`. A non-positive interval falls back to 6h; positive values are honored down to a
1-second floor.
- One new database migration (`AddServerStatsReports`) applies automatically on startup.
- Connect/disconnect/join/leave now log at Debug — if you relied on those Information lines, lower
`Serilog:MinimumLevel:Default` (or `ServerLogs:MinLevel` for the room) to `Debug`, or read the
periodic stats report for aggregates.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.2.15</Version> <Version>0.2.16</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup> </PropertyGroup>
@@ -19,6 +19,7 @@ public class ChannelListSource : IListDataSource
private readonly HashSet<string> _protectedChannels = []; private readonly HashSet<string> _protectedChannels = [];
private readonly HashSet<string> _mentionChannels = []; private readonly HashSet<string> _mentionChannels = [];
private readonly HashSet<string> _privateChannels = []; private readonly HashSet<string> _privateChannels = [];
private readonly HashSet<string> _systemChannels = [];
private string _activeChannel = string.Empty; private string _activeChannel = string.Empty;
public event NotifyCollectionChangedEventHandler? CollectionChanged; 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 NormalAttr = new(Color.DarkGray, Color.None);
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, 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); 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, public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null, IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null,
IReadOnlySet<string>? privateChannels = null) IReadOnlySet<string>? privateChannels = null, IReadOnlySet<string>? systemChannels = null)
{ {
_channelNames.Clear(); _channelNames.Clear();
_channelNames.AddRange(channels); _channelNames.AddRange(channels);
@@ -50,6 +53,9 @@ public class ChannelListSource : IListDataSource
_privateChannels.Clear(); _privateChannels.Clear();
if (privateChannels is not null) if (privateChannels is not null)
_privateChannels.UnionWith(privateChannels); _privateChannels.UnionWith(privateChannels);
_systemChannels.Clear();
if (systemChannels is not null)
_systemChannels.UnionWith(systemChannels);
_activeChannel = activeChannel; _activeChannel = activeChannel;
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0; MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
if (!SuspendCollectionChangedEvent) if (!SuspendCollectionChangedEvent)
@@ -71,7 +77,9 @@ public class ChannelListSource : IListDataSource
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); 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 // Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones
var channelText = $"#{name}"; var channelText = $"#{name}";
if (_protectedChannels.Contains(name)) if (_protectedChannels.Contains(name))
@@ -93,12 +101,15 @@ public class ChannelListSource : IListDataSource
} }
else 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); drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
// Mentions escalate above plain unread: the whole entry turns orange // Mentions escalate above plain unread: the whole entry turns orange
var hasMention = _mentionChannels.Contains(name); var hasMention = _mentionChannels.Contains(name);
var nameAttr = isActive ? ActiveAttr var nameAttr = isSystem ? SystemAttr
: isActive ? ActiveAttr
: hasMention ? MentionAttr : hasMention ? MentionAttr
: hasUnread ? UnreadAttr : hasUnread ? UnreadAttr
: NormalAttr; : NormalAttr;
@@ -107,7 +118,7 @@ public class ChannelListSource : IListDataSource
if (hasUnread) if (hasUnread)
{ {
listView.SetAttribute(Resolve(hasMention ? MentionAttr : BadgeAttr)); listView.SetAttribute(Resolve(isSystem ? SystemAttr : hasMention ? MentionAttr : BadgeAttr));
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width); 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, string?> _channelTopics = [];
private readonly Dictionary<string, bool> _channelPublic = []; private readonly Dictionary<string, bool> _channelPublic = [];
private readonly HashSet<string> _channelProtected = []; private readonly HashSet<string> _channelProtected = [];
private readonly HashSet<string> _systemChannels = [];
private readonly ChannelListSource _channelListSource; private readonly ChannelListSource _channelListSource;
private readonly ChatMessageManager _messageManager; private readonly ChatMessageManager _messageManager;
private string _connectionStatus = "Disconnected"; private string _connectionStatus = "Disconnected";
@@ -406,6 +407,14 @@ public sealed partial class MainWindow : Runnable
private void UpdateInputTitle() 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 _inputFrame.Title = (_replyTitleFragment, _stagedTitleFragment) switch
{ {
(null, null) => DefaultInputTitle, (null, null) => DefaultInputTitle,
@@ -850,6 +859,8 @@ public sealed partial class MainWindow : Runnable
break; break;
case EnterKey: case EnterKey:
if (IsCurrentChannelReadOnly)
break;
var text = _inputField.Text?.Trim() ?? string.Empty; var text = _inputField.Text?.Trim() ?? string.Empty;
// Send when there's text, or when only attachments are staged (empty caption). // Send when there's text, or when only attachments are staged (empty caption).
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments) if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
@@ -876,6 +887,9 @@ public sealed partial class MainWindow : Runnable
case CtrlVKey: case CtrlVKey:
case CtrlYKey: 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 // 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 // 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. // 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(); _channelTopics.Clear();
_channelPublic.Clear(); _channelPublic.Clear();
_channelProtected.Clear(); _channelProtected.Clear();
_systemChannels.Clear();
foreach (var ch in channels) foreach (var ch in channels)
{ {
_channelNames.Add(ch.Name); _channelNames.Add(ch.Name);
@@ -1109,6 +1124,8 @@ public sealed partial class MainWindow : Runnable
_channelPublic[ch.Name] = ch.IsPublic; _channelPublic[ch.Name] = ch.IsPublic;
if (ch.IsProtected) if (ch.IsProtected)
_channelProtected.Add(ch.Name); _channelProtected.Add(ch.Name);
if (ch.IsSystem)
_systemChannels.Add(ch.Name);
} }
RefreshChannelList(); RefreshChannelList();
} }
@@ -1116,7 +1133,8 @@ public sealed partial class MainWindow : Runnable
/// <summary> /// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary> /// </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) if (isPublic.HasValue)
_channelPublic[channelName] = isPublic.Value; _channelPublic[channelName] = isPublic.Value;
@@ -1127,9 +1145,15 @@ public sealed partial class MainWindow : Runnable
else _channelProtected.Remove(channelName); else _channelProtected.Remove(channelName);
} }
if (isSystem.HasValue)
{
if (isSystem.Value) _systemChannels.Add(channelName);
else _systemChannels.Remove(channelName);
}
if (_channelNames.Contains(channelName)) if (_channelNames.Contains(channelName))
{ {
if (isProtected.HasValue) if (isProtected.HasValue || isSystem.HasValue)
RefreshChannelList(); RefreshChannelList();
return; return;
} }
@@ -1147,6 +1171,7 @@ public sealed partial class MainWindow : Runnable
_channelTopics.Remove(channelName); _channelTopics.Remove(channelName);
_channelPublic.Remove(channelName); _channelPublic.Remove(channelName);
_channelProtected.Remove(channelName); _channelProtected.Remove(channelName);
_systemChannels.Remove(channelName);
RefreshChannelList(); RefreshChannelList();
} }
@@ -1337,6 +1362,7 @@ public sealed partial class MainWindow : Runnable
RefreshMessages(); RefreshMessages();
UpdateTopicBar(); UpdateTopicBar();
UpdateInputReadOnly();
_statusLabel.SetNeedsDraw(); _statusLabel.SetNeedsDraw();
// Update channel list selection // Update channel list selection
@@ -1345,6 +1371,19 @@ public sealed partial class MainWindow : Runnable
_channelList.SelectedItem = idx; _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> /// <summary>
/// Clear all messages and channels (used on disconnect). /// Clear all messages and channels (used on disconnect).
/// </summary> /// </summary>
@@ -1355,6 +1394,7 @@ public sealed partial class MainWindow : Runnable
_channelTopics.Clear(); _channelTopics.Clear();
_channelPublic.Clear(); _channelPublic.Clear();
_channelProtected.Clear(); _channelProtected.Clear();
_systemChannels.Clear();
_channelListSource.Update([], [], string.Empty); _channelListSource.Update([], [], string.Empty);
_channelList.Source = _channelListSource; _channelList.Source = _channelListSource;
_chatFrame.Title = "Chat"; _chatFrame.Title = "Chat";
@@ -1459,11 +1499,21 @@ public sealed partial class MainWindow : Runnable
/// </summary> /// </summary>
private void RefreshChannelList() 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 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(); .ToHashSet();
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
_channelProtected, _messageManager.MentionChannels, privateChannels); _channelProtected, _messageManager.MentionChannels, privateChannels, _systemChannels);
_channelList.Source = _channelListSource; _channelList.Source = _channelListSource;
// Restore selection to current channel // Restore selection to current channel
@@ -24,6 +24,11 @@ public interface IChannelService
// Membership // Membership
Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null); 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); 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, int MessageCount,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
bool IsProtected = false, bool IsProtected = false,
bool IsEncrypted = false); bool IsEncrypted = false,
bool IsSystem = false);
public record UserDto( public record UserDto(
Guid Id, Guid Id,
+4
View File
@@ -6,6 +6,10 @@ public class Channel
public required string Name { get; set; } public required string Name { get; set; }
public string? Topic { get; set; } public string? Topic { get; set; }
public bool IsPublic { get; set; } = true; 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; } public string? PasswordHash { get; set; }
// End-to-end encryption envelope (client-generated; server cannot decrypt room content). // End-to-end encryption envelope (client-generated; server cannot decrypt room content).
@@ -0,0 +1,62 @@
namespace EchoHub.Core.Models;
/// <summary>
/// A snapshot of server activity over one reporting window, produced periodically by the
/// stats-report background job. Each report is logged as pretty-printed JSON and persisted
/// for historical trend analysis. The window is "since the previous report" (or since startup
/// for the first report of a run).
/// </summary>
public class ServerStatsReport
{
public Guid Id { get; set; }
/// <summary>When this report was generated (equals <see cref="PeriodEnd"/>).</summary>
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>Start of the reporting window.</summary>
public DateTimeOffset PeriodStart { get; set; }
/// <summary>End of the reporting window.</summary>
public DateTimeOffset PeriodEnd { get; set; }
/// <summary>Length of the reporting window in hours.</summary>
public double WindowHours { get; set; }
// ── Activity during the window ──────────────────────────────────────────
/// <summary>Messages sent during the window.</summary>
public int MessagesSent { get; set; }
/// <summary>Attachments (files/images/audio) uploaded during the window.</summary>
public int FilesUploaded { get; set; }
/// <summary>Total bytes across all attachments uploaded during the window.</summary>
public long BytesUploaded { get; set; }
/// <summary>Accounts registered during the window ("new members joined").</summary>
public int NewMembers { get; set; }
/// <summary>Distinct users who sent at least one message during the window.</summary>
public int ActiveMembers { get; set; }
/// <summary>Session connects during the window (per-connection, across SignalR + IRC).</summary>
public int Connections { get; set; }
/// <summary>Session disconnects during the window ("members left" sessions).</summary>
public int Disconnections { get; set; }
/// <summary>Users kicked during the window.</summary>
public int Kicks { get; set; }
/// <summary>Users banned during the window.</summary>
public int Bans { get; set; }
// ── Point-in-time totals at window end ──────────────────────────────────
/// <summary>Total registered (unique) members at window end.</summary>
public int TotalMembers { get; set; }
/// <summary>Distinct users online at window end.</summary>
public int OnlineNow { get; set; }
/// <summary>Peak distinct users online observed during the window.</summary>
public int PeakOnline { get; set; }
}
@@ -400,6 +400,16 @@ public sealed class IrcCommandHandler
continue; 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( var (history, error, passwordRequired) = await _chatService.JoinChannelAsync(
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key); _conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key);
+2 -2
View File
@@ -113,7 +113,7 @@ public sealed class IrcGatewayService : BackgroundService
var connection = new IrcClientConnection(tcpClient, stream); var connection = new IrcClientConnection(tcpClient, stream);
_connections[connection.ConnectionId] = connection; _connections[connection.ConnectionId] = connection;
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId); _logger.LogDebug("IRC client connected: {Id}", connection.ConnectionId);
IChatService? chatService = null; IChatService? chatService = null;
@@ -148,7 +148,7 @@ public sealed class IrcGatewayService : BackgroundService
_connections.TryRemove(connection.ConnectionId, out _); _connections.TryRemove(connection.ConnectionId, out _);
await connection.DisposeAsync(); await connection.DisposeAsync();
_logger.LogInformation("IRC client {Id} ({Nick}) disconnected", _logger.LogDebug("IRC client {Id} ({Nick}) disconnected",
connection.ConnectionId, connection.Nickname ?? "unregistered"); connection.ConnectionId, connection.Nickname ?? "unregistered");
} }
} }
@@ -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();
}
+21
View File
@@ -0,0 +1,21 @@
namespace EchoHub.Server.Config;
/// <summary>
/// Periodic server-stats report settings, bound from the "Stats" config section (env override:
/// <c>Stats__Enabled</c> etc.). When enabled, a background job periodically snapshots server
/// activity, logs it as pretty-printed JSON, and persists it to the database.
/// </summary>
public sealed class StatsOptions
{
/// <summary>Master switch for the periodic stats report job.</summary>
public bool Enabled { get; set; } = true;
/// <summary>How often a report is generated, in hours. Default: every 6 hours.</summary>
public double IntervalHours { get; set; } = 6;
/// <summary>
/// How long persisted reports are kept before being pruned, in days. Set to 0 to keep
/// reports indefinitely. Default: 90 days.
/// </summary>
public int RetentionDays { get; set; } = 90;
}
@@ -29,6 +29,7 @@ public class ChannelsController : ControllerBase
private readonly IChatService _chatService; private readonly IChatService _chatService;
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
private readonly UploadLimits _uploadLimits; private readonly UploadLimits _uploadLimits;
private readonly ILogger<ChannelsController> _logger;
public ChannelsController( public ChannelsController(
IChannelService channelService, IChannelService channelService,
@@ -38,7 +39,8 @@ public class ChannelsController : ControllerBase
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IChatService chatService, IChatService chatService,
IMessageEncryptionService encryption, IMessageEncryptionService encryption,
UploadLimits uploadLimits) UploadLimits uploadLimits,
ILogger<ChannelsController> logger)
{ {
_channelService = channelService; _channelService = channelService;
_db = db; _db = db;
@@ -48,6 +50,7 @@ public class ChannelsController : ControllerBase
_chatService = chatService; _chatService = chatService;
_encryption = encryption; _encryption = encryption;
_uploadLimits = uploadLimits; _uploadLimits = uploadLimits;
_logger = logger;
} }
[HttpGet] [HttpGet]
@@ -201,6 +204,9 @@ public class ChannelsController : ControllerBase
if (channelDto is null) if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); 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) if (!Request.HasFormContentType)
return BadRequest(new ErrorResponse("Expected multipart form data.")); return BadRequest(new ErrorResponse("Expected multipart form data."));
@@ -286,6 +292,12 @@ public class ChannelsController : ControllerBase
}); });
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length, attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
_encryption.EncryptNullable(previewPlain))); _encryption.EncryptNullable(previewPlain)));
// Filename is client-encrypted ciphertext in E2E rooms — never log it there.
var loggedName = channelDto.IsEncrypted ? "[encrypted]" : file.FileName;
_logger.LogInformation(
"{User} uploaded {Kind} '{FileName}' ({Size} bytes) to '{Channel}': {Url}",
usernameClaim, kind, loggedName, file.Length, channelName, url);
} }
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content; var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
@@ -344,6 +356,9 @@ public class ChannelsController : ControllerBase
if (channelDto is null) if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); 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) if (channelDto.IsEncrypted)
return BadRequest(new ErrorResponse( 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.")); "Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead."));
@@ -440,6 +455,10 @@ public class ChannelsController : ControllerBase
_db.Messages.Add(message); _db.Messages.Add(message);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_logger.LogInformation(
"{User} uploaded Image '{FileName}' ({Size} bytes) from URL to '{Channel}': {Url} (source: {Source})",
usernameClaim, fileName, imageBytes.Length, channelName, attachmentUrl, request.Url);
var messageDto = new MessageDto( var messageDto = new MessageDto(
message.Id, message.Id,
_encryption.Encrypt(string.Empty), _encryption.Encrypt(string.Empty),
@@ -4,6 +4,7 @@ using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using EchoHub.Server.Services.Stats;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -22,19 +23,25 @@ public class ModerationController : ControllerBase
private readonly PresenceTracker _presenceTracker; private readonly PresenceTracker _presenceTracker;
private readonly FileStorageService _fileStorage; private readonly FileStorageService _fileStorage;
private readonly IEnumerable<IChatBroadcaster> _broadcasters; private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly ServerStatsCollector _statsCollector;
private readonly ILogger<ModerationController> _logger;
public ModerationController( public ModerationController(
EchoHubDbContext db, EchoHubDbContext db,
IChatService chatService, IChatService chatService,
PresenceTracker presenceTracker, PresenceTracker presenceTracker,
FileStorageService fileStorage, FileStorageService fileStorage,
IEnumerable<IChatBroadcaster> broadcasters) IEnumerable<IChatBroadcaster> broadcasters,
ServerStatsCollector statsCollector,
ILogger<ModerationController> logger)
{ {
_db = db; _db = db;
_chatService = chatService; _chatService = chatService;
_presenceTracker = presenceTracker; _presenceTracker = presenceTracker;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_broadcasters = broadcasters; _broadcasters = broadcasters;
_statsCollector = statsCollector;
_logger = logger;
} }
[HttpPost("role")] [HttpPost("role")]
@@ -56,9 +63,14 @@ public class ModerationController : ControllerBase
if (request.Role >= caller!.Role) if (request.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own.")); return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
var previousRole = target.Role;
target.Role = request.Role; target.Role = request.Role;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_logger.LogInformation(
"Role change: {Actor} set {Target} from {OldRole} to {NewRole}",
caller!.Username, target.Username, previousRole, request.Role);
return Ok(new { Message = $"{target.Username} is now {request.Role}." }); return Ok(new { Message = $"{target.Username} is now {request.Role}." });
} }
@@ -86,6 +98,11 @@ public class ModerationController : ControllerBase
var reason = request?.Reason ?? "You have been kicked from the server."; var reason = request?.Reason ?? "You have been kicked from the server.";
await ForceDisconnectAndCleanupAsync(target.Username, reason); await ForceDisconnectAndCleanupAsync(target.Username, reason);
_statsCollector.RecordKick();
_logger.LogInformation(
"Kick: {Actor} kicked {Target} (reason: {Reason})",
caller!.Username, target.Username, request?.Reason ?? "none");
return Ok(new { Message = $"{target.Username} has been kicked." }); return Ok(new { Message = $"{target.Username} has been kicked." });
} }
@@ -111,13 +128,18 @@ public class ModerationController : ControllerBase
var reason = request?.Reason ?? "You have been banned from this server."; var reason = request?.Reason ?? "You have been banned from this server.";
await ForceDisconnectAndCleanupAsync(target.Username, reason); await ForceDisconnectAndCleanupAsync(target.Username, reason);
_statsCollector.RecordBan();
_logger.LogWarning(
"Ban: {Actor} banned {Target} (reason: {Reason})",
caller!.Username, target.Username, request?.Reason ?? "none");
return Ok(new { Message = $"{target.Username} has been banned." }); return Ok(new { Message = $"{target.Username} has been banned." });
} }
[HttpPost("unban/{username}")] [HttpPost("unban/{username}")]
public async Task<IActionResult> UnbanUser(string username) public async Task<IActionResult> UnbanUser(string username)
{ {
var (_, error) = await GetCallerAsync(ServerRole.Admin); var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error; if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant()); var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
@@ -127,6 +149,8 @@ public class ModerationController : ControllerBase
target.IsBanned = false; target.IsBanned = false;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_logger.LogInformation("Unban: {Actor} unbanned {Target}", caller!.Username, target.Username);
return Ok(new { Message = $"{target.Username} has been unbanned." }); return Ok(new { Message = $"{target.Username} has been unbanned." });
} }
@@ -149,6 +173,12 @@ public class ModerationController : ControllerBase
: null; : null;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_logger.LogInformation(
"Mute: {Actor} muted {Target} ({Duration}, reason: {Reason})",
caller!.Username, target.Username,
request?.DurationMinutes is > 0 ? $"{request.DurationMinutes}m" : "indefinite",
request?.Reason ?? "none");
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : ""; var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
return Ok(new { Message = $"{target.Username} has been muted{durationText}." }); return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
} }
@@ -156,7 +186,7 @@ public class ModerationController : ControllerBase
[HttpPost("unmute/{username}")] [HttpPost("unmute/{username}")]
public async Task<IActionResult> UnmuteUser(string username) public async Task<IActionResult> UnmuteUser(string username)
{ {
var (_, error) = await GetCallerAsync(ServerRole.Mod); var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error; if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant()); var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
@@ -167,6 +197,8 @@ public class ModerationController : ControllerBase
target.MutedUntil = null; target.MutedUntil = null;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_logger.LogInformation("Unmute: {Actor} unmuted {Target}", caller!.Username, target.Username);
return Ok(new { Message = $"{target.Username} has been unmuted." }); return Ok(new { Message = $"{target.Username} has been unmuted." });
} }
@@ -219,13 +251,19 @@ public class ModerationController : ControllerBase
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId)); await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
// Only moderator removals of another user's message are noteworthy; self-deletes are routine.
if (!isOwnMessage)
_logger.LogInformation(
"Message removed: {Actor} deleted {Author}'s message {MessageId} in '{Channel}'",
caller.Username, message.SenderUsername, messageId, channelName);
return Ok(new { Message = "Message deleted." }); return Ok(new { Message = "Message deleted." });
} }
[HttpDelete("channels/{channel}/nuke")] [HttpDelete("channels/{channel}/nuke")]
public async Task<IActionResult> NukeChannel(string channel) public async Task<IActionResult> NukeChannel(string channel)
{ {
var (_, error) = await GetCallerAsync(ServerRole.Mod); var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error; if (error is not null) return error;
var channelName = channel.ToLowerInvariant().Trim(); var channelName = channel.ToLowerInvariant().Trim();
@@ -251,6 +289,10 @@ public class ModerationController : ControllerBase
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName)); await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
_logger.LogWarning(
"Channel nuked: {Actor} cleared {Count} messages from '{Channel}'",
caller!.Username, messages.Count, channelName);
return Ok(new { Message = $"All messages in #{channelName} have been cleared." }); return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
} }
@@ -14,6 +14,7 @@ public class EchoHubDbContext : DbContext
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>(); public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
public DbSet<InviteCode> InviteCodes => Set<InviteCode>(); public DbSet<InviteCode> InviteCodes => Set<InviteCode>();
public DbSet<ServerStatsReport> ServerStatsReports => Set<ServerStatsReport>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -107,6 +108,12 @@ public class EchoHubDbContext : DbContext
entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50); entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50);
}); });
modelBuilder.Entity<ServerStatsReport>(entity =>
{
entity.HasKey(r => r.Id);
entity.HasIndex(r => r.GeneratedAt);
});
modelBuilder.Entity<RefreshToken>(entity => modelBuilder.Entity<RefreshToken>(entity =>
{ {
entity.HasKey(r => r.Id); entity.HasKey(r => r.Id);
@@ -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");
}
}
}
@@ -0,0 +1,437 @@
// <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("20260717200332_AddServerStatsReports")]
partial class AddServerStatsReports
{
/// <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.ServerStatsReport", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ActiveMembers")
.HasColumnType("INTEGER");
b.Property<int>("Bans")
.HasColumnType("INTEGER");
b.Property<long>("BytesUploaded")
.HasColumnType("INTEGER");
b.Property<int>("Connections")
.HasColumnType("INTEGER");
b.Property<int>("Disconnections")
.HasColumnType("INTEGER");
b.Property<int>("FilesUploaded")
.HasColumnType("INTEGER");
b.Property<long>("GeneratedAt")
.HasColumnType("INTEGER");
b.Property<int>("Kicks")
.HasColumnType("INTEGER");
b.Property<int>("MessagesSent")
.HasColumnType("INTEGER");
b.Property<int>("NewMembers")
.HasColumnType("INTEGER");
b.Property<int>("OnlineNow")
.HasColumnType("INTEGER");
b.Property<int>("PeakOnline")
.HasColumnType("INTEGER");
b.Property<long>("PeriodEnd")
.HasColumnType("INTEGER");
b.Property<long>("PeriodStart")
.HasColumnType("INTEGER");
b.Property<int>("TotalMembers")
.HasColumnType("INTEGER");
b.Property<double>("WindowHours")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("GeneratedAt");
b.ToTable("ServerStatsReports");
});
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,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddServerStatsReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ServerStatsReports",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
GeneratedAt = table.Column<long>(type: "INTEGER", nullable: false),
PeriodStart = table.Column<long>(type: "INTEGER", nullable: false),
PeriodEnd = table.Column<long>(type: "INTEGER", nullable: false),
WindowHours = table.Column<double>(type: "REAL", nullable: false),
MessagesSent = table.Column<int>(type: "INTEGER", nullable: false),
FilesUploaded = table.Column<int>(type: "INTEGER", nullable: false),
BytesUploaded = table.Column<long>(type: "INTEGER", nullable: false),
NewMembers = table.Column<int>(type: "INTEGER", nullable: false),
ActiveMembers = table.Column<int>(type: "INTEGER", nullable: false),
Connections = table.Column<int>(type: "INTEGER", nullable: false),
Disconnections = table.Column<int>(type: "INTEGER", nullable: false),
Kicks = table.Column<int>(type: "INTEGER", nullable: false),
Bans = table.Column<int>(type: "INTEGER", nullable: false),
TotalMembers = table.Column<int>(type: "INTEGER", nullable: false),
OnlineNow = table.Column<int>(type: "INTEGER", nullable: false),
PeakOnline = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerStatsReports", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ServerStatsReports_GeneratedAt",
table: "ServerStatsReports",
column: "GeneratedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ServerStatsReports");
}
}
}
@@ -72,6 +72,9 @@ namespace EchoHub.Server.Data.Migrations
b.Property<bool>("IsPublic") b.Property<bool>("IsPublic")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -243,6 +246,67 @@ namespace EchoHub.Server.Data.Migrations
b.ToTable("RefreshTokens"); b.ToTable("RefreshTokens");
}); });
modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ActiveMembers")
.HasColumnType("INTEGER");
b.Property<int>("Bans")
.HasColumnType("INTEGER");
b.Property<long>("BytesUploaded")
.HasColumnType("INTEGER");
b.Property<int>("Connections")
.HasColumnType("INTEGER");
b.Property<int>("Disconnections")
.HasColumnType("INTEGER");
b.Property<int>("FilesUploaded")
.HasColumnType("INTEGER");
b.Property<long>("GeneratedAt")
.HasColumnType("INTEGER");
b.Property<int>("Kicks")
.HasColumnType("INTEGER");
b.Property<int>("MessagesSent")
.HasColumnType("INTEGER");
b.Property<int>("NewMembers")
.HasColumnType("INTEGER");
b.Property<int>("OnlineNow")
.HasColumnType("INTEGER");
b.Property<int>("PeakOnline")
.HasColumnType("INTEGER");
b.Property<long>("PeriodEnd")
.HasColumnType("INTEGER");
b.Property<long>("PeriodStart")
.HasColumnType("INTEGER");
b.Property<int>("TotalMembers")
.HasColumnType("INTEGER");
b.Property<double>("WindowHours")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("GeneratedAt");
b.ToTable("ServerStatsReports");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b => modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
+28 -1
View File
@@ -10,6 +10,8 @@ using EchoHub.Server.Data;
using EchoHub.Server.Hubs; using EchoHub.Server.Hubs;
using EchoHub.Server.Irc; using EchoHub.Server.Irc;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using EchoHub.Server.Services.ServerLogs;
using EchoHub.Server.Services.Stats;
using EchoHub.Server.Setup; using EchoHub.Server.Setup;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -41,9 +43,23 @@ while (true)
builder.Services.Configure<HostOptions>(options => builder.Services.Configure<HostOptions>(options =>
options.ShutdownTimeout = TimeSpan.FromSeconds(5)); 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 ────────────────────────────────────────────────────────── // ── Serilog ──────────────────────────────────────────────────────────
builder.Host.UseSerilog((context, config) => 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 ───────────────────────────────────────────────── // ── SQLite + EF Core ─────────────────────────────────────────────────
var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db"); var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
@@ -127,6 +143,17 @@ while (true)
builder.Services.AddHostedService<FileCleanupService>(); builder.Services.AddHostedService<FileCleanupService>();
builder.Services.AddHostedService<MuteExpirationService>(); builder.Services.AddHostedService<MuteExpirationService>();
// ── Periodic stats report (admin-configurable via the "Stats" section) ─
var statsOptions = builder.Configuration.GetSection("Stats").Get<StatsOptions>() ?? new StatsOptions();
builder.Services.AddSingleton(statsOptions);
builder.Services.AddSingleton<ServerStatsCollector>();
if (statsOptions.Enabled)
builder.Services.AddHostedService<ServerStatsReportService>();
// Live server-log streaming (only when the sink is active)
if (serverLogsSink is not null)
builder.Services.AddHostedService<ServerLogsStreamService>();
// ── Encryption ───────────────────────────────────────────────────── // ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>(); builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
+98 -5
View File
@@ -3,6 +3,7 @@ using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Services.ServerLogs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -14,17 +15,20 @@ public class ChannelService : IChannelService
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presenceTracker; private readonly PresenceTracker _presenceTracker;
private readonly SpamGuard _spamGuard; private readonly SpamGuard _spamGuard;
private readonly ServerLogsService _serverLogs;
private readonly ILogger<ChannelService> _logger; private readonly ILogger<ChannelService> _logger;
public ChannelService( public ChannelService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker, PresenceTracker presenceTracker,
SpamGuard spamGuard, SpamGuard spamGuard,
ServerLogsService serverLogs,
ILogger<ChannelService> logger) ILogger<ChannelService> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_presenceTracker = presenceTracker; _presenceTracker = presenceTracker;
_spamGuard = spamGuard; _spamGuard = spamGuard;
_serverLogs = serverLogs;
_logger = logger; _logger = logger;
} }
@@ -35,17 +39,24 @@ public class ChannelService : IChannelService
await EnsureDefaultChannelAsync(db); await EnsureDefaultChannelAsync(db);
var query = db.Channels.Where(c => // System channels (the live log room) are visible only to the configured roles,
c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId)); // 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 total = await query.CountAsync();
var channels = await query var channels = await query
.OrderBy(c => c.Name) .OrderByDescending(c => c.IsSystem)
.ThenBy(c => c.Name)
.Skip(offset) .Skip(offset)
.Take(limit) .Take(limit)
.Select(c => new ChannelDto( .Select(c => new ChannelDto(
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt, 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(); .ToListAsync();
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit); return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
@@ -64,6 +75,12 @@ public class ChannelService : IChannelService
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."); "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); var passwordError = ValidateChannelPassword(ref password);
if (passwordError is not null) if (passwordError is not null)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError); return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
@@ -166,6 +183,10 @@ public class ChannelService : IChannelService
if (dbChannel is null) if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); 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) if (dbChannel.WrappedRoomKey is not null)
return ChannelOperationResult.Fail(ChannelError.Protected, return ChannelOperationResult.Fail(ChannelError.Protected,
"This channel is end-to-end encrypted — change its passphrase from the EchoHub client (/passwd)."); "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) if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); 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); var caller = await db.Users.FindAsync(callerUserId);
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin)) if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
return ChannelOperationResult.Fail(ChannelError.Forbidden, return ChannelOperationResult.Fail(ChannelError.Forbidden,
@@ -298,7 +323,7 @@ public class ChannelService : IChannelService
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id); 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, 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) public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
@@ -372,6 +397,16 @@ public class ChannelService : IChannelService
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); 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); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) if (channel is null)
{ {
@@ -389,11 +424,31 @@ public class ChannelService : IChannelService
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel); _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 else
{ {
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.", false); 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 var hasMembership = await db.ChannelMemberships
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id); .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
@@ -442,6 +497,44 @@ public class ChannelService : IChannelService
return null; 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) private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
{ {
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
+46 -4
View File
@@ -5,6 +5,8 @@ using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Core.Security; using EchoHub.Core.Security;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Services.ServerLogs;
using EchoHub.Server.Services.Stats;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -21,6 +23,8 @@ public class ChatService : IChatService
private readonly IChannelService _channelService; private readonly IChannelService _channelService;
private readonly FileStorageService _fileStorage; private readonly FileStorageService _fileStorage;
private readonly SpamGuard _spamGuard; private readonly SpamGuard _spamGuard;
private readonly ServerLogsService _serverLogs;
private readonly ServerStatsCollector _statsCollector;
private readonly ILogger<ChatService> _logger; private readonly ILogger<ChatService> _logger;
public ChatService( public ChatService(
@@ -32,6 +36,8 @@ public class ChatService : IChatService
IChannelService channelService, IChannelService channelService,
FileStorageService fileStorage, FileStorageService fileStorage,
SpamGuard spamGuard, SpamGuard spamGuard,
ServerLogsService serverLogs,
ServerStatsCollector statsCollector,
ILogger<ChatService> logger) ILogger<ChatService> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
@@ -42,12 +48,15 @@ public class ChatService : IChatService
_channelService = channelService; _channelService = channelService;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_spamGuard = spamGuard; _spamGuard = spamGuard;
_serverLogs = serverLogs;
_statsCollector = statsCollector;
_logger = logger; _logger = logger;
} }
public async Task UserConnectedAsync(string connectionId, Guid userId, string username) public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
{ {
_presenceTracker.UserConnected(connectionId, userId, username); _presenceTracker.UserConnected(connectionId, userId, username);
_statsCollector.RecordConnection(_presenceTracker.GetOnlineUserCount());
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
@@ -60,7 +69,9 @@ public class ChatService : IChatService
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
_logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId); // Debug-level: connect/disconnect churn is high-volume on a busy server. Aggregate
// counts land in the periodic stats report instead.
_logger.LogDebug("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
} }
public async Task<string?> UserDisconnectedAsync(string connectionId) public async Task<string?> UserDisconnectedAsync(string connectionId)
@@ -71,6 +82,7 @@ public class ChatService : IChatService
: []; : [];
var username = _presenceTracker.UserDisconnected(connectionId); var username = _presenceTracker.UserDisconnected(connectionId);
_statsCollector.RecordDisconnection(_presenceTracker.GetOnlineUserCount());
if (username is not null && !_presenceTracker.IsOnline(username)) if (username is not null && !_presenceTracker.IsOnline(username))
{ {
@@ -96,7 +108,7 @@ public class ChatService : IChatService
} }
} }
_logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId); _logger.LogDebug("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
return username; return username;
} }
@@ -161,7 +173,7 @@ public class ChatService : IChatService
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId)); await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
} }
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); _logger.LogDebug("{User} joined channel '{Channel}'", username, channelName);
} }
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount); var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
@@ -173,7 +185,7 @@ public class ChatService : IChatService
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
_presenceTracker.LeaveChannel(username, channelName); _presenceTracker.LeaveChannel(username, channelName);
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username)); await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName); _logger.LogDebug("{User} left channel '{Channel}'", username, channelName);
} }
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null) public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
@@ -183,6 +195,11 @@ public class ChatService : IChatService
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return "Invalid channel name."; 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) // Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both)
var plaintext = _encryption.Decrypt(content); var plaintext = _encryption.Decrypt(content);
@@ -206,6 +223,9 @@ public class ChatService : IChatService
if (channel is null) if (channel is null)
return $"Channel '{channelName}' does not exist."; return $"Channel '{channelName}' does not exist.";
if (channel.IsSystem)
return "This channel is read-only.";
var sender = await db.Users.FindAsync(userId); var sender = await db.Users.FindAsync(userId);
// Check mute status // Check mute status
@@ -309,12 +329,34 @@ public class ChatService : IChatService
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
offset = Math.Max(offset, 0); 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(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await GetChannelHistoryInternalAsync(db, channelName, count, offset); 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> /// <summary>
/// Builds the wire reference for a reply target. Plaintext snippets are truncated /// 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 /// 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(),
};
}
@@ -0,0 +1,68 @@
namespace EchoHub.Server.Services.Stats;
/// <summary>
/// Thread-safe, in-memory accumulator for server-activity counters that have no natural
/// database timestamp to query after the fact — session connects/disconnects, moderation
/// actions, and peak concurrency. The periodic stats-report job snapshots and resets these
/// once per reporting window. Registered as a singleton; every increment is lock-free so it
/// is safe to call from hot paths (connect/disconnect).
/// </summary>
public sealed class ServerStatsCollector
{
private long _connections;
private long _disconnections;
private long _kicks;
private long _bans;
private int _peakOnline;
/// <summary>Record a session coming online, updating the running peak.</summary>
public void RecordConnection(int onlineNow)
{
Interlocked.Increment(ref _connections);
RecordOnline(onlineNow);
}
/// <summary>Record a session going offline, updating the running peak.</summary>
public void RecordDisconnection(int onlineNow)
{
Interlocked.Increment(ref _disconnections);
RecordOnline(onlineNow);
}
/// <summary>Record a kick action.</summary>
public void RecordKick() => Interlocked.Increment(ref _kicks);
/// <summary>Record a ban action.</summary>
public void RecordBan() => Interlocked.Increment(ref _bans);
/// <summary>Update the running maximum of concurrent online users (lock-free).</summary>
public void RecordOnline(int onlineNow)
{
int current;
while (onlineNow > (current = Volatile.Read(ref _peakOnline)))
{
if (Interlocked.CompareExchange(ref _peakOnline, onlineNow, current) == current)
break;
}
}
/// <summary>
/// Atomically read all counters and reset them for the next reporting window. The peak is
/// reset to <paramref name="onlineNow"/> so the next window's peak starts from the current
/// concurrency rather than zero.
/// </summary>
public StatsCounters SnapshotAndReset(int onlineNow) => new(
Connections: Interlocked.Exchange(ref _connections, 0),
Disconnections: Interlocked.Exchange(ref _disconnections, 0),
Kicks: Interlocked.Exchange(ref _kicks, 0),
Bans: Interlocked.Exchange(ref _bans, 0),
PeakOnline: Interlocked.Exchange(ref _peakOnline, onlineNow));
}
/// <summary>Immutable snapshot of the counters held by <see cref="ServerStatsCollector"/>.</summary>
public readonly record struct StatsCounters(
long Connections,
long Disconnections,
long Kicks,
long Bans,
int PeakOnline);
@@ -0,0 +1,151 @@
using System.Text.Json;
using EchoHub.Core.Models;
using EchoHub.Server.Config;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Services.Stats;
/// <summary>
/// Background job that periodically snapshots server activity over a window, logs it as
/// pretty-printed JSON (which also surfaces in the live server-logs room), and persists it to
/// the database for historical trends. Interval and retention are configurable via the "Stats"
/// section; the default cadence is every 6 hours.
/// </summary>
public sealed class ServerStatsReportService : BackgroundService
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presence;
private readonly ServerStatsCollector _collector;
private readonly StatsOptions _options;
private readonly ILogger<ServerStatsReportService> _logger;
// Start of the current reporting window. Advances to PeriodEnd after each report so that
// the DB-derived counts and the in-memory collector counters cover the same span.
private DateTimeOffset _periodStart;
public ServerStatsReportService(
IServiceScopeFactory scopeFactory,
PresenceTracker presence,
ServerStatsCollector collector,
StatsOptions options,
ILogger<ServerStatsReportService> logger)
{
_scopeFactory = scopeFactory;
_presence = presence;
_collector = collector;
_options = options;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled)
return;
// A non-positive interval falls back to the 6h default; anything positive is honoured
// but floored at 1s so a mis-set tiny value can't spin the loop.
var interval = _options.IntervalHours > 0
? TimeSpan.FromHours(_options.IntervalHours)
: TimeSpan.FromHours(6);
if (interval < TimeSpan.FromSeconds(1))
interval = TimeSpan.FromSeconds(1);
_periodStart = DateTimeOffset.UtcNow;
_logger.LogInformation(
"Server stats report job started — reporting every {Hours}h, retention {Days}d",
_options.IntervalHours, _options.RetentionDays);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
try
{
await GenerateReportAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to generate periodic server stats report");
}
}
}
private async Task GenerateReportAsync(CancellationToken ct)
{
var periodStart = _periodStart;
var periodEnd = DateTimeOffset.UtcNow;
var onlineNow = _presence.GetOnlineUserCount();
var counters = _collector.SnapshotAndReset(onlineNow);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var messagesSent = await db.Messages.CountAsync(m => m.SentAt >= periodStart, ct);
var activeMembers = await db.Messages
.Where(m => m.SentAt >= periodStart)
.Select(m => m.SenderUserId)
.Distinct()
.CountAsync(ct);
var uploadQuery = db.Attachments
.Where(a => db.Messages.Any(m => m.Id == a.MessageId && m.SentAt >= periodStart));
var filesUploaded = await uploadQuery.CountAsync(ct);
var bytesUploaded = filesUploaded == 0 ? 0L : await uploadQuery.SumAsync(a => a.FileSize, ct);
var newMembers = await db.Users.CountAsync(u => u.CreatedAt >= periodStart, ct);
var totalMembers = await db.Users.CountAsync(ct);
var report = new ServerStatsReport
{
Id = Guid.NewGuid(),
GeneratedAt = periodEnd,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
WindowHours = Math.Round((periodEnd - periodStart).TotalHours, 2),
MessagesSent = messagesSent,
FilesUploaded = filesUploaded,
BytesUploaded = bytesUploaded,
NewMembers = newMembers,
ActiveMembers = activeMembers,
Connections = (int)counters.Connections,
Disconnections = (int)counters.Disconnections,
Kicks = (int)counters.Kicks,
Bans = (int)counters.Bans,
TotalMembers = totalMembers,
OnlineNow = onlineNow,
PeakOnline = counters.PeakOnline,
};
db.ServerStatsReports.Add(report);
// Prune reports beyond the retention window (0 = keep forever).
if (_options.RetentionDays > 0)
{
var cutoff = periodEnd.AddDays(-_options.RetentionDays);
var stale = await db.ServerStatsReports
.Where(r => r.GeneratedAt < cutoff)
.ToListAsync(ct);
if (stale.Count > 0)
db.ServerStatsReports.RemoveRange(stale);
}
await db.SaveChangesAsync(ct);
_periodStart = periodEnd;
// Pretty-printed JSON so the report is readable both in the log files and the logs room.
var json = JsonSerializer.Serialize(report, JsonOptions);
_logger.LogInformation("Server stats report ({WindowHours}h window):\n{Report}", report.WindowHours, json);
}
}
@@ -47,6 +47,20 @@
"Key": "", "Key": "",
"EncryptDatabase": false "EncryptDatabase": false
}, },
"ServerLogs": {
"Enabled": true,
"RoomName": "server-logs",
"MinRole": "Mod",
"MinLevel": "Information",
"BacklogLines": 100,
"LogDirectory": "logs",
"LogFilePattern": "echohub-server-*.log"
},
"Stats": {
"Enabled": true,
"IntervalHours": 6,
"RetentionDays": 90
},
"Irc": { "Irc": {
"Enabled": false, "Enabled": false,
"Port": 6667, "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) => public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) =>
Task.FromResult(MembershipResult); 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> /// <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);
}
}