mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c2e8eae87 | ||
|
|
75ff10c5fd | ||
|
|
46afd2a195 | ||
|
|
38eca99fb1 |
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live server-log room settings, bound from the "ServerLogs" config section (env override:
|
||||||
|
/// <c>ServerLogs__Enabled</c> etc.). When enabled, a read-only system channel is auto-created
|
||||||
|
/// and log events are streamed to it live — log lines are never stored as messages in the
|
||||||
|
/// database; the rolling Serilog log files remain the only persistence.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerLogsOptions
|
||||||
|
{
|
||||||
|
/// <summary>Master switch for the live log room.</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Name of the auto-created system channel. Must satisfy the normal channel-name rules;
|
||||||
|
/// the name is reserved — users cannot create a channel with it.
|
||||||
|
/// </summary>
|
||||||
|
public string RoomName { get; set; } = "server-logs";
|
||||||
|
|
||||||
|
/// <summary>Minimum server role allowed to see and join the log room (Member/Mod/Admin/Owner).</summary>
|
||||||
|
public ServerRole MinRole { get; set; } = ServerRole.Mod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum level of log events streamed to the room (Verbose/Debug/Information/Warning/
|
||||||
|
/// Error/Fatal). Only affects the room — file and console sinks keep their own levels.
|
||||||
|
/// </summary>
|
||||||
|
public LogEventLevel MinLevel { get; set; } = LogEventLevel.Information;
|
||||||
|
|
||||||
|
/// <summary>How many recent log entries are replayed from the log file when someone opens the room.</summary>
|
||||||
|
public int BacklogLines { get; set; } = 100;
|
||||||
|
|
||||||
|
/// <summary>Directory holding the rolling log files. Must match the Serilog file sink's path.</summary>
|
||||||
|
public string LogDirectory { get; set; } = "logs";
|
||||||
|
|
||||||
|
/// <summary>Filename glob for the rolling log files inside <see cref="LogDirectory"/>.</summary>
|
||||||
|
public string LogFilePattern { get; set; } = "echohub-server-*.log";
|
||||||
|
|
||||||
|
/// <summary>Channel names are stored lowercased; compare against this form.</summary>
|
||||||
|
public string NormalizedRoomName => RoomName.ToLowerInvariant().Trim();
|
||||||
|
}
|
||||||
@@ -201,6 +201,9 @@ public class ChannelsController : ControllerBase
|
|||||||
if (channelDto is null)
|
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."));
|
||||||
|
|
||||||
@@ -344,6 +347,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."));
|
||||||
|
|||||||
+376
@@ -0,0 +1,376 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260717182450_AddChannelIsSystem")]
|
||||||
|
partial class AddChannelIsSystem
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AsciiPreview")
|
||||||
|
.HasMaxLength(64000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId");
|
||||||
|
|
||||||
|
b.ToTable("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptionSalt")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("WrappedRoomKey")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedByUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxUses")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("UseCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("InviteCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("AttachmentFileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReplyToMessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Message", "Message")
|
||||||
|
.WithMany("Attachments")
|
||||||
|
.HasForeignKey("MessageId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Message");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Attachments");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChannelIsSystem : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsSystem",
|
||||||
|
table: "Channels",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsSystem",
|
||||||
|
table: "Channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.Property<bool>("IsPublic")
|
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)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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.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 +42,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 +142,10 @@ while (true)
|
|||||||
builder.Services.AddHostedService<FileCleanupService>();
|
builder.Services.AddHostedService<FileCleanupService>();
|
||||||
builder.Services.AddHostedService<MuteExpirationService>();
|
builder.Services.AddHostedService<MuteExpirationService>();
|
||||||
|
|
||||||
|
// 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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -21,6 +22,7 @@ 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 ILogger<ChatService> _logger;
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
public ChatService(
|
public ChatService(
|
||||||
@@ -32,6 +34,7 @@ public class ChatService : IChatService
|
|||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
SpamGuard spamGuard,
|
SpamGuard spamGuard,
|
||||||
|
ServerLogsService serverLogs,
|
||||||
ILogger<ChatService> logger)
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
@@ -42,6 +45,7 @@ public class ChatService : IChatService
|
|||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
_spamGuard = spamGuard;
|
_spamGuard = spamGuard;
|
||||||
|
_serverLogs = serverLogs;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +187,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 +215,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 +321,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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,6 +47,15 @@
|
|||||||
"Key": "",
|
"Key": "",
|
||||||
"EncryptDatabase": false
|
"EncryptDatabase": false
|
||||||
},
|
},
|
||||||
|
"ServerLogs": {
|
||||||
|
"Enabled": true,
|
||||||
|
"RoomName": "server-logs",
|
||||||
|
"MinRole": "Mod",
|
||||||
|
"MinLevel": "Information",
|
||||||
|
"BacklogLines": 100,
|
||||||
|
"LogDirectory": "logs",
|
||||||
|
"LogFilePattern": "echohub-server-*.log"
|
||||||
|
},
|
||||||
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 _));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user