From 03ba9493fc191399497fc0479cf8c2c82c21b780 Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:13:22 +0900 Subject: [PATCH] prevent report dodging by leaving channel, allow reports to work after leaving the channel too --- .../Hubs/LightlessHub.Chat.cs | 6 - .../Services/ChatChannelService.cs | 105 +++++++++++++++++- .../Discord/DiscordBot.cs | 8 +- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs index 9fcabf0..ccc45dc 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs @@ -334,12 +334,6 @@ public partial class LightlessHub { var channel = request.Channel.WithNormalizedCustomKey(); - if (!_chatChannelService.TryGetPresence(UserUID, channel, out _)) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Join the chat channel before reporting messages.").ConfigureAwait(false); - return; - } - if (!_chatChannelService.TryGetMessage(request.MessageId, out var messageEntry)) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to locate the reported message. It may have already expired.").ConfigureAwait(false); diff --git a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs index 6c01cb7..fb9746f 100644 --- a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs +++ b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs @@ -1,31 +1,32 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; +using System.Security.Cryptography; using LightlessSync.API.Data; using LightlessSync.API.Dto.Chat; using LightlessSyncServer.Models; -using Microsoft.Extensions.Logging; namespace LightlessSyncServer.Services; -public sealed class ChatChannelService +public sealed class ChatChannelService : IDisposable { private readonly ILogger _logger; private readonly Dictionary _zoneDefinitions; private readonly Dictionary> _membersByChannel = new(); private readonly Dictionary> _presenceByUser = new(StringComparer.Ordinal); private readonly Dictionary> _participantsByChannel = new(); + private readonly Dictionary> _inactiveParticipantsByChannel = new(); private readonly Dictionary> _messagesByChannel = new(); private readonly Dictionary Node)> _messageIndex = new(StringComparer.Ordinal); private readonly object _syncRoot = new(); private const int MaxMessagesPerChannel = 200; + private static readonly TimeSpan InactiveParticipantRetention = TimeSpan.FromMinutes(15); + private static readonly TimeSpan InactiveParticipantCleanupInterval = TimeSpan.FromMinutes(1); + private readonly Timer _inactiveParticipantCleanupTimer; public ChatChannelService(ILogger logger) { _logger = logger; _zoneDefinitions = ChatZoneDefinitions.Defaults .ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase); + _inactiveParticipantCleanupTimer = new Timer(_ => CleanupExpiredInactiveParticipants(), null, InactiveParticipantCleanupInterval, InactiveParticipantCleanupInterval); } public IReadOnlyList GetZoneChannelInfos() => @@ -271,6 +272,22 @@ public sealed class ChatChannelService { return true; } + + if (_inactiveParticipantsByChannel.TryGetValue(key, out var inactive) && + inactive.TryGetValue(token, out var inactiveEntry)) + { + if (inactiveEntry.ExpiresAt > DateTime.UtcNow) + { + participant = inactiveEntry.Participant; + return true; + } + + inactive.Remove(token); + if (inactive.Count == 0) + { + _inactiveParticipantsByChannel.Remove(key); + } + } } participant = default; @@ -412,6 +429,7 @@ public sealed class ChatChannelService } participantsByToken[token] = finalParticipant; + RemoveInactiveParticipantLocked(key, token); _logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key)); return entryToStore; @@ -446,9 +464,77 @@ public sealed class ChatChannelService } } + StoreInactiveParticipantLocked(key, existing.Participant); return true; } + private void StoreInactiveParticipantLocked(ChannelKey key, ChatParticipantInfo participant) + { + if (string.IsNullOrEmpty(participant.Token)) + { + return; + } + + if (!_inactiveParticipantsByChannel.TryGetValue(key, out var inactive)) + { + inactive = new Dictionary(StringComparer.Ordinal); + _inactiveParticipantsByChannel[key] = inactive; + } + + inactive[participant.Token] = new InactiveParticipantEntry(participant, DateTime.UtcNow + InactiveParticipantRetention); + } + + private void RemoveInactiveParticipantLocked(ChannelKey key, string token) + { + if (_inactiveParticipantsByChannel.TryGetValue(key, out var inactive) && + inactive.Remove(token) && + inactive.Count == 0) + { + _inactiveParticipantsByChannel.Remove(key); + } + } + + private void CleanupExpiredInactiveParticipants() + { + lock (_syncRoot) + { + if (_inactiveParticipantsByChannel.Count == 0) + { + return; + } + + var now = DateTime.UtcNow; + var channelsToRemove = new List(); + + foreach (var (key, inactive) in _inactiveParticipantsByChannel) + { + var tokensToRemove = new List(); + foreach (var (token, entry) in inactive) + { + if (entry.ExpiresAt <= now) + { + tokensToRemove.Add(token); + } + } + + foreach (var token in tokensToRemove) + { + inactive.Remove(token); + } + + if (inactive.Count == 0) + { + channelsToRemove.Add(key); + } + } + + foreach (var channel in channelsToRemove) + { + _inactiveParticipantsByChannel.Remove(channel); + } + } + } + private static string GenerateToken() { Span buffer = stackalloc byte[8]; @@ -458,4 +544,11 @@ public sealed class ChatChannelService private static string Describe(ChannelKey key) => $"{key.Type}:{key.WorldId}:{key.CustomKey}"; + + public void Dispose() + { + _inactiveParticipantCleanupTimer.Dispose(); + } + + private readonly record struct InactiveParticipantEntry(ChatParticipantInfo Participant, DateTime ExpiresAt); } diff --git a/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs b/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs index de69b95..7db6a75 100644 --- a/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs +++ b/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs @@ -204,7 +204,7 @@ internal class DiscordBot : IHostedService var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false); var components = new ComponentBuilder() - .WithButton("Actioned", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger) + .WithButton("Resolve", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger) .WithButton("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary) .WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger); @@ -395,7 +395,7 @@ internal class DiscordBot : IHostedService switch (action) { case "resolve": - resolutionLabel = "Actioned"; + resolutionLabel = "Resolved"; break; case "dismiss": resolutionLabel = "Dismissed"; @@ -431,7 +431,7 @@ internal class DiscordBot : IHostedService string responseText = action switch { - "resolve" => "actioned", + "resolve" => "resolved", "dismiss" => "dismissed", "banchat" => "chat access revoked", _ => "processed" @@ -473,7 +473,7 @@ internal class DiscordBot : IHostedService embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase)); var resolutionText = action switch { - "resolve" => "Actioned", + "resolve" => "Resolved", "dismiss" => "Dismissed", "banchat" => "Chat access revoked", _ => "Processed"