diff --git a/.env.example b/.env.example index 825ec91..4c6b8ee 100644 --- a/.env.example +++ b/.env.example @@ -75,3 +75,19 @@ Irc__Enabled=false # ── Logging ────────────────────────────────────────────────────────── # Serilog__MinimumLevel__Default=Information + +# ── Server logs room ───────────────────────────────────────────────── +# Read-only system channel that live-streams Serilog events to Mod+ users. +# ServerLogs__Enabled=true +# ServerLogs__RoomName=server-logs +# ServerLogs__MinRole=Mod +# ServerLogs__MinLevel=Information +# ServerLogs__BacklogLines=100 +# ServerLogs__LogDirectory=logs +# ServerLogs__LogFilePattern=echohub-server-*.log + +# ── Periodic stats report ──────────────────────────────────────────── +# Aggregate activity snapshot logged as JSON and persisted to the DB. +# Stats__Enabled=true +# Stats__IntervalHours=6 +# Stats__RetentionDays=90 diff --git a/src/EchoHub.Core/Models/ServerStatsReport.cs b/src/EchoHub.Core/Models/ServerStatsReport.cs new file mode 100644 index 0000000..7a855af --- /dev/null +++ b/src/EchoHub.Core/Models/ServerStatsReport.cs @@ -0,0 +1,62 @@ +namespace EchoHub.Core.Models; + +/// +/// A snapshot of server activity over one reporting window, produced periodically by the +/// stats-report background job. Each report is logged as pretty-printed JSON and persisted +/// for historical trend analysis. The window is "since the previous report" (or since startup +/// for the first report of a run). +/// +public class ServerStatsReport +{ + public Guid Id { get; set; } + + /// When this report was generated (equals ). + public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow; + + /// Start of the reporting window. + public DateTimeOffset PeriodStart { get; set; } + + /// End of the reporting window. + public DateTimeOffset PeriodEnd { get; set; } + + /// Length of the reporting window in hours. + public double WindowHours { get; set; } + + // ── Activity during the window ────────────────────────────────────────── + /// Messages sent during the window. + public int MessagesSent { get; set; } + + /// Attachments (files/images/audio) uploaded during the window. + public int FilesUploaded { get; set; } + + /// Total bytes across all attachments uploaded during the window. + public long BytesUploaded { get; set; } + + /// Accounts registered during the window ("new members joined"). + public int NewMembers { get; set; } + + /// Distinct users who sent at least one message during the window. + public int ActiveMembers { get; set; } + + /// Session connects during the window (per-connection, across SignalR + IRC). + public int Connections { get; set; } + + /// Session disconnects during the window ("members left" sessions). + public int Disconnections { get; set; } + + /// Users kicked during the window. + public int Kicks { get; set; } + + /// Users banned during the window. + public int Bans { get; set; } + + // ── Point-in-time totals at window end ────────────────────────────────── + /// Total registered (unique) members at window end. + public int TotalMembers { get; set; } + + /// Distinct users online at window end. + public int OnlineNow { get; set; } + + /// Peak distinct users online observed during the window. + public int PeakOnline { get; set; } +} diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index 357125b..f4554b3 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -113,7 +113,7 @@ public sealed class IrcGatewayService : BackgroundService var connection = new IrcClientConnection(tcpClient, stream); _connections[connection.ConnectionId] = connection; - _logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId); + _logger.LogDebug("IRC client connected: {Id}", connection.ConnectionId); IChatService? chatService = null; @@ -148,7 +148,7 @@ public sealed class IrcGatewayService : BackgroundService _connections.TryRemove(connection.ConnectionId, out _); await connection.DisposeAsync(); - _logger.LogInformation("IRC client {Id} ({Nick}) disconnected", + _logger.LogDebug("IRC client {Id} ({Nick}) disconnected", connection.ConnectionId, connection.Nickname ?? "unregistered"); } } diff --git a/src/EchoHub.Server/Config/StatsOptions.cs b/src/EchoHub.Server/Config/StatsOptions.cs new file mode 100644 index 0000000..09914a4 --- /dev/null +++ b/src/EchoHub.Server/Config/StatsOptions.cs @@ -0,0 +1,21 @@ +namespace EchoHub.Server.Config; + +/// +/// Periodic server-stats report settings, bound from the "Stats" config section (env override: +/// Stats__Enabled etc.). When enabled, a background job periodically snapshots server +/// activity, logs it as pretty-printed JSON, and persists it to the database. +/// +public sealed class StatsOptions +{ + /// Master switch for the periodic stats report job. + public bool Enabled { get; set; } = true; + + /// How often a report is generated, in hours. Default: every 6 hours. + public double IntervalHours { get; set; } = 6; + + /// + /// How long persisted reports are kept before being pruned, in days. Set to 0 to keep + /// reports indefinitely. Default: 90 days. + /// + public int RetentionDays { get; set; } = 90; +} diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 0765e10..096f87a 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -29,6 +29,7 @@ public class ChannelsController : ControllerBase private readonly IChatService _chatService; private readonly IMessageEncryptionService _encryption; private readonly UploadLimits _uploadLimits; + private readonly ILogger _logger; public ChannelsController( IChannelService channelService, @@ -38,7 +39,8 @@ public class ChannelsController : ControllerBase IHttpClientFactory httpClientFactory, IChatService chatService, IMessageEncryptionService encryption, - UploadLimits uploadLimits) + UploadLimits uploadLimits, + ILogger logger) { _channelService = channelService; _db = db; @@ -48,6 +50,7 @@ public class ChannelsController : ControllerBase _chatService = chatService; _encryption = encryption; _uploadLimits = uploadLimits; + _logger = logger; } [HttpGet] @@ -289,6 +292,12 @@ public class ChannelsController : ControllerBase }); attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length, _encryption.EncryptNullable(previewPlain))); + + // Filename is client-encrypted ciphertext in E2E rooms — never log it there. + var loggedName = channelDto.IsEncrypted ? "[encrypted]" : file.FileName; + _logger.LogInformation( + "{User} uploaded {Kind} '{FileName}' ({Size} bytes) to '{Channel}': {Url}", + usernameClaim, kind, loggedName, file.Length, channelName, url); } var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content; @@ -446,6 +455,10 @@ public class ChannelsController : ControllerBase _db.Messages.Add(message); await _db.SaveChangesAsync(); + _logger.LogInformation( + "{User} uploaded Image '{FileName}' ({Size} bytes) from URL to '{Channel}': {Url} (source: {Source})", + usernameClaim, fileName, imageBytes.Length, channelName, attachmentUrl, request.Url); + var messageDto = new MessageDto( message.Id, _encryption.Encrypt(string.Empty), diff --git a/src/EchoHub.Server/Controllers/ModerationController.cs b/src/EchoHub.Server/Controllers/ModerationController.cs index d6de0d4..b1c818f 100644 --- a/src/EchoHub.Server/Controllers/ModerationController.cs +++ b/src/EchoHub.Server/Controllers/ModerationController.cs @@ -4,6 +4,7 @@ using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Data; using EchoHub.Server.Services; +using EchoHub.Server.Services.Stats; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -22,19 +23,25 @@ public class ModerationController : ControllerBase private readonly PresenceTracker _presenceTracker; private readonly FileStorageService _fileStorage; private readonly IEnumerable _broadcasters; + private readonly ServerStatsCollector _statsCollector; + private readonly ILogger _logger; public ModerationController( EchoHubDbContext db, IChatService chatService, PresenceTracker presenceTracker, FileStorageService fileStorage, - IEnumerable broadcasters) + IEnumerable broadcasters, + ServerStatsCollector statsCollector, + ILogger logger) { _db = db; _chatService = chatService; _presenceTracker = presenceTracker; _fileStorage = fileStorage; _broadcasters = broadcasters; + _statsCollector = statsCollector; + _logger = logger; } [HttpPost("role")] @@ -56,9 +63,14 @@ public class ModerationController : ControllerBase if (request.Role >= caller!.Role) return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own.")); + var previousRole = target.Role; target.Role = request.Role; await _db.SaveChangesAsync(); + _logger.LogInformation( + "Role change: {Actor} set {Target} from {OldRole} to {NewRole}", + caller!.Username, target.Username, previousRole, request.Role); + return Ok(new { Message = $"{target.Username} is now {request.Role}." }); } @@ -86,6 +98,11 @@ public class ModerationController : ControllerBase var reason = request?.Reason ?? "You have been kicked from the server."; await ForceDisconnectAndCleanupAsync(target.Username, reason); + _statsCollector.RecordKick(); + _logger.LogInformation( + "Kick: {Actor} kicked {Target} (reason: {Reason})", + caller!.Username, target.Username, request?.Reason ?? "none"); + return Ok(new { Message = $"{target.Username} has been kicked." }); } @@ -111,13 +128,18 @@ public class ModerationController : ControllerBase var reason = request?.Reason ?? "You have been banned from this server."; await ForceDisconnectAndCleanupAsync(target.Username, reason); + _statsCollector.RecordBan(); + _logger.LogWarning( + "Ban: {Actor} banned {Target} (reason: {Reason})", + caller!.Username, target.Username, request?.Reason ?? "none"); + return Ok(new { Message = $"{target.Username} has been banned." }); } [HttpPost("unban/{username}")] public async Task UnbanUser(string username) { - var (_, error) = await GetCallerAsync(ServerRole.Admin); + var (caller, error) = await GetCallerAsync(ServerRole.Admin); if (error is not null) return error; var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant()); @@ -127,6 +149,8 @@ public class ModerationController : ControllerBase target.IsBanned = false; await _db.SaveChangesAsync(); + _logger.LogInformation("Unban: {Actor} unbanned {Target}", caller!.Username, target.Username); + return Ok(new { Message = $"{target.Username} has been unbanned." }); } @@ -149,6 +173,12 @@ public class ModerationController : ControllerBase : null; await _db.SaveChangesAsync(); + _logger.LogInformation( + "Mute: {Actor} muted {Target} ({Duration}, reason: {Reason})", + caller!.Username, target.Username, + request?.DurationMinutes is > 0 ? $"{request.DurationMinutes}m" : "indefinite", + request?.Reason ?? "none"); + var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : ""; return Ok(new { Message = $"{target.Username} has been muted{durationText}." }); } @@ -156,7 +186,7 @@ public class ModerationController : ControllerBase [HttpPost("unmute/{username}")] public async Task UnmuteUser(string username) { - var (_, error) = await GetCallerAsync(ServerRole.Mod); + var (caller, error) = await GetCallerAsync(ServerRole.Mod); if (error is not null) return error; var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant()); @@ -167,6 +197,8 @@ public class ModerationController : ControllerBase target.MutedUntil = null; await _db.SaveChangesAsync(); + _logger.LogInformation("Unmute: {Actor} unmuted {Target}", caller!.Username, target.Username); + return Ok(new { Message = $"{target.Username} has been unmuted." }); } @@ -219,13 +251,19 @@ public class ModerationController : ControllerBase await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId)); + // Only moderator removals of another user's message are noteworthy; self-deletes are routine. + if (!isOwnMessage) + _logger.LogInformation( + "Message removed: {Actor} deleted {Author}'s message {MessageId} in '{Channel}'", + caller.Username, message.SenderUsername, messageId, channelName); + return Ok(new { Message = "Message deleted." }); } [HttpDelete("channels/{channel}/nuke")] public async Task NukeChannel(string channel) { - var (_, error) = await GetCallerAsync(ServerRole.Mod); + var (caller, error) = await GetCallerAsync(ServerRole.Mod); if (error is not null) return error; var channelName = channel.ToLowerInvariant().Trim(); @@ -251,6 +289,10 @@ public class ModerationController : ControllerBase await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName)); + _logger.LogWarning( + "Channel nuked: {Actor} cleared {Count} messages from '{Channel}'", + caller!.Username, messages.Count, channelName); + return Ok(new { Message = $"All messages in #{channelName} have been cleared." }); } diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index 0e1cc06..7c8a4a0 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -14,6 +14,7 @@ public class EchoHubDbContext : DbContext public DbSet RefreshTokens => Set(); public DbSet ChannelMemberships => Set(); public DbSet InviteCodes => Set(); + public DbSet ServerStatsReports => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -107,6 +108,12 @@ public class EchoHubDbContext : DbContext entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50); }); + modelBuilder.Entity(entity => + { + entity.HasKey(r => r.Id); + entity.HasIndex(r => r.GeneratedAt); + }); + modelBuilder.Entity(entity => { entity.HasKey(r => r.Id); diff --git a/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.Designer.cs new file mode 100644 index 0000000..c28709e --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.Designer.cs @@ -0,0 +1,437 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260717200332_AddServerStatsReports")] + partial class AddServerStatsReports + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AsciiPreview") + .HasMaxLength(64000) + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("EncryptionSalt") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("IsSystem") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WrappedRoomKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("CreatedByUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("UseCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("InviteCodes"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("ReplyToMessageId") + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveMembers") + .HasColumnType("INTEGER"); + + b.Property("Bans") + .HasColumnType("INTEGER"); + + b.Property("BytesUploaded") + .HasColumnType("INTEGER"); + + b.Property("Connections") + .HasColumnType("INTEGER"); + + b.Property("Disconnections") + .HasColumnType("INTEGER"); + + b.Property("FilesUploaded") + .HasColumnType("INTEGER"); + + b.Property("GeneratedAt") + .HasColumnType("INTEGER"); + + b.Property("Kicks") + .HasColumnType("INTEGER"); + + b.Property("MessagesSent") + .HasColumnType("INTEGER"); + + b.Property("NewMembers") + .HasColumnType("INTEGER"); + + b.Property("OnlineNow") + .HasColumnType("INTEGER"); + + b.Property("PeakOnline") + .HasColumnType("INTEGER"); + + b.Property("PeriodEnd") + .HasColumnType("INTEGER"); + + b.Property("PeriodStart") + .HasColumnType("INTEGER"); + + b.Property("TotalMembers") + .HasColumnType("INTEGER"); + + b.Property("WindowHours") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("GeneratedAt"); + + b.ToTable("ServerStatsReports"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Attachment", b => + { + b.HasOne("EchoHub.Core.Models.Message", "Message") + .WithMany("Attachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.HasOne("EchoHub.Core.Models.Channel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EchoHub.Core.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.cs b/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.cs new file mode 100644 index 0000000..104a5ac --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddServerStatsReports : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ServerStatsReports", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + GeneratedAt = table.Column(type: "INTEGER", nullable: false), + PeriodStart = table.Column(type: "INTEGER", nullable: false), + PeriodEnd = table.Column(type: "INTEGER", nullable: false), + WindowHours = table.Column(type: "REAL", nullable: false), + MessagesSent = table.Column(type: "INTEGER", nullable: false), + FilesUploaded = table.Column(type: "INTEGER", nullable: false), + BytesUploaded = table.Column(type: "INTEGER", nullable: false), + NewMembers = table.Column(type: "INTEGER", nullable: false), + ActiveMembers = table.Column(type: "INTEGER", nullable: false), + Connections = table.Column(type: "INTEGER", nullable: false), + Disconnections = table.Column(type: "INTEGER", nullable: false), + Kicks = table.Column(type: "INTEGER", nullable: false), + Bans = table.Column(type: "INTEGER", nullable: false), + TotalMembers = table.Column(type: "INTEGER", nullable: false), + OnlineNow = table.Column(type: "INTEGER", nullable: false), + PeakOnline = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ServerStatsReports", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ServerStatsReports_GeneratedAt", + table: "ServerStatsReports", + column: "GeneratedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ServerStatsReports"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 8e02d18..5756c79 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -246,6 +246,67 @@ namespace EchoHub.Server.Data.Migrations b.ToTable("RefreshTokens"); }); + modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveMembers") + .HasColumnType("INTEGER"); + + b.Property("Bans") + .HasColumnType("INTEGER"); + + b.Property("BytesUploaded") + .HasColumnType("INTEGER"); + + b.Property("Connections") + .HasColumnType("INTEGER"); + + b.Property("Disconnections") + .HasColumnType("INTEGER"); + + b.Property("FilesUploaded") + .HasColumnType("INTEGER"); + + b.Property("GeneratedAt") + .HasColumnType("INTEGER"); + + b.Property("Kicks") + .HasColumnType("INTEGER"); + + b.Property("MessagesSent") + .HasColumnType("INTEGER"); + + b.Property("NewMembers") + .HasColumnType("INTEGER"); + + b.Property("OnlineNow") + .HasColumnType("INTEGER"); + + b.Property("PeakOnline") + .HasColumnType("INTEGER"); + + b.Property("PeriodEnd") + .HasColumnType("INTEGER"); + + b.Property("PeriodStart") + .HasColumnType("INTEGER"); + + b.Property("TotalMembers") + .HasColumnType("INTEGER"); + + b.Property("WindowHours") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("GeneratedAt"); + + b.ToTable("ServerStatsReports"); + }); + modelBuilder.Entity("EchoHub.Core.Models.User", b => { b.Property("Id") diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 27b9de9..db8b117 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -11,6 +11,7 @@ using EchoHub.Server.Hubs; using EchoHub.Server.Irc; using EchoHub.Server.Services; using EchoHub.Server.Services.ServerLogs; +using EchoHub.Server.Services.Stats; using EchoHub.Server.Setup; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.RateLimiting; @@ -142,6 +143,13 @@ while (true) builder.Services.AddHostedService(); builder.Services.AddHostedService(); + // ── Periodic stats report (admin-configurable via the "Stats" section) ─ + var statsOptions = builder.Configuration.GetSection("Stats").Get() ?? new StatsOptions(); + builder.Services.AddSingleton(statsOptions); + builder.Services.AddSingleton(); + if (statsOptions.Enabled) + builder.Services.AddHostedService(); + // Live server-log streaming (only when the sink is active) if (serverLogsSink is not null) builder.Services.AddHostedService(); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 9d29b47..dd5724e 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -6,6 +6,7 @@ using EchoHub.Core.Models; using EchoHub.Core.Security; using EchoHub.Server.Data; using EchoHub.Server.Services.ServerLogs; +using EchoHub.Server.Services.Stats; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -23,6 +24,7 @@ public class ChatService : IChatService private readonly FileStorageService _fileStorage; private readonly SpamGuard _spamGuard; private readonly ServerLogsService _serverLogs; + private readonly ServerStatsCollector _statsCollector; private readonly ILogger _logger; public ChatService( @@ -35,6 +37,7 @@ public class ChatService : IChatService FileStorageService fileStorage, SpamGuard spamGuard, ServerLogsService serverLogs, + ServerStatsCollector statsCollector, ILogger logger) { _scopeFactory = scopeFactory; @@ -46,12 +49,14 @@ public class ChatService : IChatService _fileStorage = fileStorage; _spamGuard = spamGuard; _serverLogs = serverLogs; + _statsCollector = statsCollector; _logger = logger; } public async Task UserConnectedAsync(string connectionId, Guid userId, string username) { _presenceTracker.UserConnected(connectionId, userId, username); + _statsCollector.RecordConnection(_presenceTracker.GetOnlineUserCount()); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -64,7 +69,9 @@ public class ChatService : IChatService await db.SaveChangesAsync(); } - _logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId); + // Debug-level: connect/disconnect churn is high-volume on a busy server. Aggregate + // counts land in the periodic stats report instead. + _logger.LogDebug("{User} connected (ConnectionId: {ConnectionId})", username, connectionId); } public async Task UserDisconnectedAsync(string connectionId) @@ -75,6 +82,7 @@ public class ChatService : IChatService : []; var username = _presenceTracker.UserDisconnected(connectionId); + _statsCollector.RecordDisconnection(_presenceTracker.GetOnlineUserCount()); if (username is not null && !_presenceTracker.IsOnline(username)) { @@ -100,7 +108,7 @@ public class ChatService : IChatService } } - _logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId); + _logger.LogDebug("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId); return username; } @@ -165,7 +173,7 @@ public class ChatService : IChatService await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId)); } - _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); + _logger.LogDebug("{User} joined channel '{Channel}'", username, channelName); } var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount); @@ -177,7 +185,7 @@ public class ChatService : IChatService channelName = channelName.ToLowerInvariant().Trim(); _presenceTracker.LeaveChannel(username, channelName); await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username)); - _logger.LogInformation("{User} left channel '{Channel}'", username, channelName); + _logger.LogDebug("{User} left channel '{Channel}'", username, channelName); } public async Task SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null) diff --git a/src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs b/src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs new file mode 100644 index 0000000..e94cc64 --- /dev/null +++ b/src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs @@ -0,0 +1,68 @@ +namespace EchoHub.Server.Services.Stats; + +/// +/// Thread-safe, in-memory accumulator for server-activity counters that have no natural +/// database timestamp to query after the fact — session connects/disconnects, moderation +/// actions, and peak concurrency. The periodic stats-report job snapshots and resets these +/// once per reporting window. Registered as a singleton; every increment is lock-free so it +/// is safe to call from hot paths (connect/disconnect). +/// +public sealed class ServerStatsCollector +{ + private long _connections; + private long _disconnections; + private long _kicks; + private long _bans; + private int _peakOnline; + + /// Record a session coming online, updating the running peak. + public void RecordConnection(int onlineNow) + { + Interlocked.Increment(ref _connections); + RecordOnline(onlineNow); + } + + /// Record a session going offline, updating the running peak. + public void RecordDisconnection(int onlineNow) + { + Interlocked.Increment(ref _disconnections); + RecordOnline(onlineNow); + } + + /// Record a kick action. + public void RecordKick() => Interlocked.Increment(ref _kicks); + + /// Record a ban action. + public void RecordBan() => Interlocked.Increment(ref _bans); + + /// Update the running maximum of concurrent online users (lock-free). + public void RecordOnline(int onlineNow) + { + int current; + while (onlineNow > (current = Volatile.Read(ref _peakOnline))) + { + if (Interlocked.CompareExchange(ref _peakOnline, onlineNow, current) == current) + break; + } + } + + /// + /// Atomically read all counters and reset them for the next reporting window. The peak is + /// reset to so the next window's peak starts from the current + /// concurrency rather than zero. + /// + public StatsCounters SnapshotAndReset(int onlineNow) => new( + Connections: Interlocked.Exchange(ref _connections, 0), + Disconnections: Interlocked.Exchange(ref _disconnections, 0), + Kicks: Interlocked.Exchange(ref _kicks, 0), + Bans: Interlocked.Exchange(ref _bans, 0), + PeakOnline: Interlocked.Exchange(ref _peakOnline, onlineNow)); +} + +/// Immutable snapshot of the counters held by . +public readonly record struct StatsCounters( + long Connections, + long Disconnections, + long Kicks, + long Bans, + int PeakOnline); diff --git a/src/EchoHub.Server/Services/Stats/ServerStatsReportService.cs b/src/EchoHub.Server/Services/Stats/ServerStatsReportService.cs new file mode 100644 index 0000000..1e997c9 --- /dev/null +++ b/src/EchoHub.Server/Services/Stats/ServerStatsReportService.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using EchoHub.Core.Models; +using EchoHub.Server.Config; +using EchoHub.Server.Data; +using EchoHub.Server.Services; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Services.Stats; + +/// +/// Background job that periodically snapshots server activity over a window, logs it as +/// pretty-printed JSON (which also surfaces in the live server-logs room), and persists it to +/// the database for historical trends. Interval and retention are configurable via the "Stats" +/// section; the default cadence is every 6 hours. +/// +public sealed class ServerStatsReportService : BackgroundService +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly PresenceTracker _presence; + private readonly ServerStatsCollector _collector; + private readonly StatsOptions _options; + private readonly ILogger _logger; + + // Start of the current reporting window. Advances to PeriodEnd after each report so that + // the DB-derived counts and the in-memory collector counters cover the same span. + private DateTimeOffset _periodStart; + + public ServerStatsReportService( + IServiceScopeFactory scopeFactory, + PresenceTracker presence, + ServerStatsCollector collector, + StatsOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _presence = presence; + _collector = collector; + _options = options; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.Enabled) + return; + + // A non-positive interval falls back to the 6h default; anything positive is honoured + // but floored at 1s so a mis-set tiny value can't spin the loop. + var interval = _options.IntervalHours > 0 + ? TimeSpan.FromHours(_options.IntervalHours) + : TimeSpan.FromHours(6); + if (interval < TimeSpan.FromSeconds(1)) + interval = TimeSpan.FromSeconds(1); + _periodStart = DateTimeOffset.UtcNow; + + _logger.LogInformation( + "Server stats report job started — reporting every {Hours}h, retention {Days}d", + _options.IntervalHours, _options.RetentionDays); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(interval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + + try + { + await GenerateReportAsync(stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Failed to generate periodic server stats report"); + } + } + } + + private async Task GenerateReportAsync(CancellationToken ct) + { + var periodStart = _periodStart; + var periodEnd = DateTimeOffset.UtcNow; + var onlineNow = _presence.GetOnlineUserCount(); + var counters = _collector.SnapshotAndReset(onlineNow); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var messagesSent = await db.Messages.CountAsync(m => m.SentAt >= periodStart, ct); + + var activeMembers = await db.Messages + .Where(m => m.SentAt >= periodStart) + .Select(m => m.SenderUserId) + .Distinct() + .CountAsync(ct); + + var uploadQuery = db.Attachments + .Where(a => db.Messages.Any(m => m.Id == a.MessageId && m.SentAt >= periodStart)); + var filesUploaded = await uploadQuery.CountAsync(ct); + var bytesUploaded = filesUploaded == 0 ? 0L : await uploadQuery.SumAsync(a => a.FileSize, ct); + + var newMembers = await db.Users.CountAsync(u => u.CreatedAt >= periodStart, ct); + var totalMembers = await db.Users.CountAsync(ct); + + var report = new ServerStatsReport + { + Id = Guid.NewGuid(), + GeneratedAt = periodEnd, + PeriodStart = periodStart, + PeriodEnd = periodEnd, + WindowHours = Math.Round((periodEnd - periodStart).TotalHours, 2), + MessagesSent = messagesSent, + FilesUploaded = filesUploaded, + BytesUploaded = bytesUploaded, + NewMembers = newMembers, + ActiveMembers = activeMembers, + Connections = (int)counters.Connections, + Disconnections = (int)counters.Disconnections, + Kicks = (int)counters.Kicks, + Bans = (int)counters.Bans, + TotalMembers = totalMembers, + OnlineNow = onlineNow, + PeakOnline = counters.PeakOnline, + }; + + db.ServerStatsReports.Add(report); + + // Prune reports beyond the retention window (0 = keep forever). + if (_options.RetentionDays > 0) + { + var cutoff = periodEnd.AddDays(-_options.RetentionDays); + var stale = await db.ServerStatsReports + .Where(r => r.GeneratedAt < cutoff) + .ToListAsync(ct); + if (stale.Count > 0) + db.ServerStatsReports.RemoveRange(stale); + } + + await db.SaveChangesAsync(ct); + _periodStart = periodEnd; + + // Pretty-printed JSON so the report is readable both in the log files and the logs room. + var json = JsonSerializer.Serialize(report, JsonOptions); + _logger.LogInformation("Server stats report ({WindowHours}h window):\n{Report}", report.WindowHours, json); + } +} diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index c9a0d9a..1b2e97d 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -56,6 +56,11 @@ "LogDirectory": "logs", "LogFilePattern": "echohub-server-*.log" }, + "Stats": { + "Enabled": true, + "IntervalHours": 6, + "RetentionDays": 90 + }, "Irc": { "Enabled": false, "Port": 6667,