Fix chat stuff #32
@@ -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 " + ChatRateLimitMessages + " 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user