Files
LightlessServer/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Chat.cs

698 lines
27 KiB
C#

using System.Collections.Concurrent;
using System.Text.Json;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Chat;
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
{
private const int MaxChatMessageLength = 200;
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
};
[Authorize(Policy = "Identified")]
public Task<IReadOnlyList<ZoneChatChannelInfoDto>> GetZoneChatChannels()
{
return Task.FromResult(_chatChannelService.GetZoneChannelInfos());
}
[Authorize(Policy = "Identified")]
public async Task<IReadOnlyList<GroupChatChannelInfoDto>> GetGroupChatChannels()
{
var userUid = UserUID;
var groupInfos = await DbContext.Groups
.AsNoTracking()
.Where(g => g.ChatEnabled
&& (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)
{
TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "clearing presence for banned user");
await NotifyChatBanAsync(UserUID).ConfigureAwait(false);
return;
}
if (!presence.IsActive)
{
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;
}
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;
}
}
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:
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.");
}
if (!group.ChatEnabled)
{
TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID, channel), "removing chat presence", channel);
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "This Syncshell chat is disabled.").ConfigureAwait(false);
return;
}
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;
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:
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 (channel.Type == ChatChannelType.Group)
{
var groupId = channel.CustomKey ?? string.Empty;
var chatEnabled = !string.IsNullOrEmpty(groupId) && await DbContext.Groups
.AsNoTracking()
.Where(g => g.GID == groupId)
.Select(g => g.ChatEnabled)
.SingleOrDefaultAsync(RequestAbortedToken)
.ConfigureAwait(false);
if (!chatEnabled)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "This Syncshell chat is disabled.").ConfigureAwait(false);
return;
}
}
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];
}
if (channel.Type == ChatChannelType.Zone &&
!ChatMessageFilter.TryValidate(sanitizedMessage, out var rejectionReason))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, rejectionReason).ConfigureAwait(false);
return;
}
try
{
var recipients = _chatChannelService.GetMembers(presence.Channel);
var recipientsList = recipients.ToList();
if (recipientsList.Count == 0)
{
return;
}
var bannedRecipients = recipientsList.Count == 0
? new List<string>()
: await DbContext.Users.AsNoTracking()
.Where(u => recipientsList.Contains(u.UID) && u.ChatBanned)
.Select(u => u.UID)
.ToListAsync(RequestAbortedToken)
.ConfigureAwait(false);
HashSet<string>? bannedSet = null;
if (bannedRecipients.Count > 0)
{
bannedSet = new HashSet<string>(bannedRecipients, StringComparer.Ordinal);
foreach (var bannedUid in bannedSet)
{
_chatChannelService.RemovePresence(bannedUid);
await NotifyChatBanAsync(bannedUid).ConfigureAwait(false);
}
}
var deliveryTargets = new Dictionary<string, (string Uid, bool IncludeSensitive)>(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
{
_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<Task>(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)
{
_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);
}
}
[Authorize(Policy = "Identified")]
public async Task ReportChatMessage(ChatReportSubmitDto request)
{
var channel = request.Channel.WithNormalizedCustomKey();
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,
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);
}
[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
? 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 = includeSensitiveInfo && (kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder);
return new ChatSenderDescriptor(
kind,
participant.Token,
displayName,
hashedCid,
descriptor.Type == ChatChannelType.Group ? participant.User : null,
canResolveProfile);
}
private async Task<bool> 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<bool> 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))
{
TryInvokeChatService(
() => _chatChannelService.RefreshLightfinderState(userUid, null, isLightfinder: false),
"refreshing lightfinder state",
descriptor,
userUid);
return false;
}
return true;
}
return false;
}
private async Task<bool> HandleIfChatBannedAsync(string userUid)
{
var isBanned = await DbContext.Users
.AsNoTracking()
.AnyAsync(u => u.UID == userUid && u.ChatBanned, RequestAbortedToken)
.ConfigureAwait(false);
if (!isBanned)
return false;
TryInvokeChatService(() => _chatChannelService.RemovePresence(userUid), "clearing presence for chat-banned user", targetUserUid: 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;
}
}
}