prevent report dodging by leaving channel, allow reports to work after leaving the channel too

This commit is contained in:
azyges
2025-12-14 15:13:22 +09:00
parent c731b265ff
commit 03ba9493fc
3 changed files with 103 additions and 16 deletions

View File

@@ -334,12 +334,6 @@ public partial class LightlessHub
{ {
var channel = request.Channel.WithNormalizedCustomKey(); 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)) 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); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to locate the reported message. It may have already expired.").ConfigureAwait(false);

View File

@@ -1,31 +1,32 @@
using System; using System.Security.Cryptography;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Chat;
using LightlessSyncServer.Models; using LightlessSyncServer.Models;
using Microsoft.Extensions.Logging;
namespace LightlessSyncServer.Services; namespace LightlessSyncServer.Services;
public sealed class ChatChannelService public sealed class ChatChannelService : IDisposable
{ {
private readonly ILogger<ChatChannelService> _logger; private readonly ILogger<ChatChannelService> _logger;
private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions; private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions;
private readonly Dictionary<ChannelKey, HashSet<string>> _membersByChannel = new(); private readonly Dictionary<ChannelKey, HashSet<string>> _membersByChannel = new();
private readonly Dictionary<string, Dictionary<ChannelKey, ChatPresenceEntry>> _presenceByUser = new(StringComparer.Ordinal); private readonly Dictionary<string, Dictionary<ChannelKey, ChatPresenceEntry>> _presenceByUser = new(StringComparer.Ordinal);
private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new(); private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new();
private readonly Dictionary<ChannelKey, Dictionary<string, InactiveParticipantEntry>> _inactiveParticipantsByChannel = new();
private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new(); private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new();
private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> Node)> _messageIndex = new(StringComparer.Ordinal); private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> Node)> _messageIndex = new(StringComparer.Ordinal);
private readonly object _syncRoot = new(); private readonly object _syncRoot = new();
private const int MaxMessagesPerChannel = 200; 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<ChatChannelService> logger) public ChatChannelService(ILogger<ChatChannelService> logger)
{ {
_logger = logger; _logger = logger;
_zoneDefinitions = ChatZoneDefinitions.Defaults _zoneDefinitions = ChatZoneDefinitions.Defaults
.ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase); .ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase);
_inactiveParticipantCleanupTimer = new Timer(_ => CleanupExpiredInactiveParticipants(), null, InactiveParticipantCleanupInterval, InactiveParticipantCleanupInterval);
} }
public IReadOnlyList<ZoneChatChannelInfoDto> GetZoneChannelInfos() => public IReadOnlyList<ZoneChatChannelInfoDto> GetZoneChannelInfos() =>
@@ -271,6 +272,22 @@ public sealed class ChatChannelService
{ {
return true; 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; participant = default;
@@ -412,6 +429,7 @@ public sealed class ChatChannelService
} }
participantsByToken[token] = finalParticipant; participantsByToken[token] = finalParticipant;
RemoveInactiveParticipantLocked(key, token);
_logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key)); _logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key));
return entryToStore; return entryToStore;
@@ -446,9 +464,77 @@ public sealed class ChatChannelService
} }
} }
StoreInactiveParticipantLocked(key, existing.Participant);
return true; 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<string, InactiveParticipantEntry>(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<ChannelKey>();
foreach (var (key, inactive) in _inactiveParticipantsByChannel)
{
var tokensToRemove = new List<string>();
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() private static string GenerateToken()
{ {
Span<byte> buffer = stackalloc byte[8]; Span<byte> buffer = stackalloc byte[8];
@@ -458,4 +544,11 @@ public sealed class ChatChannelService
private static string Describe(ChannelKey key) private static string Describe(ChannelKey key)
=> $"{key.Type}:{key.WorldId}:{key.CustomKey}"; => $"{key.Type}:{key.WorldId}:{key.CustomKey}";
public void Dispose()
{
_inactiveParticipantCleanupTimer.Dispose();
}
private readonly record struct InactiveParticipantEntry(ChatParticipantInfo Participant, DateTime ExpiresAt);
} }

View File

@@ -204,7 +204,7 @@ internal class DiscordBot : IHostedService
var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false); var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false);
var components = new ComponentBuilder() 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("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary)
.WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger); .WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger);
@@ -395,7 +395,7 @@ internal class DiscordBot : IHostedService
switch (action) switch (action)
{ {
case "resolve": case "resolve":
resolutionLabel = "Actioned"; resolutionLabel = "Resolved";
break; break;
case "dismiss": case "dismiss":
resolutionLabel = "Dismissed"; resolutionLabel = "Dismissed";
@@ -431,7 +431,7 @@ internal class DiscordBot : IHostedService
string responseText = action switch string responseText = action switch
{ {
"resolve" => "actioned", "resolve" => "resolved",
"dismiss" => "dismissed", "dismiss" => "dismissed",
"banchat" => "chat access revoked", "banchat" => "chat access revoked",
_ => "processed" _ => "processed"
@@ -473,7 +473,7 @@ internal class DiscordBot : IHostedService
embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase)); embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase));
var resolutionText = action switch var resolutionText = action switch
{ {
"resolve" => "Actioned", "resolve" => "Resolved",
"dismiss" => "Dismissed", "dismiss" => "Dismissed",
"banchat" => "Chat access revoked", "banchat" => "Chat access revoked",
_ => "Processed" _ => "Processed"