prevent report dodging by leaving channel, allow reports to work after leaving the channel too
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user