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();
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);

View File

@@ -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<ChatChannelService> _logger;
private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions;
private readonly Dictionary<ChannelKey, HashSet<string>> _membersByChannel = new();
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, InactiveParticipantEntry>> _inactiveParticipantsByChannel = new();
private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new();
private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> 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<ChatChannelService> logger)
{
_logger = logger;
_zoneDefinitions = ChatZoneDefinitions.Defaults
.ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase);
_inactiveParticipantCleanupTimer = new Timer(_ => CleanupExpiredInactiveParticipants(), null, InactiveParticipantCleanupInterval, InactiveParticipantCleanupInterval);
}
public IReadOnlyList<ZoneChatChannelInfoDto> 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<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()
{
Span<byte> 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);
}

View File

@@ -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"