mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 23:34:10 +02:00
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:
@@ -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."));
|
||||
|
||||
+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")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user