698 lines
27 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|