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.Utils; using LightlessSyncShared.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; namespace LightlessSyncServer.Hubs; public partial class LightlessHub { private const int MaxChatMessageLength = 200; 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 }; [Authorize(Policy = "Identified")] public Task> GetZoneChatChannels() { return Task.FromResult(_chatChannelService.GetZoneChannelInfos()); } [Authorize(Policy = "Identified")] public async Task> GetGroupChatChannels() { var userUid = UserUID; var groupInfos = await DbContext.Groups .AsNoTracking() .Where(g => g.OwnerUID == userUid || DbContext.GroupPairs.Any(p => p.GroupGID == g.GID && p.GroupUserUID == userUid)) .ToListAsync() .ConfigureAwait(false); return groupInfos .Select(g => { var displayName = string.IsNullOrWhiteSpace(g.Alias) ? g.GID : g.Alias!; var descriptor = new ChatChannelDescriptor { Type = ChatChannelType.Group, WorldId = 0, ZoneId = 0, CustomKey = g.GID }; return new GroupChatChannelInfoDto( descriptor, displayName, g.GID, string.Equals(g.OwnerUID, userUid, StringComparison.Ordinal)); }) .OrderBy(info => info.DisplayName, StringComparer.OrdinalIgnoreCase) .ToList(); } [Authorize(Policy = "Identified")] public async Task UpdateChatPresence(ChatPresenceUpdateDto presence) { var channel = presence.Channel.WithNormalizedCustomKey(); var userRecord = await DbContext.Users .AsNoTracking() .SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken) .ConfigureAwait(false); if (userRecord.ChatBanned) { _chatChannelService.RemovePresence(UserUID); await NotifyChatBanAsync(UserUID).ConfigureAwait(false); return; } if (!presence.IsActive) { _chatChannelService.RemovePresence(UserUID, channel); return; } switch (channel.Type) { case ChatChannelType.Zone: if (!_chatChannelService.TryResolveZone(channel.CustomKey, out var definition)) { throw new HubException("Unsupported chat channel."); } if (channel.WorldId == 0 || !WorldRegistry.IsKnownWorld(channel.WorldId)) { throw new HubException("Unsupported chat channel."); } if (presence.TerritoryId == 0 || !definition.TerritoryIds.Contains(presence.TerritoryId)) { throw new HubException("Zone chat is only available in supported territories."); } string? hashedCid = null; var isLightfinder = false; if (IsValidHashedCid(UserCharaIdent)) { var (entry, expiry) = await TryGetBroadcastEntryAsync(UserCharaIdent).ConfigureAwait(false); isLightfinder = HasActiveBroadcast(entry, expiry); if (isLightfinder) { hashedCid = UserCharaIdent; } } _chatChannelService.UpdateZonePresence( UserUID, definition, channel.WorldId, presence.TerritoryId, hashedCid, isLightfinder, isActive: true); break; case ChatChannelType.Group: var groupKey = channel.CustomKey ?? string.Empty; if (string.IsNullOrEmpty(groupKey)) { throw new HubException("Unsupported chat channel."); } var userData = userRecord.ToUserData(); var group = await DbContext.Groups .AsNoTracking() .SingleOrDefaultAsync(g => g.GID == groupKey, cancellationToken: RequestAbortedToken) .ConfigureAwait(false); if (group is null) { throw new HubException("Unsupported chat channel."); } var isMember = string.Equals(group.OwnerUID, UserUID, StringComparison.Ordinal) || await DbContext.GroupPairs .AsNoTracking() .AnyAsync(gp => gp.GroupGID == groupKey && gp.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken) .ConfigureAwait(false); if (!isMember) { throw new HubException("Join the syncshell before using chat."); } var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias; _chatChannelService.UpdateGroupPresence( UserUID, group.GID, displayName, userData, IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null, isActive: true); break; default: throw new HubException("Unsupported chat channel."); } } [Authorize(Policy = "Identified")] public async Task SendChatMessage(ChatSendRequestDto request) { if (string.IsNullOrWhiteSpace(request.Message)) { throw new HubException("Message cannot be empty."); } var channel = request.Channel.WithNormalizedCustomKey(); if (await HandleIfChatBannedAsync(UserUID).ConfigureAwait(false)) { throw new HubException("Chat access has been revoked."); } if (!_chatChannelService.TryGetPresence(UserUID, channel, out var presence)) { 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) { sanitizedMessage = sanitizedMessage[..MaxChatMessageLength]; } var recipients = _chatChannelService.GetMembers(presence.Channel); var recipientsList = recipients.ToList(); if (recipientsList.Count == 0) { 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) { bannedSet = new HashSet(bannedRecipients, StringComparer.Ordinal); foreach (var bannedUid in bannedSet) { _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)) { 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 { _chatChannelService.RemovePresence(uid); } } if (deliveryTargets.Count == 0) { return; } 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")] public async Task ReportChatMessage(ChatReportSubmitDto request) { var channel = request.Channel.WithNormalizedCustomKey(); if (!_chatChannelService.TryGetPresence(UserUID, channel, out _)) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Join the chat channel before reporting messages.").ConfigureAwait(false); return; } if (!_chatChannelService.TryGetMessage(request.MessageId, out var messageEntry)) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to locate the reported message. It may have already expired.").ConfigureAwait(false); return; } var requestedChannelKey = ChannelKey.FromDescriptor(channel); var messageChannelKey = ChannelKey.FromDescriptor(messageEntry.Channel.WithNormalizedCustomKey()); if (!requestedChannelKey.Equals(messageChannelKey)) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "The reported message no longer matches this channel.").ConfigureAwait(false); return; } if (string.Equals(messageEntry.SenderUserUid, UserUID, StringComparison.Ordinal)) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You cannot report your own message.").ConfigureAwait(false); return; } var reason = request.Reason?.Trim(); if (string.IsNullOrWhiteSpace(reason)) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Please provide a short explanation for the report.").ConfigureAwait(false); return; } const int MaxReasonLength = 500; if (reason.Length > MaxReasonLength) { reason = reason[..MaxReasonLength]; } var additionalContext = string.IsNullOrWhiteSpace(request.AdditionalContext) ? null : request.AdditionalContext.Trim(); const int MaxContextLength = 1000; if (!string.IsNullOrEmpty(additionalContext) && additionalContext.Length > MaxContextLength) { additionalContext = additionalContext[..MaxContextLength]; } var alreadyReported = await DbContext.ReportedChatMessages .AsNoTracking() .AnyAsync(r => r.MessageId == request.MessageId && r.ReporterUserUid == UserUID && !r.Resolved, cancellationToken: RequestAbortedToken) .ConfigureAwait(false); if (alreadyReported) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You already reported this message and it is pending review.").ConfigureAwait(false); return; } var oneHourAgo = DateTime.UtcNow - TimeSpan.FromHours(1); var reportRateLimited = await DbContext.ReportedChatMessages .AsNoTracking() .AnyAsync(r => r.ReporterUserUid == UserUID && r.ReportTimeUtc >= oneHourAgo, cancellationToken: RequestAbortedToken) .ConfigureAwait(false); if (reportRateLimited) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can file at most one chat report per hour.").ConfigureAwait(false); return; } /* if (!string.IsNullOrEmpty(messageEntry.SenderUserUid)) { var targetAlreadyPending = await DbContext.ReportedChatMessages .AsNoTracking() .AnyAsync(r => r.ReportedUserUid == messageEntry.SenderUserUid && !r.Resolved, cancellationToken: RequestAbortedToken) .ConfigureAwait(false); if (targetAlreadyPending) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "This user already has a report pending review.").ConfigureAwait(false); return; } } */ var snapshotEntries = _chatChannelService.GetRecentMessages(messageEntry.Channel, 25); var snapshotItems = snapshotEntries .Select(e => new ChatReportSnapshotItem( e.MessageId, e.SentAtUtc, e.SenderUserUid, e.SenderUser?.AliasOrUID, e.SenderIsLightfinder, e.SenderHashedCid, e.Message)) .ToArray(); var snapshotJson = JsonSerializer.Serialize(snapshotItems, ChatReportSnapshotSerializerOptions); var report = new ReportedChatMessage { ReportTimeUtc = DateTime.UtcNow, ReporterUserUid = UserUID, ReportedUserUid = messageEntry.SenderUserUid, ChannelType = messageEntry.Channel.Type, WorldId = messageEntry.Channel.WorldId, ZoneId = messageEntry.Channel.ZoneId, ChannelKey = messageEntry.Channel.CustomKey ?? string.Empty, MessageId = messageEntry.MessageId, MessageSentAtUtc = messageEntry.SentAtUtc, MessageContent = messageEntry.Message, SenderToken = messageEntry.SenderToken, SenderHashedCid = messageEntry.SenderHashedCid, SenderDisplayName = messageEntry.SenderUser?.AliasOrUID, SenderWasLightfinder = messageEntry.SenderIsLightfinder, SnapshotJson = snapshotJson, Reason = reason, AdditionalContext = additionalContext }; DbContext.ReportedChatMessages.Add(report); await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Thank you. Your report has been queued for moderator review.").ConfigureAwait(false); } private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatParticipantInfo participant, bool includeSensitiveInfo = false) { var kind = descriptor.Type == ChatChannelType.Group ? ChatSenderKind.IdentifiedUser : ChatSenderKind.Anonymous; string? displayName; if (kind == ChatSenderKind.IdentifiedUser) { displayName = participant.User?.Alias ?? participant.User?.UID ?? participant.UserUid; } else if (includeSensitiveInfo && participant.IsLightfinder && !string.IsNullOrEmpty(participant.HashedCid)) { displayName = participant.HashedCid; } else { var source = participant.UserUid ?? string.Empty; var suffix = source.Length >= 4 ? source[^4..] : source; displayName = string.IsNullOrEmpty(suffix) ? "Anonymous" : $"Anon-{suffix}"; } var hashedCid = includeSensitiveInfo && participant.IsLightfinder ? participant.HashedCid : null; var canResolveProfile = kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder; return new ChatSenderDescriptor( kind, participant.Token, displayName, hashedCid, descriptor.Type == ChatChannelType.Group ? participant.User : null, 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) { return true; } var viewerCid = UserCharaIdent; if (!IsValidHashedCid(viewerCid)) { return false; } var (entry, expiry) = await TryGetBroadcastEntryAsync(viewerCid).ConfigureAwait(false); return HasActiveBroadcast(entry, expiry); } private async Task AllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor, string userUid) { if (descriptor.Type == ChatChannelType.Group) { return true; } if (_chatChannelService.TryGetPresence(userUid, descriptor, out var presence)) { 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; } private async Task HandleIfChatBannedAsync(string userUid) { var isBanned = await DbContext.Users .AsNoTracking() .AnyAsync(u => u.UID == userUid && u.ChatBanned, RequestAbortedToken) .ConfigureAwait(false); if (!isBanned) return false; _chatChannelService.RemovePresence(userUid); await NotifyChatBanAsync(userUid).ConfigureAwait(false); return true; } private async Task NotifyChatBanAsync(string userUid) { if (string.Equals(userUid, UserUID, StringComparison.Ordinal)) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your chat access has been revoked.").ConfigureAwait(false); } else if (_userConnections.TryGetValue(userUid, out var connectionId)) { 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; } } }