feat: add server logs functionality with role-based access and system channel management

- Introduced a new migration to add the IsSystem column to the Channels table.
- Updated the DbContext model snapshot to reflect the new IsSystem property.
- Enhanced the ChannelService to manage system channels, including creation, visibility control, and protection against deletion.
- Implemented ServerLogsService to handle live server logging, including reading from log files and managing access based on user roles.
- Created ServerLogsSink to queue log events for streaming to the live log room.
- Developed ServerLogsStreamService to stream log events to clients in real-time.
- Added configuration options for server logs in appsettings.
- Implemented comprehensive unit tests for channel service system channel behavior and server logs functionality.
This commit is contained in:
HueByte
2026-07-17 20:44:47 +02:00
parent df1bd0119c
commit 38eca99fb1
22 changed files with 1569 additions and 17 deletions
@@ -0,0 +1,43 @@
using EchoHub.Core.Models;
using Serilog.Events;
namespace EchoHub.Server.Config;
/// <summary>
/// Live server-log room settings, bound from the "ServerLogs" config section (env override:
/// <c>ServerLogs__Enabled</c> etc.). When enabled, a read-only system channel is auto-created
/// and log events are streamed to it live — log lines are never stored as messages in the
/// database; the rolling Serilog log files remain the only persistence.
/// </summary>
public sealed class ServerLogsOptions
{
/// <summary>Master switch for the live log room.</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Name of the auto-created system channel. Must satisfy the normal channel-name rules;
/// the name is reserved — users cannot create a channel with it.
/// </summary>
public string RoomName { get; set; } = "server-logs";
/// <summary>Minimum server role allowed to see and join the log room (Member/Mod/Admin/Owner).</summary>
public ServerRole MinRole { get; set; } = ServerRole.Mod;
/// <summary>
/// Minimum level of log events streamed to the room (Verbose/Debug/Information/Warning/
/// Error/Fatal). Only affects the room — file and console sinks keep their own levels.
/// </summary>
public LogEventLevel MinLevel { get; set; } = LogEventLevel.Information;
/// <summary>How many recent log entries are replayed from the log file when someone opens the room.</summary>
public int BacklogLines { get; set; } = 100;
/// <summary>Directory holding the rolling log files. Must match the Serilog file sink's path.</summary>
public string LogDirectory { get; set; } = "logs";
/// <summary>Filename glob for the rolling log files inside <see cref="LogDirectory"/>.</summary>
public string LogFilePattern { get; set; } = "echohub-server-*.log";
/// <summary>Channel names are stored lowercased; compare against this form.</summary>
public string NormalizedRoomName => RoomName.ToLowerInvariant().Trim();
}
@@ -201,6 +201,9 @@ public class ChannelsController : ControllerBase
if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (channelDto.IsSystem)
return StatusCode(403, new ErrorResponse("This channel is read-only."));
if (!Request.HasFormContentType)
return BadRequest(new ErrorResponse("Expected multipart form data."));
@@ -344,6 +347,9 @@ public class ChannelsController : ControllerBase
if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (channelDto.IsSystem)
return StatusCode(403, new ErrorResponse("This channel is read-only."));
if (channelDto.IsEncrypted)
return BadRequest(new ErrorResponse(
"Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead."));
@@ -0,0 +1,376 @@
// <auto-generated />
using System;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
[DbContext(typeof(EchoHubDbContext))]
[Migration("20260717182450_AddChannelIsSystem")]
partial class AddChannelIsSystem
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AsciiPreview")
.HasMaxLength(64000)
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<Guid>("MessageId")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MessageId");
b.ToTable("Attachments");
});
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<string>("EncryptionSalt")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("WrappedRoomKey")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("JoinedAt")
.HasColumnType("INTEGER");
b.HasKey("UserId", "ChannelId");
b.HasIndex("ChannelId");
b.HasIndex("UserId");
b.ToTable("ChannelMemberships");
});
modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<string>("CreatedByUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<int>("MaxUses")
.HasColumnType("INTEGER");
b.Property<int>("UseCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("InviteCodes");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid?>("ReplyToMessageId")
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
{
b.HasOne("EchoHub.Core.Models.Message", "Message")
.WithMany("Attachments")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Message");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", null)
.WithMany()
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EchoHub.Core.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
.WithMany("Messages")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Channel");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.HasOne("EchoHub.Core.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Navigation("Attachments");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddChannelIsSystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsSystem",
table: "Channels",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsSystem",
table: "Channels");
}
}
}
@@ -72,6 +72,9 @@ namespace EchoHub.Server.Data.Migrations
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
+20 -1
View File
@@ -10,6 +10,7 @@ using EchoHub.Server.Data;
using EchoHub.Server.Hubs;
using EchoHub.Server.Irc;
using EchoHub.Server.Services;
using EchoHub.Server.Services.ServerLogs;
using EchoHub.Server.Setup;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
@@ -41,9 +42,23 @@ while (true)
builder.Services.Configure<HostOptions>(options =>
options.ShutdownTimeout = TimeSpan.FromSeconds(5));
// ── Server log room (admin-configurable via the "ServerLogs" section) ─
// Bound before Serilog so the live-log sink can be wired into the pipeline. The sink
// is a singleton shared between Serilog (producer) and the stream service (consumer).
var serverLogsOptions = builder.Configuration.GetSection("ServerLogs").Get<ServerLogsOptions>() ?? new ServerLogsOptions();
builder.Services.AddSingleton(serverLogsOptions);
builder.Services.AddSingleton<ServerLogsService>();
var serverLogsSink = serverLogsOptions.Enabled ? new ServerLogsSink(serverLogsOptions) : null;
if (serverLogsSink is not null)
builder.Services.AddSingleton(serverLogsSink);
// ── Serilog ──────────────────────────────────────────────────────────
builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration));
{
config.ReadFrom.Configuration(context.Configuration);
if (serverLogsSink is not null)
config.WriteTo.Sink(serverLogsSink);
});
// ── SQLite + EF Core ─────────────────────────────────────────────────
var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
@@ -127,6 +142,10 @@ while (true)
builder.Services.AddHostedService<FileCleanupService>();
builder.Services.AddHostedService<MuteExpirationService>();
// Live server-log streaming (only when the sink is active)
if (serverLogsSink is not null)
builder.Services.AddHostedService<ServerLogsStreamService>();
// ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
+98 -5
View File
@@ -3,6 +3,7 @@ using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services.ServerLogs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -14,17 +15,20 @@ public class ChannelService : IChannelService
private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presenceTracker;
private readonly SpamGuard _spamGuard;
private readonly ServerLogsService _serverLogs;
private readonly ILogger<ChannelService> _logger;
public ChannelService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
SpamGuard spamGuard,
ServerLogsService serverLogs,
ILogger<ChannelService> logger)
{
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_spamGuard = spamGuard;
_serverLogs = serverLogs;
_logger = logger;
}
@@ -35,17 +39,24 @@ public class ChannelService : IChannelService
await EnsureDefaultChannelAsync(db);
var query = db.Channels.Where(c =>
c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
// System channels (the live log room) are visible only to the configured roles,
// regardless of membership; the room sorts above everything else.
var caller = await db.Users.FindAsync(userId);
var canViewSystem = _serverLogs.CanView(caller?.Role ?? ServerRole.Member);
var query = db.Channels.Where(c => c.IsSystem
? canViewSystem
: c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
var total = await query.CountAsync();
var channels = await query
.OrderBy(c => c.Name)
.OrderByDescending(c => c.IsSystem)
.ThenBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt,
c.PasswordHash != null, c.WrappedRoomKey != null))
c.PasswordHash != null, c.WrappedRoomKey != null, c.IsSystem))
.ToListAsync();
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
@@ -64,6 +75,12 @@ public class ChannelService : IChannelService
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
// The log room's name is reserved even while the feature is disabled, so enabling it
// later never turns a user-owned channel into the stream target.
if (channelName == _serverLogs.Options.NormalizedRoomName)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
$"Channel name '{channelName}' is reserved.");
var passwordError = ValidateChannelPassword(ref password);
if (passwordError is not null)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
@@ -166,6 +183,10 @@ public class ChannelService : IChannelService
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
if (dbChannel.IsSystem)
return ChannelOperationResult.Fail(ChannelError.Protected,
"System channels cannot be password protected.");
if (dbChannel.WrappedRoomKey is not null)
return ChannelOperationResult.Fail(ChannelError.Protected,
"This channel is end-to-end encrypted — change its passphrase from the EchoHub client (/passwd).");
@@ -247,6 +268,10 @@ public class ChannelService : IChannelService
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
if (dbChannel.IsSystem)
return ChannelOperationResult.Fail(ChannelError.Protected,
"System channels cannot be deleted.");
var caller = await db.Users.FindAsync(callerUserId);
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
return ChannelOperationResult.Fail(ChannelError.Forbidden,
@@ -298,7 +323,7 @@ public class ChannelService : IChannelService
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt,
c.PasswordHash != null, c.WrappedRoomKey != null);
c.PasswordHash != null, c.WrappedRoomKey != null, c.IsSystem);
}
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
@@ -372,6 +397,16 @@ public class ChannelService : IChannelService
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
// The live log room is role-gated: only the configured roles may join, no matter
// how the join arrives (TUI, REST, IRC).
var isLogsChannel = _serverLogs.IsLogsChannel(channelName);
if (isLogsChannel)
{
var joiner = await db.Users.FindAsync(userId);
if (!_serverLogs.CanView(joiner?.Role ?? ServerRole.Member))
return (false, $"Channel '{channelName}' is restricted to server staff.", false);
}
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
@@ -389,11 +424,31 @@ public class ChannelService : IChannelService
await db.SaveChangesAsync();
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
}
else if (isLogsChannel)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = ServerLogsService.RoomTopic,
IsPublic = false,
IsSystem = true,
CreatedByUserId = Guid.Empty,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
_logger.LogWarning("Log channel '{Channel}' was missing and has been recreated", channelName);
}
else
{
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.", false);
}
}
else if (channel.IsSystem && !isLogsChannel)
{
// A system channel left behind while its feature is disabled stays inaccessible.
return (false, $"Channel '{channelName}' is not available.", false);
}
var hasMembership = await db.ChannelMemberships
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
@@ -442,6 +497,44 @@ public class ChannelService : IChannelService
return null;
}
public async Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = topic,
IsPublic = false,
IsSystem = true,
CreatedByUserId = Guid.Empty,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
_logger.LogInformation("System channel '{Channel}' created", channelName);
}
else if (!channel.IsSystem)
{
// A regular channel squatting on the system name (created while the feature was
// off) is claimed, so server content never streams into a user-owned room.
channel.IsSystem = true;
channel.IsPublic = false;
channel.PasswordHash = null;
await db.SaveChangesAsync();
_logger.LogWarning("Existing channel '{Channel}' was claimed as a system channel", channelName);
}
return new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt,
false, channel.WrappedRoomKey != null, true);
}
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
{
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
@@ -5,6 +5,7 @@ using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
using EchoHub.Server.Data;
using EchoHub.Server.Services.ServerLogs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -21,6 +22,7 @@ public class ChatService : IChatService
private readonly IChannelService _channelService;
private readonly FileStorageService _fileStorage;
private readonly SpamGuard _spamGuard;
private readonly ServerLogsService _serverLogs;
private readonly ILogger<ChatService> _logger;
public ChatService(
@@ -32,6 +34,7 @@ public class ChatService : IChatService
IChannelService channelService,
FileStorageService fileStorage,
SpamGuard spamGuard,
ServerLogsService serverLogs,
ILogger<ChatService> logger)
{
_scopeFactory = scopeFactory;
@@ -42,6 +45,7 @@ public class ChatService : IChatService
_channelService = channelService;
_fileStorage = fileStorage;
_spamGuard = spamGuard;
_serverLogs = serverLogs;
_logger = logger;
}
@@ -183,6 +187,11 @@ public class ChatService : IChatService
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return "Invalid channel name.";
// The live log room is read-only for everyone, every role. Reject by name before any
// work so a streamed log line can never provoke a DB write or another log event.
if (_serverLogs.IsLogsChannel(channelName))
return "This channel is read-only.";
// Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both)
var plaintext = _encryption.Decrypt(content);
@@ -206,6 +215,9 @@ public class ChatService : IChatService
if (channel is null)
return $"Channel '{channelName}' does not exist.";
if (channel.IsSystem)
return "This channel is read-only.";
var sender = await db.Users.FindAsync(userId);
// Check mute status
@@ -309,12 +321,34 @@ public class ChatService : IChatService
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
offset = Math.Max(offset, 0);
// The log room has no DB messages — its backlog is the tail of the rolling log file.
// Only the first page carries the backlog; older pages are empty (files are the archive).
if (_serverLogs.IsLogsChannel(channelName))
return offset > 0 ? [] : BuildLogBacklog(channelName);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
}
/// <summary>
/// Turns the log-file backlog into transport-encrypted <see cref="MessageDto"/>s so clients
/// render past log lines exactly like streamed ones. Never touches the database.
/// </summary>
private List<MessageDto> BuildLogBacklog(string channelName)
{
return _serverLogs.ReadBacklog()
.Select(entry => new MessageDto(
Guid.NewGuid(),
_encryption.Encrypt(entry.Content),
ServerLogsService.SenderName,
null,
channelName,
entry.Timestamp))
.ToList();
}
/// <summary>
/// Builds the wire reference for a reply target. Plaintext snippets are truncated
/// server-side; end-to-end room ciphertext must pass through whole (a truncated blob
@@ -0,0 +1,138 @@
using System.Globalization;
using EchoHub.Core.Models;
using EchoHub.Server.Config;
namespace EchoHub.Server.Services.ServerLogs;
/// <summary>
/// A backlog entry read from the log file: one timestamped log line plus any continuation
/// lines (exception stack traces) that followed it.
/// </summary>
public record LogBacklogEntry(DateTimeOffset Timestamp, string Content);
/// <summary>
/// Shared logic for the live log room: room identity, the role gate, and reading the backlog
/// tail from the current rolling log file. The file stays the only persistence — log lines
/// are never stored as messages.
/// </summary>
public sealed class ServerLogsService
{
/// <summary>Username shown as the sender of streamed log messages.</summary>
public const string SenderName = "server";
public const string RoomTopic = "Live server logs — read-only";
/// <summary>Timestamp prefix of the file sink's output template.</summary>
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";
/// <summary>How far back into the log file the backlog read reaches, at most.</summary>
private const int TailReadBytes = 256 * 1024;
private readonly ServerLogsOptions _options;
public ServerLogsService(ServerLogsOptions options) => _options = options;
public ServerLogsOptions Options => _options;
/// <summary>Whether the given channel is the (enabled) live log room.</summary>
public bool IsLogsChannel(string channelName) =>
_options.Enabled
&& string.Equals(channelName.Trim(), _options.NormalizedRoomName, StringComparison.OrdinalIgnoreCase);
/// <summary>Whether a user with this role may see and join the log room.</summary>
public bool CanView(ServerRole role) => _options.Enabled && role >= _options.MinRole;
/// <summary>
/// Reads the last <see cref="ServerLogsOptions.BacklogLines"/> entries from the newest
/// log file. A line starting with a timestamp begins a new entry; continuation lines
/// (stack traces) stay attached to the entry above them. Best-effort: any I/O problem
/// yields an empty backlog rather than failing the join.
/// </summary>
public IReadOnlyList<LogBacklogEntry> ReadBacklog()
{
try
{
var directory = Path.GetFullPath(_options.LogDirectory);
if (!Directory.Exists(directory))
return [];
var newest = new DirectoryInfo(directory)
.GetFiles(_options.LogFilePattern)
.OrderByDescending(f => f.LastWriteTimeUtc)
.FirstOrDefault();
if (newest is null)
return [];
// Shared read: Serilog keeps the file open for writing (and rolls it daily).
using var stream = new FileStream(newest.FullName, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
var seeked = stream.Length > TailReadBytes;
if (seeked)
stream.Seek(-TailReadBytes, SeekOrigin.End);
using var reader = new StreamReader(stream);
var lines = reader.ReadToEnd().Split('\n');
return GroupIntoEntries(lines, skipLeadingContinuations: seeked, _options.BacklogLines);
}
catch
{
return [];
}
}
/// <summary>
/// Groups raw file lines into entries by their timestamp prefix. Public for tests.
/// </summary>
public static IReadOnlyList<LogBacklogEntry> GroupIntoEntries(
IReadOnlyList<string> lines, bool skipLeadingContinuations, int maxEntries)
{
var entries = new List<LogBacklogEntry>();
LogBacklogEntry? current = null;
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd('\r');
if (line.Length == 0)
continue;
if (TryParseTimestamp(line, out var timestamp, out var rest))
{
if (current is not null)
entries.Add(current);
current = new LogBacklogEntry(timestamp, rest);
}
else if (current is not null)
{
current = current with { Content = current.Content + "\n" + line };
}
else if (!skipLeadingContinuations)
{
// File starts mid-entry only when we seeked into it; otherwise keep the line.
current = new LogBacklogEntry(DateTimeOffset.UtcNow, line);
}
}
if (current is not null)
entries.Add(current);
if (entries.Count > maxEntries)
entries.RemoveRange(0, entries.Count - maxEntries);
return entries;
}
private static bool TryParseTimestamp(string line, out DateTimeOffset timestamp, out string rest)
{
timestamp = default;
rest = string.Empty;
if (line.Length <= TimestampFormat.Length
|| !DateTime.TryParseExact(line[..TimestampFormat.Length], TimestampFormat,
CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
return false;
timestamp = new DateTimeOffset(parsed);
rest = line[TimestampFormat.Length..].TrimStart();
return true;
}
}
@@ -0,0 +1,51 @@
using System.Threading.Channels;
using EchoHub.Server.Config;
using Serilog.Core;
using Serilog.Events;
namespace EchoHub.Server.Services.ServerLogs;
/// <summary>
/// Serilog sink feeding the live log room. Events are queued in a bounded drop-oldest buffer
/// and consumed by <see cref="ServerLogsStreamService"/>; nothing is written to the database.
/// Events emitted by the streaming pipeline itself (the stream service and SignalR transport
/// internals) are dropped here so a broadcast that logs — e.g. a transport warning on a dead
/// connection — can never enter a log → broadcast → log feedback loop.
/// </summary>
public sealed class ServerLogsSink : ILogEventSink
{
private const int QueueCapacity = 512;
private static readonly string[] ExcludedSourcePrefixes =
[
"EchoHub.Server.Services.ServerLogs",
"Microsoft.AspNetCore.SignalR",
"Microsoft.AspNetCore.Http.Connections",
];
private readonly Channel<LogEvent> _queue = Channel.CreateBounded<LogEvent>(
new BoundedChannelOptions(QueueCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
private readonly LogEventLevel _minLevel;
public ServerLogsSink(ServerLogsOptions options) => _minLevel = options.MinLevel;
public ChannelReader<LogEvent> Reader => _queue.Reader;
public void Emit(LogEvent logEvent)
{
if (logEvent.Level < _minLevel)
return;
if (logEvent.Properties.TryGetValue(Constants.SourceContextPropertyName, out var sourceProperty)
&& sourceProperty is ScalarValue { Value: string source }
&& ExcludedSourcePrefixes.Any(source.StartsWith))
return;
_queue.Writer.TryWrite(logEvent);
}
}
@@ -0,0 +1,128 @@
using System.Globalization;
using System.Text;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Server.Config;
using EchoHub.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
using Serilog.Events;
namespace EchoHub.Server.Services.ServerLogs;
/// <summary>
/// Streams queued log events to the live log room as ephemeral messages: SignalR only (the
/// IRC gateway never sees them) and no database rows. Ensures the room exists before
/// streaming, recreating it on the fly if it was deleted.
///
/// This class must never log from its streaming path — its namespace is excluded by
/// <see cref="ServerLogsSink"/> as a second line of defense, but the primary rule is simply
/// not to log per event, otherwise every streamed line would spawn another.
/// </summary>
public sealed class ServerLogsStreamService : BackgroundService
{
private static readonly TimeSpan EnsureInterval = TimeSpan.FromSeconds(15);
private readonly ServerLogsSink _sink;
private readonly ServerLogsOptions _options;
private readonly IChannelService _channelService;
private readonly IMessageEncryptionService _encryption;
private readonly IServiceProvider _serviceProvider;
private IHubContext<ChatHub, IEchoHubClient>? _hubContext;
private DateTimeOffset _lastEnsure = DateTimeOffset.MinValue;
// Resolved lazily: the hub context isn't available while hosted services are constructed.
private IHubContext<ChatHub, IEchoHubClient> HubContext
=> _hubContext ??= _serviceProvider.GetRequiredService<IHubContext<ChatHub, IEchoHubClient>>();
public ServerLogsStreamService(
ServerLogsSink sink,
ServerLogsOptions options,
IChannelService channelService,
IMessageEncryptionService encryption,
IServiceProvider serviceProvider)
{
_sink = sink;
_options = options;
_channelService = channelService;
_encryption = encryption;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Make the room exist from server start, not only once the first event arrives.
await TryEnsureRoomAsync();
await foreach (var logEvent in _sink.Reader.ReadAllAsync(stoppingToken))
{
try
{
await TryEnsureRoomAsync();
var roomName = _options.NormalizedRoomName;
var message = new MessageDto(
Guid.NewGuid(),
_encryption.Encrypt(Format(logEvent)),
ServerLogsService.SenderName,
null,
roomName,
logEvent.Timestamp);
await HubContext.Clients.Group(roomName).ReceiveMessage(message);
}
catch when (!stoppingToken.IsCancellationRequested)
{
// Swallow: logging here would re-enter the pipeline.
}
}
}
/// <summary>
/// Recreates the log room if it disappeared, at most once per <see cref="EnsureInterval"/>.
/// </summary>
private async Task TryEnsureRoomAsync()
{
if (DateTimeOffset.UtcNow - _lastEnsure < EnsureInterval)
return;
_lastEnsure = DateTimeOffset.UtcNow;
try
{
await _channelService.EnsureSystemChannelAsync(_options.NormalizedRoomName, ServerLogsService.RoomTopic);
}
catch
{
// Retried on the next interval; events streamed meanwhile just go to no group members.
}
}
/// <summary>Formats an event like the file sink's template, minus the timestamp (clients render their own). Public for tests.</summary>
public static string Format(LogEvent logEvent)
{
var builder = new StringBuilder("[").Append(ShortLevel(logEvent.Level)).Append("] ")
.Append(logEvent.RenderMessage(CultureInfo.InvariantCulture));
if (logEvent.Exception is not null)
builder.Append('\n').Append(logEvent.Exception);
if (builder.Length > HubConstants.MaxMessageLength)
{
builder.Length = HubConstants.MaxMessageLength - 1;
builder.Append('…');
}
return builder.ToString();
}
private static string ShortLevel(LogEventLevel level) => level switch
{
LogEventLevel.Verbose => "VRB",
LogEventLevel.Debug => "DBG",
LogEventLevel.Information => "INF",
LogEventLevel.Warning => "WRN",
LogEventLevel.Error => "ERR",
LogEventLevel.Fatal => "FTL",
_ => level.ToString().ToUpperInvariant(),
};
}
@@ -47,6 +47,15 @@
"Key": "",
"EncryptDatabase": false
},
"ServerLogs": {
"Enabled": true,
"RoomName": "server-logs",
"MinRole": "Mod",
"MinLevel": "Information",
"BacklogLines": 100,
"LogDirectory": "logs",
"LogFilePattern": "echohub-server-*.log"
},
"Irc": {
"Enabled": false,
"Port": 6667,