diff --git a/LightlessAPI b/LightlessAPI index dfb0594..6b54352 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit dfb0594a5be49994cda6d95aa0d995bd93cdfbc0 +Subproject commit 6b543529aa2dd660ed397eb45ed8b2936664e8ef diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs index 12ed62d..1e21a90 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs @@ -2,14 +2,13 @@ using System.Collections.Concurrent; using System.Text.Json; using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto.Chat; -using LightlessSync.API.Dto.User; using LightlessSyncServer.Models; +using LightlessSyncServer.Services; using LightlessSyncServer.Utils; using LightlessSyncShared.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; - namespace LightlessSyncServer.Hubs; public partial class LightlessHub @@ -81,14 +80,18 @@ public partial class LightlessHub if (userRecord.ChatBanned) { - _chatChannelService.RemovePresence(UserUID); + TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "clearing presence for banned user"); await NotifyChatBanAsync(UserUID).ConfigureAwait(false); return; } if (!presence.IsActive) { - _chatChannelService.RemovePresence(UserUID, channel); + if (!TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID, channel), "removing chat presence", channel)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "We couldn't update your chat presence. Please try again.").ConfigureAwait(false); + } + return; } @@ -122,14 +125,22 @@ public partial class LightlessHub } } - _chatChannelService.UpdateZonePresence( - UserUID, - definition, - channel.WorldId, - presence.TerritoryId, - hashedCid, - isLightfinder, - isActive: true); + if (!TryInvokeChatService( + () => _chatChannelService.UpdateZonePresence( + UserUID, + definition, + channel.WorldId, + presence.TerritoryId, + hashedCid, + isLightfinder, + isActive: true), + "updating zone chat presence", + channel)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Zone chat is temporarily unavailable. Please try again.").ConfigureAwait(false); + return; + } + break; case ChatChannelType.Group: @@ -164,13 +175,21 @@ public partial class LightlessHub var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias; - _chatChannelService.UpdateGroupPresence( - UserUID, - group.GID, - displayName, - userData, - IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null, - isActive: true); + if (!TryInvokeChatService( + () => _chatChannelService.UpdateGroupPresence( + UserUID, + group.GID, + displayName, + userData, + IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null, + isActive: true), + "updating group chat presence", + channel)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Syncshell chat is temporarily unavailable. Please try again.").ConfigureAwait(false); + return; + } + break; default: @@ -210,119 +229,101 @@ public partial class LightlessHub sanitizedMessage = sanitizedMessage[..MaxChatMessageLength]; } - var recipients = _chatChannelService.GetMembers(presence.Channel); - var recipientsList = recipients.ToList(); - if (recipientsList.Count == 0) + if (channel.Type == ChatChannelType.Zone && + !ChatMessageFilter.TryValidate(sanitizedMessage, out var rejectionReason)) { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, rejectionReason).ConfigureAwait(false); return; } - var bannedRecipients = recipientsList.Count == 0 - ? new List() - : await DbContext.Users.AsNoTracking() - .Where(u => recipientsList.Contains(u.UID) && u.ChatBanned) - .Select(u => u.UID) - .ToListAsync(RequestAbortedToken) - .ConfigureAwait(false); - - HashSet? bannedSet = null; - if (bannedRecipients.Count > 0) + try { - bannedSet = new HashSet(bannedRecipients, StringComparer.Ordinal); - foreach (var bannedUid in bannedSet) + var recipients = _chatChannelService.GetMembers(presence.Channel); + var recipientsList = recipients.ToList(); + if (recipientsList.Count == 0) { - _chatChannelService.RemovePresence(bannedUid); - await NotifyChatBanAsync(bannedUid).ConfigureAwait(false); - } - } - - var deliveryTargets = new Dictionary(StringComparer.Ordinal); - foreach (var uid in recipientsList) - { - if (bannedSet != null && bannedSet.Contains(uid)) - { - continue; + return; } - if (_userConnections.TryGetValue(uid, out var connectionId)) + var bannedRecipients = recipientsList.Count == 0 + ? new List() + : await DbContext.Users.AsNoTracking() + .Where(u => recipientsList.Contains(u.UID) && u.ChatBanned) + .Select(u => u.UID) + .ToListAsync(RequestAbortedToken) + .ConfigureAwait(false); + + HashSet? bannedSet = null; + if (bannedRecipients.Count > 0) { - var includeSensitive = await AllowsLightfinderDetailsAsync(presence.Channel, uid).ConfigureAwait(false); - if (deliveryTargets.TryGetValue(connectionId, out var existing)) + bannedSet = new HashSet(bannedRecipients, StringComparer.Ordinal); + foreach (var bannedUid in bannedSet) { - deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive); + _chatChannelService.RemovePresence(bannedUid); + await NotifyChatBanAsync(bannedUid).ConfigureAwait(false); + } + } + + var deliveryTargets = new Dictionary(StringComparer.Ordinal); + foreach (var uid in recipientsList) + { + if (bannedSet != null && bannedSet.Contains(uid)) + { + continue; + } + + if (_userConnections.TryGetValue(uid, out var connectionId)) + { + if (_chatChannelService.IsTokenMuted(uid, presence.Channel, presence.Participant.Token)) + { + continue; + } + + var includeSensitive = await AllowsLightfinderDetailsAsync(presence.Channel, uid).ConfigureAwait(false); + if (deliveryTargets.TryGetValue(connectionId, out var existing)) + { + deliveryTargets[connectionId] = (existing.Uid, existing.IncludeSensitive || includeSensitive); + } + else + { + deliveryTargets[connectionId] = (uid, includeSensitive); + } } else { - deliveryTargets[connectionId] = (uid, includeSensitive); + _chatChannelService.RemovePresence(uid); } } - else + + if (deliveryTargets.Count == 0) { - _chatChannelService.RemovePresence(uid); + return; } - } - if (deliveryTargets.Count == 0) + var timestamp = DateTime.UtcNow; + var messageId = _chatChannelService.RecordMessage(presence.Channel, presence.Participant, sanitizedMessage, timestamp); + var sendTasks = new List(deliveryTargets.Count); + + foreach (var (connectionId, target) in deliveryTargets) + { + var sender = BuildSenderDescriptor(presence.Channel, presence.Participant, target.IncludeSensitive); + var payload = new ChatMessageDto( + presence.Channel, + sender, + sanitizedMessage, + timestamp, + messageId); + + sendTasks.Add(Clients.Client(connectionId).Client_ChatReceive(payload)); + } + + await Task.WhenAll(sendTasks).ConfigureAwait(false); + } + catch (Exception ex) { - return; + _logger.LogError(ex, "Failed to deliver chat message for {User} in {Channel}", UserUID, DescribeChannel(presence.Channel)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Something went wrong while sending your message. Please try again.").ConfigureAwait(false); } - - var timestamp = DateTime.UtcNow; - var messageId = _chatChannelService.RecordMessage(presence.Channel, presence.Participant, sanitizedMessage, timestamp); - var sendTasks = new List(deliveryTargets.Count); - - foreach (var (connectionId, target) in deliveryTargets) - { - var sender = BuildSenderDescriptor(presence.Channel, presence.Participant, target.IncludeSensitive); - var payload = new ChatMessageDto( - presence.Channel, - sender, - sanitizedMessage, - timestamp, - messageId); - - sendTasks.Add(Clients.Client(connectionId).Client_ChatReceive(payload)); - } - - await Task.WhenAll(sendTasks).ConfigureAwait(false); - } - - [Authorize(Policy = "Identified")] - public async Task ResolveChatParticipant(ChatParticipantResolveRequestDto request) - { - var channel = request.Channel.WithNormalizedCustomKey(); - - if (!_chatChannelService.TryGetPresence(UserUID, channel, out _)) - { - throw new HubException("Join the chat channel before resolving participants."); - } - - if (!_chatChannelService.TryResolveParticipant(channel, request.Token, out var participant)) - { - return null; - } - - var viewerAllowsDetails = await ViewerAllowsLightfinderDetailsAsync(channel).ConfigureAwait(false); - var includeSensitiveInfo = channel.Type == ChatChannelType.Group || viewerAllowsDetails; - - var sender = BuildSenderDescriptor(channel, participant, includeSensitiveInfo); - - if (!includeSensitiveInfo) - { - return new ChatParticipantResolveResultDto(channel, sender, null); - } - - UserProfileDto? profile = null; - if (channel.Type == ChatChannelType.Group) - { - profile = await LoadChatParticipantProfileAsync(participant.UserUid).ConfigureAwait(false); - } - else if (participant.IsLightfinder && !string.IsNullOrEmpty(participant.HashedCid)) - { - profile = await LoadChatParticipantProfileAsync(participant.UserUid).ConfigureAwait(false); - } - - return new ChatParticipantResolveResultDto(channel, sender, profile); } [Authorize(Policy = "Identified")] @@ -436,7 +437,6 @@ public partial class LightlessHub MessageId = messageEntry.MessageId, MessageSentAtUtc = messageEntry.SentAtUtc, MessageContent = messageEntry.Message, - SenderToken = messageEntry.SenderToken, SenderHashedCid = messageEntry.SenderHashedCid, SenderDisplayName = messageEntry.SenderUser?.AliasOrUID, SenderWasLightfinder = messageEntry.SenderIsLightfinder, @@ -451,6 +451,91 @@ public partial class LightlessHub await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Thank you. Your report has been queued for moderator review.").ConfigureAwait(false); } + [Authorize(Policy = "Identified")] + public async Task SetChatParticipantMute(ChatParticipantMuteRequestDto request) + { + var channel = request.Channel.WithNormalizedCustomKey(); + + if (!_chatChannelService.TryGetPresence(UserUID, channel, out _)) + { + throw new HubException("Join the chat channel before updating mutes."); + } + + if (string.IsNullOrWhiteSpace(request.Token)) + { + throw new HubException("Invalid participant."); + } + + if (!_chatChannelService.TryGetActiveParticipant(channel, request.Token, out var participant)) + { + throw new HubException("Unable to locate that participant in this channel."); + } + + if (string.Equals(participant.UserUid, UserUID, StringComparison.Ordinal)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You cannot mute yourself.").ConfigureAwait(false); + return; + } + + ChatMuteUpdateResult result; + try + { + result = _chatChannelService.SetMutedParticipant(UserUID, channel, participant, request.Mute); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update mute for {User} in {Channel}", UserUID, DescribeChannel(channel)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to update mute settings right now. Please try again.").ConfigureAwait(false); + return; + } + + if (result == ChatMuteUpdateResult.ChannelLimitReached) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"You can mute at most {ChatChannelService.MaxMutedParticipantsPerChannel} participants per channel. Unmute someone before adding another mute.").ConfigureAwait(false); + return; + } + + if (result != ChatMuteUpdateResult.Changed) + { + return; + } + + if (request.Mute) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "You will no longer receive this participant's messages in the current channel.").ConfigureAwait(false); + } + else + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "You will receive this participant's messages again.").ConfigureAwait(false); + } + } + + private static string DescribeChannel(ChatChannelDescriptor descriptor) => + $"{descriptor.Type}:{descriptor.WorldId}:{descriptor.CustomKey}"; + + private bool TryInvokeChatService(Action action, string operationDescription, ChatChannelDescriptor? descriptor = null, string? targetUserUid = null) + { + try + { + action(); + return true; + } + catch (Exception ex) + { + var logUser = targetUserUid ?? UserUID; + if (descriptor is ChatChannelDescriptor described) + { + _logger.LogError(ex, "Chat service failed while {Operation} for {User} in {Channel}", operationDescription, logUser, DescribeChannel(described)); + } + else + { + _logger.LogError(ex, "Chat service failed while {Operation} for {User}", operationDescription, logUser); + } + + return false; + } + } + private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatParticipantInfo participant, bool includeSensitiveInfo = false) { var kind = descriptor.Type == ChatChannelType.Group @@ -477,7 +562,7 @@ public partial class LightlessHub ? participant.HashedCid : null; - var canResolveProfile = kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder; + var canResolveProfile = includeSensitiveInfo && (kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder); return new ChatSenderDescriptor( kind, @@ -488,44 +573,6 @@ public partial class LightlessHub canResolveProfile); } - private async Task LoadChatParticipantProfileAsync(string userUid) - { - if (string.IsNullOrEmpty(userUid)) - return null; - - var targetUser = await DbContext.Users - .AsNoTracking() - .SingleOrDefaultAsync(u => u.UID == userUid, cancellationToken: RequestAbortedToken) - .ConfigureAwait(false); - - if (targetUser is null) - return null; - - var userData = targetUser.ToUserData(); - - var profileData = await DbContext.UserProfileData - .AsNoTracking() - .SingleOrDefaultAsync(p => p.UserUID == userUid, cancellationToken: RequestAbortedToken) - .ConfigureAwait(false); - - if (profileData is null) - { - return new UserProfileDto(userData, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: null, Tags: Array.Empty()); - } - - if (profileData.FlaggedForReport) - { - return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile is flagged for report and pending evaluation", Tags: Array.Empty()); - } - - if (profileData.ProfileDisabled) - { - return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile was permanently disabled", Tags: Array.Empty()); - } - - return profileData.ToDTO(); - } - private async Task ViewerAllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor) { if (descriptor.Type == ChatChannelType.Group) @@ -560,7 +607,11 @@ public partial class LightlessHub var (entry, expiry) = await TryGetBroadcastEntryAsync(presence.Participant.HashedCid!).ConfigureAwait(false); if (!IsActiveBroadcastForUser(entry, expiry, userUid)) { - _chatChannelService.RefreshLightfinderState(userUid, null, isLightfinder: false); + TryInvokeChatService( + () => _chatChannelService.RefreshLightfinderState(userUid, null, isLightfinder: false), + "refreshing lightfinder state", + descriptor, + userUid); return false; } @@ -580,7 +631,7 @@ public partial class LightlessHub if (!isBanned) return false; - _chatChannelService.RemovePresence(userUid); + TryInvokeChatService(() => _chatChannelService.RemovePresence(userUid), "clearing presence for chat-banned user", targetUserUid: userUid); await NotifyChatBanAsync(userUid).ConfigureAwait(false); return true; } diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs index bf1981f..d41211a 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs @@ -464,7 +464,9 @@ 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); + TryInvokeChatService( + () => _chatChannelService.RefreshLightfinderState(UserUID, hashedCid, isLightfinder: true), + "refreshing lightfinder state (enable)"); } else { @@ -505,7 +507,9 @@ public partial class LightlessHub } _logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID)); - _chatChannelService.RefreshLightfinderState(UserUID, null, isLightfinder: false); + TryInvokeChatService( + () => _chatChannelService.RefreshLightfinderState(UserUID, null, isLightfinder: false), + "refreshing lightfinder state (disable)"); } } diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs index ccfae97..99963d6 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs @@ -227,7 +227,7 @@ public partial class LightlessHub : Hub, ILightlessHub catch { } finally { - _chatChannelService.RemovePresence(UserUID); + TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "removing chat presence on disconnect"); _userConnections.Remove(UserUID, out _); } } diff --git a/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs b/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs index 39214dc..7d78c21 100644 --- a/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs +++ b/LightlessSyncServer/LightlessSyncServer/Models/ChatModels.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using LightlessSync.API.Data; +using LightlessSync.API.Data; using LightlessSync.API.Dto.Chat; namespace LightlessSyncServer.Models; @@ -32,7 +30,6 @@ public readonly record struct ChatMessageLogEntry( string MessageId, ChatChannelDescriptor Channel, DateTime SentAtUtc, - string SenderToken, string SenderUserUid, UserData? SenderUser, bool SenderIsLightfinder, diff --git a/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs index 4d51848..5157c23 100644 --- a/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs +++ b/LightlessSyncServer/LightlessSyncServer/Models/ChatZoneDefinitions.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using LightlessSync.API.Dto.Chat; +using LightlessSync.API.Dto.Chat; namespace LightlessSyncServer.Models; @@ -62,159 +61,5 @@ internal static class ChatZoneDefinitions TerritoryIds: TerritoryRegistry.GetIds( "Ul'dah - Steps of Nald", "Ul'dah - Steps of Thal")), - new ZoneChannelDefinition( - Key: "ishgard", - DisplayName: "Ishgard", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "ishgard" - }, - TerritoryNames: new[] - { - "Foundation", - "The Pillars" - }, - TerritoryIds: TerritoryRegistry.GetIds( - "Foundation", - "The Pillars")), - new ZoneChannelDefinition( - Key: "kugane", - DisplayName: "Kugane", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "kugane" - }, - TerritoryNames: new[] - { - "Kugane" - }, - TerritoryIds: TerritoryRegistry.GetIds("Kugane")), - new ZoneChannelDefinition( - Key: "crystarium", - DisplayName: "The Crystarium", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "crystarium" - }, - TerritoryNames: new[] - { - "The Crystarium" - }, - TerritoryIds: TerritoryRegistry.GetIds("The Crystarium")), - new ZoneChannelDefinition( - Key: "oldsharlayan", - DisplayName: "Old Sharlayan", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "oldsharlayan" - }, - TerritoryNames: new[] - { - "Old Sharlayan" - }, - TerritoryIds: TerritoryRegistry.GetIds("Old Sharlayan")), - new ZoneChannelDefinition( - Key: "tuliyollal", - DisplayName: "Tuliyollal", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "tuliyollal" - }, - TerritoryNames: new[] - { - "Tuliyollal" - }, - TerritoryIds: TerritoryRegistry.GetIds("Tuliyollal")), - new ZoneChannelDefinition( - Key: "eulmore", - DisplayName: "Eulmore", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "eulmore" - }, - TerritoryNames: new[] - { - "Eulmore" - }, - TerritoryIds: TerritoryRegistry.GetIds("Eulmore")), - new ZoneChannelDefinition( - Key: "idyllshire", - DisplayName: "Idyllshire", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "idyllshire" - }, - TerritoryNames: new[] - { - "Idyllshire" - }, - TerritoryIds: TerritoryRegistry.GetIds("Idyllshire")), - new ZoneChannelDefinition( - Key: "rhalgrsreach", - DisplayName: "Rhalgr's Reach", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "rhalgrsreach" - }, - TerritoryNames: new[] - { - "Rhalgr's Reach" - }, - TerritoryIds: TerritoryRegistry.GetIds("Rhalgr's Reach")), - new ZoneChannelDefinition( - Key: "radzathan", - DisplayName: "Radz-at-Han", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "radzathan" - }, - TerritoryNames: new[] - { - "Radz-at-Han" - }, - TerritoryIds: TerritoryRegistry.GetIds("Radz-at-Han")), - new ZoneChannelDefinition( - Key: "solutionnine", - DisplayName: "Solution Nine", - Descriptor: new ChatChannelDescriptor - { - Type = ChatChannelType.Zone, - WorldId = 0, - ZoneId = 0, - CustomKey = "solutionnine" - }, - TerritoryNames: new[] - { - "Solution Nine" - }, - TerritoryIds: TerritoryRegistry.GetIds("Solution Nine")) }; - } diff --git a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs index 9d4990b..830f03d 100644 --- a/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs +++ b/LightlessSyncServer/LightlessSyncServer/Services/ChatChannelService.cs @@ -14,20 +14,18 @@ public sealed class ChatChannelService : IDisposable private readonly Dictionary> _membersByChannel = new(); private readonly Dictionary> _presenceByUser = new(StringComparer.Ordinal); private readonly Dictionary> _participantsByChannel = new(); - private readonly Dictionary> _inactiveParticipantsByChannel = new(); private readonly Dictionary> _messagesByChannel = new(); private readonly Dictionary Node)> _messageIndex = new(StringComparer.Ordinal); + private readonly Dictionary>> _mutedTokensByUser = new(StringComparer.Ordinal); + private readonly Dictionary>> _mutedUidsByUser = new(StringComparer.Ordinal); private readonly object _syncRoot = new(); 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; + internal const int MaxMutedParticipantsPerChannel = 8; public ChatChannelService(ILogger logger, IOptions? zoneOverrides = null) { _logger = logger; _zoneDefinitions = BuildZoneDefinitions(zoneOverrides?.Value); - _inactiveParticipantCleanupTimer = new Timer(_ => CleanupExpiredInactiveParticipants(), null, InactiveParticipantCleanupInterval, InactiveParticipantCleanupInterval); } private Dictionary BuildZoneDefinitions(ChatZoneOverridesOptions? overrides) @@ -275,7 +273,6 @@ public sealed class ChatChannelService : IDisposable messageId, channel, sentAtUtc, - participant.Token, participant.UserUid, participant.User, participant.IsLightfinder, @@ -381,39 +378,6 @@ public sealed class ChatChannelService : IDisposable } } - public bool TryResolveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant) - { - var key = ChannelKey.FromDescriptor(channel); - - lock (_syncRoot) - { - if (_participantsByChannel.TryGetValue(key, out var participants) && - participants.TryGetValue(token, out participant)) - { - 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; - return false; - } - public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder) { ArgumentException.ThrowIfNullOrEmpty(userUid); @@ -549,7 +513,8 @@ public sealed class ChatChannelService : IDisposable } participantsByToken[token] = finalParticipant; - RemoveInactiveParticipantLocked(key, token); + + ApplyUIDMuteIfPresent(normalizedDescriptor, finalParticipant); _logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key)); return entryToStore; @@ -584,74 +549,110 @@ public sealed class ChatChannelService : IDisposable } } - StoreInactiveParticipantLocked(key, existing.Participant); + ClearMutesForChannel(userUid, key); return true; } - private void StoreInactiveParticipantLocked(ChannelKey key, ChatParticipantInfo participant) + internal bool TryGetActiveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant) { - if (string.IsNullOrEmpty(participant.Token)) - { - return; - } + var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey()); - if (!_inactiveParticipantsByChannel.TryGetValue(key, out var inactive)) - { - inactive = new Dictionary(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) + if (_participantsByChannel.TryGetValue(key, out var participants) && + participants.TryGetValue(token, out participant)) { - return; + return true; + } + } + + participant = default; + return false; + } + + internal bool IsTokenMuted(string userUid, ChatChannelDescriptor channel, string token) + { + var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey()); + + lock (_syncRoot) + { + if (!_mutedTokensByUser.TryGetValue(userUid, out var channels) || + !channels.TryGetValue(key, out var tokens)) + { + return false; } - var now = DateTime.UtcNow; - var channelsToRemove = new List(); - - foreach (var (key, inactive) in _inactiveParticipantsByChannel) + if (tokens.Contains(token)) { - var tokensToRemove = new List(); - foreach (var (token, entry) in inactive) + return true; + } + + if (_participantsByChannel.TryGetValue(key, out var participants) && + participants.TryGetValue(token, out var participant)) + { + return IsUIDMutedLocked(userUid, key, participant.UserUid); + } + + return false; + } + } + + public ChatMuteUpdateResult SetMutedParticipant(string userUid, ChatChannelDescriptor channel, ChatParticipantInfo participant, bool mute) + { + ArgumentException.ThrowIfNullOrEmpty(userUid); + ArgumentException.ThrowIfNullOrEmpty(participant.Token); + ArgumentException.ThrowIfNullOrEmpty(participant.UserUid); + + var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey()); + + lock (_syncRoot) + { + if (!_mutedTokensByUser.TryGetValue(userUid, out var channels)) + { + if (!mute) { - if (entry.ExpiresAt <= now) - { - tokensToRemove.Add(token); - } + return ChatMuteUpdateResult.NoChange; } - foreach (var token in tokensToRemove) + channels = new Dictionary>(); + _mutedTokensByUser[userUid] = channels; + } + + if (!channels.TryGetValue(key, out var tokens)) + { + if (!mute) { - inactive.Remove(token); + return ChatMuteUpdateResult.NoChange; } - if (inactive.Count == 0) + tokens = new HashSet(StringComparer.Ordinal); + channels[key] = tokens; + } + + if (mute) + { + if (!tokens.Contains(participant.Token) && tokens.Count >= MaxMutedParticipantsPerChannel) { - channelsToRemove.Add(key); + return ChatMuteUpdateResult.ChannelLimitReached; + } + + var added = tokens.Add(participant.Token); + EnsureUIDMuteLocked(userUid, key, participant.UserUid); + return added ? ChatMuteUpdateResult.Changed : ChatMuteUpdateResult.NoChange; + } + + var removed = tokens.Remove(participant.Token); + if (tokens.Count == 0) + { + channels.Remove(key); + if (channels.Count == 0) + { + _mutedTokensByUser.Remove(userUid); } } - foreach (var channel in channelsToRemove) - { - _inactiveParticipantsByChannel.Remove(channel); - } + RemoveUIDMuteLocked(userUid, key, participant.UserUid); + return removed ? ChatMuteUpdateResult.Changed : ChatMuteUpdateResult.NoChange; } } @@ -665,10 +666,102 @@ public sealed class ChatChannelService : IDisposable private static string Describe(ChannelKey key) => $"{key.Type}:{key.WorldId}:{key.CustomKey}"; - public void Dispose() + private void ClearMutesForChannel(string userUid, ChannelKey key) { - _inactiveParticipantCleanupTimer.Dispose(); + if (_mutedTokensByUser.TryGetValue(userUid, out var tokenChannels) && + tokenChannels.Remove(key) && + tokenChannels.Count == 0) + { + _mutedTokensByUser.Remove(userUid); + } + + if (_mutedUidsByUser.TryGetValue(userUid, out var uidChannels) && + uidChannels.Remove(key) && + uidChannels.Count == 0) + { + _mutedUidsByUser.Remove(userUid); + } } - private readonly record struct InactiveParticipantEntry(ChatParticipantInfo Participant, DateTime ExpiresAt); + private void ApplyUIDMuteIfPresent(ChatChannelDescriptor descriptor, ChatParticipantInfo participant) + { + var key = ChannelKey.FromDescriptor(descriptor); + foreach (var kvp in _mutedUidsByUser) + { + var muter = kvp.Key; + var channels = kvp.Value; + if (!channels.TryGetValue(key, out var mutedUids) || !mutedUids.Contains(participant.UserUid)) + { + continue; + } + + if (!_mutedTokensByUser.TryGetValue(muter, out var tokenChannels)) + { + tokenChannels = new Dictionary>(); + _mutedTokensByUser[muter] = tokenChannels; + } + + if (!tokenChannels.TryGetValue(key, out var tokens)) + { + tokens = new HashSet(StringComparer.Ordinal); + tokenChannels[key] = tokens; + } + + tokens.Add(participant.Token); + } + } + + private void EnsureUIDMuteLocked(string userUid, ChannelKey key, string targetUid) + { + if (!_mutedUidsByUser.TryGetValue(userUid, out var channels)) + { + channels = new Dictionary>(); + _mutedUidsByUser[userUid] = channels; + } + + if (!channels.TryGetValue(key, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + channels[key] = set; + } + + set.Add(targetUid); + } + + private void RemoveUIDMuteLocked(string userUid, ChannelKey key, string targetUid) + { + if (!_mutedUidsByUser.TryGetValue(userUid, out var channels) || + !channels.TryGetValue(key, out var set)) + { + return; + } + + set.Remove(targetUid); + if (set.Count == 0) + { + channels.Remove(key); + if (channels.Count == 0) + { + _mutedUidsByUser.Remove(userUid); + } + } + } + + private bool IsUIDMutedLocked(string userUid, ChannelKey key, string targetUid) + { + return _mutedUidsByUser.TryGetValue(userUid, out var channels) && + channels.TryGetValue(key, out var set) && + set.Contains(targetUid); + } + + public void Dispose() + { + } +} + +public enum ChatMuteUpdateResult +{ + NoChange, + Changed, + ChannelLimitReached } diff --git a/LightlessSyncServer/LightlessSyncServer/Utils/ChatMessageFilter.cs b/LightlessSyncServer/LightlessSyncServer/Utils/ChatMessageFilter.cs new file mode 100644 index 0000000..c732894 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Utils/ChatMessageFilter.cs @@ -0,0 +1,26 @@ +using System; +using System.Text.RegularExpressions; + +namespace LightlessSyncServer.Utils; + +internal static class ChatMessageFilter +{ + private static readonly Regex UrlRegex = new(@"\b(?:https?://|www\.)\S+", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static bool TryValidate(string? message, out string rejectionReason) + { + rejectionReason = string.Empty; + if (string.IsNullOrWhiteSpace(message)) + { + return true; + } + + if (UrlRegex.IsMatch(message)) + { + rejectionReason = "Links are not permitted in chat."; + return false; + } + + return true; + } +} diff --git a/LightlessSyncServer/LightlessSyncServer/Utils/LightlessHubLogger.cs b/LightlessSyncServer/LightlessSyncServer/Utils/LightlessHubLogger.cs index bb73bfa..fa4b498 100644 --- a/LightlessSyncServer/LightlessSyncServer/Utils/LightlessHubLogger.cs +++ b/LightlessSyncServer/LightlessSyncServer/Utils/LightlessHubLogger.cs @@ -30,4 +30,14 @@ public class LightlessHubLogger string formattedArgs = args != null && args.Length != 0 ? "|" + string.Join(":", args) : string.Empty; _logger.LogWarning("{uid}:{method}{args}", _hub.UserUID, methodName, formattedArgs); } + + public void LogError(Exception exception, string message, params object[] args) + { + _logger.LogError(exception, message, args); + } + + public void LogError(string message, params object[] args) + { + _logger.LogError(message, args); + } } diff --git a/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs b/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs index f979294..eb75a42 100644 --- a/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs +++ b/LightlessSyncServer/LightlessSyncShared/Models/ReportedChatMessage.cs @@ -40,9 +40,6 @@ public class ReportedChatMessage [Required] public string MessageContent { get; set; } = string.Empty; - [Required] - public string SenderToken { get; set; } = string.Empty; - public string? SenderHashedCid { get; set; } public string? SenderDisplayName { get; set; }