adjustments and add rate limit

This commit is contained in:
azyges
2025-11-12 04:39:32 +09:00
parent cf5135f598
commit 8821f1d450
3 changed files with 92 additions and 14 deletions

View File

@@ -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<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)
{
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<bool> 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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -19,7 +19,7 @@ public sealed class ChatChannelService
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 = 40;
private const int MaxMessagesPerChannel = 200;
public ChatChannelService(ILogger<ChatChannelService> 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.
}
}