using EchoHub.Core.Models; using EchoHub.Server.Config; namespace EchoHub.Server.Services; public enum SpamVerdictKind { Allowed, Rejected, /// The user crossed the violation threshold — the caller should apply a timed mute. AutoMute, } public readonly record struct SpamVerdict(SpamVerdictKind Kind, string? Reason = null, TimeSpan MuteDuration = default) { public static readonly SpamVerdict Allowed = new(SpamVerdictKind.Allowed); } /// /// In-memory, per-user spam protection consulted from (messages, /// joins) and (channel creation), so every ingress protocol — /// SignalR and IRC alike — shares the same limits. State is per-process and never persisted; /// auto-mutes land in the normal mute store. Operates only on content the server already /// stores (for end-to-end encrypted rooms that is ciphertext) — nothing is decrypted. /// public sealed class SpamGuard { private readonly SpamOptions _options; private readonly Lock _lock = new(); private readonly Dictionary _users = []; // Lazy stale-state pruning so the dictionary can't grow unbounded on busy servers private const int PruneThreshold = 512; private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30); public SpamGuard(SpamOptions options) { _options = options; } public bool Enabled => _options.Enabled; private sealed class UserState { public readonly Queue MessageTimes = new(); public readonly Queue JoinTimes = new(); public readonly Queue CreateTimes = new(); public readonly Queue Violations = new(); public string? LastContent; public int RepeatCount; public DateTimeOffset LastSeen; } /// /// Checks a message send. Rejections count as violations; enough violations inside the /// violation window escalate to (evaluated only on /// rejected messages, so a clean message never mutes anyone). /// public SpamVerdict CheckMessage(Guid userId, ServerRole role, string content, DateTimeOffset? nowOverride = null) { if (!_options.Enabled || role >= ServerRole.Mod) return SpamVerdict.Allowed; var now = nowOverride ?? DateTimeOffset.UtcNow; lock (_lock) { var state = GetState(userId, now); // Flood: attempts count too — a client hammering retries must actually pause Prune(state.MessageTimes, now - TimeSpan.FromSeconds(_options.WindowSeconds)); state.MessageTimes.Enqueue(now); if (state.MessageTimes.Count > _options.MaxMessagesPerWindow) return Reject(state, now, "You're sending messages too fast — slow down."); // Duplicates: identical (trimmed, case-insensitive) content repeated back-to-back var normalized = content.Trim().ToLowerInvariant(); if (normalized == state.LastContent) { if (state.RepeatCount >= _options.MaxDuplicateMessages) return Reject(state, now, "Duplicate message — say something new."); state.RepeatCount++; } else { state.LastContent = normalized; state.RepeatCount = 1; } return SpamVerdict.Allowed; } } /// Checks a channel join. Rejections count as violations but never auto-mute. public SpamVerdict CheckJoin(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null) { return CheckWindow(userId, role, nowOverride, s => s.JoinTimes, TimeSpan.FromSeconds(_options.JoinWindowSeconds), _options.MaxJoinsPerWindow, "You're joining channels too fast — slow down."); } /// Checks a channel creation. Rejections count as violations but never auto-mute. public SpamVerdict CheckChannelCreate(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null) { return CheckWindow(userId, role, nowOverride, s => s.CreateTimes, TimeSpan.FromMinutes(_options.ChannelCreateWindowMinutes), _options.MaxChannelCreatesPerWindow, "You're creating channels too fast — try again later."); } private SpamVerdict CheckWindow(Guid userId, ServerRole role, DateTimeOffset? nowOverride, Func> queueSelector, TimeSpan window, int max, string reason) { if (!_options.Enabled || role >= ServerRole.Mod) return SpamVerdict.Allowed; var now = nowOverride ?? DateTimeOffset.UtcNow; lock (_lock) { var state = GetState(userId, now); var queue = queueSelector(state); Prune(queue, now - window); queue.Enqueue(now); if (queue.Count > max) { RecordViolation(state, now); return new SpamVerdict(SpamVerdictKind.Rejected, reason); } return SpamVerdict.Allowed; } } /// /// Records a violation and decides between plain rejection and auto-mute escalation. /// On escalation the violation history is cleared so the user starts fresh post-mute. /// private SpamVerdict Reject(UserState state, DateTimeOffset now, string reason) { RecordViolation(state, now); if (_options.AutoMuteMinutes > 0 && state.Violations.Count >= _options.ViolationThreshold) { state.Violations.Clear(); return new SpamVerdict(SpamVerdictKind.AutoMute, reason, TimeSpan.FromMinutes(_options.AutoMuteMinutes)); } return new SpamVerdict(SpamVerdictKind.Rejected, reason); } private void RecordViolation(UserState state, DateTimeOffset now) { Prune(state.Violations, now - TimeSpan.FromMinutes(_options.ViolationWindowMinutes)); state.Violations.Enqueue(now); } private UserState GetState(Guid userId, DateTimeOffset now) { if (_users.Count > PruneThreshold) { foreach (var stale in _users.Where(kv => now - kv.Value.LastSeen > StaleAfter).Select(kv => kv.Key).ToList()) _users.Remove(stale); } if (!_users.TryGetValue(userId, out var state)) { state = new UserState(); _users[userId] = state; } state.LastSeen = now; return state; } private static void Prune(Queue queue, DateTimeOffset cutoff) { while (queue.Count > 0 && queue.Peek() < cutoff) queue.Dequeue(); } }