adjustments and add rate limit
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -18,6 +19,15 @@ namespace LightlessSyncServer.Hubs;
|
|||||||
public partial class LightlessHub
|
public partial class LightlessHub
|
||||||
{
|
{
|
||||||
private const int MaxChatMessageLength = 400;
|
private const int MaxChatMessageLength = 400;
|
||||||
|
private const int ChatRateLimitMessages = 7;
|
||||||
|
private static readonly TimeSpan ChatRateLimitWindow = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly ConcurrentDictionary<string, ChatRateLimitState> ChatRateLimiters = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private sealed class ChatRateLimitState
|
||||||
|
{
|
||||||
|
public readonly Queue<DateTime> Events = new();
|
||||||
|
public readonly object SyncRoot = new();
|
||||||
|
}
|
||||||
private static readonly JsonSerializerOptions ChatReportSnapshotSerializerOptions = new(JsonSerializerDefaults.General)
|
private static readonly JsonSerializerOptions ChatReportSnapshotSerializerOptions = new(JsonSerializerDefaults.General)
|
||||||
{
|
{
|
||||||
WriteIndented = false
|
WriteIndented = false
|
||||||
@@ -192,6 +202,12 @@ public partial class LightlessHub
|
|||||||
throw new HubException("Join a chat channel before sending messages.");
|
throw new HubException("Join a chat channel before sending messages.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!UseChatRateLimit(UserUID))
|
||||||
|
{
|
||||||
|
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can send at most 8 chat messages per minute. Please wait before sending more.").ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var sanitizedMessage = request.Message.Trim().ReplaceLineEndings(" ");
|
var sanitizedMessage = request.Message.Trim().ReplaceLineEndings(" ");
|
||||||
if (sanitizedMessage.Length > MaxChatMessageLength)
|
if (sanitizedMessage.Length > MaxChatMessageLength)
|
||||||
{
|
{
|
||||||
@@ -234,7 +250,7 @@ public partial class LightlessHub
|
|||||||
|
|
||||||
if (_userConnections.TryGetValue(uid, out var connectionId))
|
if (_userConnections.TryGetValue(uid, out var connectionId))
|
||||||
{
|
{
|
||||||
var includeSensitive = AllowsLightfinderDetails(presence.Channel, uid);
|
var includeSensitive = await AllowsLightfinderDetailsAsync(presence.Channel, uid).ConfigureAwait(false);
|
||||||
if (deliveryTargets.TryGetValue(connectionId, out var existing))
|
if (deliveryTargets.TryGetValue(connectionId, out var existing))
|
||||||
{
|
{
|
||||||
deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive);
|
deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive);
|
||||||
@@ -537,7 +553,7 @@ public partial class LightlessHub
|
|||||||
return HasActiveBroadcast(entry, expiry);
|
return HasActiveBroadcast(entry, expiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool AllowsLightfinderDetails(ChatChannelDescriptor descriptor, string userUid)
|
private async Task<bool> AllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor, string userUid)
|
||||||
{
|
{
|
||||||
if (descriptor.Type == ChatChannelType.Group)
|
if (descriptor.Type == ChatChannelType.Group)
|
||||||
{
|
{
|
||||||
@@ -546,7 +562,19 @@ public partial class LightlessHub
|
|||||||
|
|
||||||
if (_chatChannelService.TryGetPresence(userUid, descriptor, out var presence))
|
if (_chatChannelService.TryGetPresence(userUid, descriptor, out var presence))
|
||||||
{
|
{
|
||||||
return presence.Participant.IsLightfinder;
|
if (!presence.Participant.IsLightfinder || !IsValidHashedCid(presence.Participant.HashedCid))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (entry, expiry) = await TryGetBroadcastEntryAsync(presence.Participant.HashedCid!).ConfigureAwait(false);
|
||||||
|
if (!IsActiveBroadcastForUser(entry, expiry, userUid))
|
||||||
|
{
|
||||||
|
_chatChannelService.RefreshLightfinderState(userUid, null, isLightfinder: false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -578,4 +606,25 @@ public partial class LightlessHub
|
|||||||
await Clients.Client(connectionId).Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false);
|
await Clients.Client(connectionId).Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool UseChatRateLimit(string userUid)
|
||||||
|
{
|
||||||
|
var state = ChatRateLimiters.GetOrAdd(userUid, _ => new ChatRateLimitState());
|
||||||
|
lock (state.SyncRoot)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
while (state.Events.Count > 0 && now - state.Events.Peek() >= ChatRateLimitWindow)
|
||||||
|
{
|
||||||
|
state.Events.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.Events.Count >= ChatRateLimitMessages)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Events.Enqueue(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,6 +464,7 @@ public partial class LightlessHub
|
|||||||
await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
|
await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
|
||||||
await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
|
await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
|
||||||
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast enabled", hashedCid, "GID", gid));
|
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast enabled", hashedCid, "GID", gid));
|
||||||
|
_chatChannelService.RefreshLightfinderState(UserUID, hashedCid, isLightfinder: true);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -504,6 +505,7 @@ public partial class LightlessHub
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID));
|
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID));
|
||||||
|
_chatChannelService.RefreshLightfinderState(UserUID, null, isLightfinder: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,4 +1246,4 @@ public partial class LightlessHub
|
|||||||
|
|
||||||
private ClientPair OppositeEntry(string otherUID) =>
|
private ClientPair OppositeEntry(string otherUID) =>
|
||||||
DbContext.ClientPairs.AsNoTracking().SingleOrDefault(w => w.User.UID == otherUID && w.OtherUser.UID == UserUID);
|
DbContext.ClientPairs.AsNoTracking().SingleOrDefault(w => w.User.UID == otherUID && w.OtherUser.UID == UserUID);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public sealed class ChatChannelService
|
|||||||
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 = 40;
|
private const int MaxMessagesPerChannel = 200;
|
||||||
|
|
||||||
public ChatChannelService(ILogger<ChatChannelService> logger)
|
public ChatChannelService(ILogger<ChatChannelService> logger)
|
||||||
{
|
{
|
||||||
@@ -277,6 +277,41 @@ public sealed class ChatChannelService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(userUid);
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!_presenceByUser.TryGetValue(userUid, out var entries) || entries.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (key, existing) in entries.ToArray())
|
||||||
|
{
|
||||||
|
var updatedParticipant = existing.Participant with
|
||||||
|
{
|
||||||
|
HashedCid = isLightfinder ? hashedCid : null,
|
||||||
|
IsLightfinder = isLightfinder
|
||||||
|
};
|
||||||
|
|
||||||
|
var updatedEntry = existing with
|
||||||
|
{
|
||||||
|
Participant = updatedParticipant,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
entries[key] = updatedEntry;
|
||||||
|
|
||||||
|
if (_participantsByChannel.TryGetValue(key, out var participants))
|
||||||
|
{
|
||||||
|
participants[updatedParticipant.Token] = updatedParticipant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ChatPresenceEntry? UpdatePresence(
|
private ChatPresenceEntry? UpdatePresence(
|
||||||
string userUid,
|
string userUid,
|
||||||
ChatChannelDescriptor descriptor,
|
ChatChannelDescriptor descriptor,
|
||||||
@@ -398,15 +433,7 @@ public sealed class ChatChannelService
|
|||||||
if (members.Count == 0)
|
if (members.Count == 0)
|
||||||
{
|
{
|
||||||
_membersByChannel.Remove(key);
|
_membersByChannel.Remove(key);
|
||||||
if (_messagesByChannel.TryGetValue(key, out var messages))
|
// Preserve message history even when a channel becomes empty so moderation can still resolve reports.
|
||||||
{
|
|
||||||
foreach (var entry in messages)
|
|
||||||
{
|
|
||||||
_messageIndex.Remove(entry.MessageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
_messagesByChannel.Remove(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user