From 8821f1d4505071311fd9907a20d13b2d4c15bb48 Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Wed, 12 Nov 2025 04:39:32 +0900 Subject: [PATCH] adjustments and add rate limit --- .../Hubs/LightlessHub.Chat.cs | 55 ++++++++++++++++++- .../Hubs/LightlessHub.User.cs | 4 +- .../Services/ChatChannelService.cs | 47 ++++++++++++---- 3 files changed, 92 insertions(+), 14 deletions(-) diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs index 4ae561a..74b07f5 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -18,6 +19,15 @@ namespace LightlessSyncServer.Hubs; public partial class LightlessHub { private const int MaxChatMessageLength = 400; + private const int ChatRateLimitMessages = 7; + private static readonly TimeSpan ChatRateLimitWindow = TimeSpan.FromMinutes(1); + private static readonly ConcurrentDictionary ChatRateLimiters = new(StringComparer.Ordinal); + + private sealed class ChatRateLimitState + { + public readonly Queue Events = new(); + public readonly object SyncRoot = new(); + } private static readonly JsonSerializerOptions ChatReportSnapshotSerializerOptions = new(JsonSerializerDefaults.General) { WriteIndented = false @@ -192,6 +202,12 @@ public partial class LightlessHub 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(" "); if (sanitizedMessage.Length > MaxChatMessageLength) { @@ -234,7 +250,7 @@ public partial class LightlessHub 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)) { deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive); @@ -537,7 +553,7 @@ public partial class LightlessHub return HasActiveBroadcast(entry, expiry); } - private bool AllowsLightfinderDetails(ChatChannelDescriptor descriptor, string userUid) + private async Task AllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor, string userUid) { if (descriptor.Type == ChatChannelType.Group) { @@ -546,7 +562,19 @@ public partial class LightlessHub 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; @@ -578,4 +606,25 @@ public partial class LightlessHub 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; + } + } } diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs index b4358bb..bf1981f 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs @@ -464,6 +464,7 @@ public partial class LightlessHub await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args("broadcast enabled", hashedCid, "GID", gid)); + _chatChannelService.RefreshLightfinderState(UserUID, hashedCid, isLightfinder: true); } else { @@ -504,6 +505,7 @@ public partial class LightlessHub } _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) => DbContext.ClientPairs.AsNoTracking().SingleOrDefault(w => w.User.UID == otherUID && w.OtherUser.UID == UserUID); -} \ No newline at end of file +} diff --git a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs index 9c47097..6c01cb7 100644 --- a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs +++ b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs @@ -19,7 +19,7 @@ public sealed class ChatChannelService private readonly Dictionary> _messagesByChannel = new(); private readonly Dictionary Node)> _messageIndex = new(StringComparer.Ordinal); private readonly object _syncRoot = new(); - private const int MaxMessagesPerChannel = 40; + private const int MaxMessagesPerChannel = 200; public ChatChannelService(ILogger logger) { @@ -277,6 +277,41 @@ public sealed class ChatChannelService 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( string userUid, ChatChannelDescriptor descriptor, @@ -398,15 +433,7 @@ public sealed class ChatChannelService if (members.Count == 0) { _membersByChannel.Remove(key); - if (_messagesByChannel.TryGetValue(key, out var messages)) - { - foreach (var entry in messages) - { - _messageIndex.Remove(entry.MessageId); - } - - _messagesByChannel.Remove(key); - } + // Preserve message history even when a channel becomes empty so moderation can still resolve reports. } }