Compare commits

...

21 Commits

Author SHA1 Message Date
cake
d1526fa49e Done same for user 2025-12-22 21:20:32 +01:00
cake
8515364ffd Removed code that would prevent changes on disabled profile. 2025-12-22 15:46:52 +01:00
76bdbe28da Merge pull request 'revert ce9b94a534c1239b4b89c4ccf05354c477da49d9' (#44) from defnotken-patch-1 into master
Reviewed-on: #44
2025-12-22 02:27:14 +00:00
f4753505aa revert ce9b94a534
revert Merge pull request 'add capability to disable chat feature for groups/syncshells' (#43) from disable-chat-groups into master

Reviewed-on: #43
2025-12-22 02:25:40 +00:00
ce9b94a534 Merge pull request 'add capability to disable chat feature for groups/syncshells' (#43) from disable-chat-groups into master
Reviewed-on: #43
2025-12-22 02:18:00 +00:00
Abelfreyja
e58210e770 add capability to disable chat feature for groups/syncshells 2025-12-22 10:24:34 +09:00
6d49389bd4 Merge pull request 'bump-api' (#42) from bump-api into master
Reviewed-on: #42
2025-12-19 15:38:53 +00:00
defnotken
9a964b4933 Merge branch 'bump-api' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into bump-api 2025-12-19 09:37:49 -06:00
defnotken
2ca0fe0697 bumping tha api 2025-12-19 09:37:15 -06:00
10bcf5dd1f Merge pull request 'migration-fix' (#41) from migration-fix into master
Reviewed-on: #41
2025-12-19 12:04:12 +00:00
defnotken
dda22594f1 db migration add for chat changes 2025-12-19 05:52:52 -06:00
defnotken
802077371b fixed db migration for chat 2025-12-19 05:52:00 -06:00
6321f385da Discord-bot + server updates (#39)
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #39
Co-authored-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Co-committed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-19 10:38:51 +00:00
cake
7cd463d4b7 bump api server 2025-12-19 07:12:09 +01:00
dda45e8e73 Merge pull request 'Chat adjustment for release' (#38) from chat-adjustments into master
Reviewed-on: #38
2025-12-18 21:19:55 +00:00
cake
7f01cc3661 bump api 2025-12-18 22:19:19 +01:00
azyges
67fe2a1f0f just chat improvements :sludge: 2025-12-17 03:46:23 +09:00
azyges
1734f0e32f Merge branch 'master' of https://git.lightless-sync.org/Lightless-Sync/LightlessServer into chat-adjustments 2025-12-17 00:14:40 +09:00
2fb41d0b9f Merge pull request 'Reduced content length of message, commented out the pending report.' (#37) from chat-changes into master
Reviewed-on: #37
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-16 12:00:08 +00:00
azyges
989d079601 chat zone config, add more default zones 2025-12-14 15:55:32 +09:00
azyges
03ba9493fc prevent report dodging by leaving channel, allow reports to work after leaving the channel too 2025-12-14 15:13:22 +09:00
25 changed files with 2048 additions and 282 deletions

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace LightlessSyncServer.Configuration;
public sealed class ChatZoneOverridesOptions
{
public List<ChatZoneOverride>? Zones { get; set; }
}
public sealed class ChatZoneOverride
{
public string Key { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public List<string>? TerritoryNames { get; set; }
public List<ushort>? TerritoryIds { get; set; }
}

View File

@@ -2,14 +2,13 @@ using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.User;
using LightlessSyncServer.Models; using LightlessSyncServer.Models;
using LightlessSyncServer.Services;
using LightlessSyncServer.Utils; using LightlessSyncServer.Utils;
using LightlessSyncShared.Models; using LightlessSyncShared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Hubs; namespace LightlessSyncServer.Hubs;
public partial class LightlessHub public partial class LightlessHub
@@ -81,14 +80,18 @@ public partial class LightlessHub
if (userRecord.ChatBanned) if (userRecord.ChatBanned)
{ {
_chatChannelService.RemovePresence(UserUID); TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "clearing presence for banned user");
await NotifyChatBanAsync(UserUID).ConfigureAwait(false); await NotifyChatBanAsync(UserUID).ConfigureAwait(false);
return; return;
} }
if (!presence.IsActive) 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; return;
} }
@@ -122,14 +125,22 @@ public partial class LightlessHub
} }
} }
_chatChannelService.UpdateZonePresence( if (!TryInvokeChatService(
() => _chatChannelService.UpdateZonePresence(
UserUID, UserUID,
definition, definition,
channel.WorldId, channel.WorldId,
presence.TerritoryId, presence.TerritoryId,
hashedCid, hashedCid,
isLightfinder, isLightfinder,
isActive: true); 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; break;
case ChatChannelType.Group: case ChatChannelType.Group:
@@ -164,13 +175,21 @@ public partial class LightlessHub
var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias; var displayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias;
_chatChannelService.UpdateGroupPresence( if (!TryInvokeChatService(
() => _chatChannelService.UpdateGroupPresence(
UserUID, UserUID,
group.GID, group.GID,
displayName, displayName,
userData, userData,
IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null, IsValidHashedCid(UserCharaIdent) ? UserCharaIdent : null,
isActive: true); 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; break;
default: default:
@@ -210,6 +229,15 @@ public partial class LightlessHub
sanitizedMessage = sanitizedMessage[..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 recipients = _chatChannelService.GetMembers(presence.Channel);
var recipientsList = recipients.ToList(); var recipientsList = recipients.ToList();
if (recipientsList.Count == 0) if (recipientsList.Count == 0)
@@ -246,6 +274,11 @@ public partial class LightlessHub
if (_userConnections.TryGetValue(uid, out var connectionId)) 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); var includeSensitive = await AllowsLightfinderDetailsAsync(presence.Channel, uid).ConfigureAwait(false);
if (deliveryTargets.TryGetValue(connectionId, out var existing)) if (deliveryTargets.TryGetValue(connectionId, out var existing))
{ {
@@ -286,43 +319,11 @@ public partial class LightlessHub
await Task.WhenAll(sendTasks).ConfigureAwait(false); await Task.WhenAll(sendTasks).ConfigureAwait(false);
} }
catch (Exception ex)
[Authorize(Policy = "Identified")]
public async Task<ChatParticipantResolveResultDto?> ResolveChatParticipant(ChatParticipantResolveRequestDto request)
{ {
var channel = request.Channel.WithNormalizedCustomKey(); _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);
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")] [Authorize(Policy = "Identified")]
@@ -330,12 +331,6 @@ public partial class LightlessHub
{ {
var channel = request.Channel.WithNormalizedCustomKey(); 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)) 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); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Unable to locate the reported message. It may have already expired.").ConfigureAwait(false);
@@ -442,7 +437,6 @@ public partial class LightlessHub
MessageId = messageEntry.MessageId, MessageId = messageEntry.MessageId,
MessageSentAtUtc = messageEntry.SentAtUtc, MessageSentAtUtc = messageEntry.SentAtUtc,
MessageContent = messageEntry.Message, MessageContent = messageEntry.Message,
SenderToken = messageEntry.SenderToken,
SenderHashedCid = messageEntry.SenderHashedCid, SenderHashedCid = messageEntry.SenderHashedCid,
SenderDisplayName = messageEntry.SenderUser?.AliasOrUID, SenderDisplayName = messageEntry.SenderUser?.AliasOrUID,
SenderWasLightfinder = messageEntry.SenderIsLightfinder, SenderWasLightfinder = messageEntry.SenderIsLightfinder,
@@ -457,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); 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) private ChatSenderDescriptor BuildSenderDescriptor(ChatChannelDescriptor descriptor, ChatParticipantInfo participant, bool includeSensitiveInfo = false)
{ {
var kind = descriptor.Type == ChatChannelType.Group var kind = descriptor.Type == ChatChannelType.Group
@@ -483,7 +562,7 @@ public partial class LightlessHub
? participant.HashedCid ? participant.HashedCid
: null; : null;
var canResolveProfile = kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder; var canResolveProfile = includeSensitiveInfo && (kind == ChatSenderKind.IdentifiedUser || participant.IsLightfinder);
return new ChatSenderDescriptor( return new ChatSenderDescriptor(
kind, kind,
@@ -494,44 +573,6 @@ public partial class LightlessHub
canResolveProfile); canResolveProfile);
} }
private async Task<UserProfileDto?> 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<int>());
}
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<int>());
}
if (profileData.ProfileDisabled)
{
return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile was permanently disabled", Tags: Array.Empty<int>());
}
return profileData.ToDTO();
}
private async Task<bool> ViewerAllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor) private async Task<bool> ViewerAllowsLightfinderDetailsAsync(ChatChannelDescriptor descriptor)
{ {
if (descriptor.Type == ChatChannelType.Group) if (descriptor.Type == ChatChannelType.Group)
@@ -566,7 +607,11 @@ public partial class LightlessHub
var (entry, expiry) = await TryGetBroadcastEntryAsync(presence.Participant.HashedCid!).ConfigureAwait(false); var (entry, expiry) = await TryGetBroadcastEntryAsync(presence.Participant.HashedCid!).ConfigureAwait(false);
if (!IsActiveBroadcastForUser(entry, expiry, userUid)) 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; return false;
} }
@@ -586,7 +631,7 @@ public partial class LightlessHub
if (!isBanned) if (!isBanned)
return false; return false;
_chatChannelService.RemovePresence(userUid); TryInvokeChatService(() => _chatChannelService.RemovePresence(userUid), "clearing presence for chat-banned user", targetUserUid: userUid);
await NotifyChatBanAsync(userUid).ConfigureAwait(false); await NotifyChatBanAsync(userUid).ConfigureAwait(false);
return true; return true;
} }

View File

@@ -152,7 +152,7 @@ public partial class LightlessHub
BroadcastRedisEntry? entry; BroadcastRedisEntry? entry;
try try
{ {
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(broadcastValue!); entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(broadcastValue.ToString()!);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -864,12 +864,6 @@ public partial class LightlessHub
{ {
groupProfileDb.Group ??= group; groupProfileDb.Group ??= group;
if (groupProfileDb?.ProfileDisabled ?? false)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false);
return;
}
groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage); groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage);
} }

View File

@@ -336,7 +336,7 @@ public partial class LightlessHub
BroadcastRedisEntry? entry; BroadcastRedisEntry? entry;
try try
{ {
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(broadcastValue.Value!); entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(broadcastValue.Value.ToString()!);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -438,7 +438,7 @@ public partial class LightlessHub
{ {
try try
{ {
existingEntry = JsonSerializer.Deserialize<BroadcastRedisEntry>(existingValue!); existingEntry = JsonSerializer.Deserialize<BroadcastRedisEntry>(existingValue.ToString()!);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -464,7 +464,9 @@ public partial class LightlessHub
await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast enabled", hashedCid, "GID", gid)); _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 else
{ {
@@ -475,7 +477,7 @@ public partial class LightlessHub
BroadcastRedisEntry? entry; BroadcastRedisEntry? entry;
try try
{ {
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(value!); entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(value.ToString()!);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -505,7 +507,9 @@ public partial class LightlessHub
} }
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID)); _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)");
} }
} }
@@ -532,7 +536,7 @@ public partial class LightlessHub
BroadcastRedisEntry? entry; BroadcastRedisEntry? entry;
try try
{ {
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(value.Value!); entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(value.Value.ToString()!);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -586,7 +590,7 @@ public partial class LightlessHub
BroadcastRedisEntry? entry; BroadcastRedisEntry? entry;
try try
{ {
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(result.Value!); entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(result.Value.ToString()!);
} }
catch catch
{ {
@@ -641,7 +645,7 @@ public partial class LightlessHub
BroadcastRedisEntry? entry; BroadcastRedisEntry? entry;
try try
{ {
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(value!); entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(value.ToString()!);
} }
catch catch
{ {
@@ -724,7 +728,7 @@ public partial class LightlessHub
try try
{ {
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(raw!); entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(raw.ToString()!);
if (entry is not null && !string.Equals(entry.HashedCID, cid, StringComparison.Ordinal)) if (entry is not null && !string.Equals(entry.HashedCID, cid, StringComparison.Ordinal))
{ {
_logger.LogCallWarning(LightlessHubLogger.Args("mismatched broadcast cid in batch", "Requested", cid, "EntryCID", entry.HashedCID)); _logger.LogCallWarning(LightlessHubLogger.Args("mismatched broadcast cid in batch", "Requested", cid, "EntryCID", entry.HashedCID));
@@ -1167,12 +1171,6 @@ public partial class LightlessHub
return; return;
} }
if (profileData.ProfileDisabled)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false);
return;
}
profileData.UpdateProfileFromDto(dto, profileResult.Base64Image, bannerResult.Base64Image); profileData.UpdateProfileFromDto(dto, profileResult.Base64Image, bannerResult.Base64Image);
} }
else else

View File

@@ -227,7 +227,7 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
catch { } catch { }
finally finally
{ {
_chatChannelService.RemovePresence(UserUID); TryInvokeChatService(() => _chatChannelService.RemovePresence(UserUID), "removing chat presence on disconnect");
_userConnections.Remove(UserUID, out _); _userConnections.Remove(UserUID, out _);
} }
} }

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<UserSecretsId>aspnet-LightlessSyncServer-BA82A12A-0B30-463C-801D-B7E81318CD50</UserSecretsId> <UserSecretsId>aspnet-LightlessSyncServer-BA82A12A-0B30-463C-801D-B7E81318CD50</UserSecretsId>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@@ -1,6 +1,4 @@
using System; using LightlessSync.API.Data;
using System.Collections.Generic;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Chat;
namespace LightlessSyncServer.Models; namespace LightlessSyncServer.Models;
@@ -32,7 +30,6 @@ public readonly record struct ChatMessageLogEntry(
string MessageId, string MessageId,
ChatChannelDescriptor Channel, ChatChannelDescriptor Channel,
DateTime SentAtUtc, DateTime SentAtUtc,
string SenderToken,
string SenderUserUid, string SenderUserUid,
UserData? SenderUser, UserData? SenderUser,
bool SenderIsLightfinder, bool SenderIsLightfinder,

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic; using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Dto.Chat;
namespace LightlessSyncServer.Models; namespace LightlessSyncServer.Models;
@@ -61,7 +60,6 @@ internal static class ChatZoneDefinitions
}, },
TerritoryIds: TerritoryRegistry.GetIds( TerritoryIds: TerritoryRegistry.GetIds(
"Ul'dah - Steps of Nald", "Ul'dah - Steps of Nald",
"Ul'dah - Steps of Thal")) "Ul'dah - Steps of Thal")),
}; };
} }

View File

@@ -1,15 +1,13 @@
using System; using System.Security.Cryptography;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Chat;
using LightlessSyncServer.Configuration;
using LightlessSyncServer.Models; using LightlessSyncServer.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options;
namespace LightlessSyncServer.Services; namespace LightlessSyncServer.Services;
public sealed class ChatChannelService public sealed class ChatChannelService : IDisposable
{ {
private readonly ILogger<ChatChannelService> _logger; private readonly ILogger<ChatChannelService> _logger;
private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions; private readonly Dictionary<string, ZoneChannelDefinition> _zoneDefinitions;
@@ -18,16 +16,137 @@ public sealed class ChatChannelService
private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new(); private readonly Dictionary<ChannelKey, Dictionary<string, ChatParticipantInfo>> _participantsByChannel = new();
private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new(); private readonly Dictionary<ChannelKey, LinkedList<ChatMessageLogEntry>> _messagesByChannel = new();
private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> Node)> _messageIndex = new(StringComparer.Ordinal); private readonly Dictionary<string, (ChannelKey Channel, LinkedListNode<ChatMessageLogEntry> Node)> _messageIndex = new(StringComparer.Ordinal);
private readonly Dictionary<string, Dictionary<ChannelKey, HashSet<string>>> _mutedTokensByUser = new(StringComparer.Ordinal);
private readonly Dictionary<string, Dictionary<ChannelKey, HashSet<string>>> _mutedUidsByUser = new(StringComparer.Ordinal);
private readonly object _syncRoot = new(); private readonly object _syncRoot = new();
private const int MaxMessagesPerChannel = 200; private const int MaxMessagesPerChannel = 200;
internal const int MaxMutedParticipantsPerChannel = 8;
public ChatChannelService(ILogger<ChatChannelService> logger) public ChatChannelService(ILogger<ChatChannelService> logger, IOptions<ChatZoneOverridesOptions>? zoneOverrides = null)
{ {
_logger = logger; _logger = logger;
_zoneDefinitions = ChatZoneDefinitions.Defaults _zoneDefinitions = BuildZoneDefinitions(zoneOverrides?.Value);
.ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase);
} }
private Dictionary<string, ZoneChannelDefinition> BuildZoneDefinitions(ChatZoneOverridesOptions? overrides)
{
var definitions = ChatZoneDefinitions.Defaults
.ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase);
if (overrides?.Zones is null || overrides.Zones.Count == 0)
{
return definitions;
}
foreach (var entry in overrides.Zones)
{
if (entry is null)
{
continue;
}
if (!TryCreateZoneDefinition(entry, out var definition))
{
continue;
}
definitions[definition.Key] = definition;
}
return definitions;
}
private bool TryCreateZoneDefinition(ChatZoneOverride entry, out ZoneChannelDefinition definition)
{
definition = default;
var key = NormalizeZoneKey(entry.Key);
if (string.IsNullOrEmpty(key))
{
_logger.LogWarning("Skipped chat zone override with missing key.");
return false;
}
var territoryIds = new HashSet<ushort>();
if (entry.TerritoryIds is not null)
{
foreach (var candidate in entry.TerritoryIds)
{
if (candidate > 0)
{
territoryIds.Add(candidate);
}
}
}
var territoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (entry.TerritoryNames is not null)
{
foreach (var name in entry.TerritoryNames)
{
if (string.IsNullOrWhiteSpace(name))
continue;
var trimmed = name.Trim();
territoryNames.Add(trimmed);
if (TerritoryRegistry.TryGetIds(trimmed, out var ids))
{
territoryIds.UnionWith(ids);
}
else
{
_logger.LogWarning("Chat zone override {Zone} references unknown territory '{Territory}'.", key, trimmed);
}
}
}
if (territoryIds.Count == 0)
{
_logger.LogWarning("Skipped chat zone override for {Zone}: no territory IDs resolved.", key);
return false;
}
if (territoryNames.Count == 0)
{
foreach (var territoryId in territoryIds)
{
if (TerritoryRegistry.ById.TryGetValue(territoryId, out var territory))
{
territoryNames.Add(territory.Name);
}
}
}
if (territoryNames.Count == 0)
{
territoryNames.Add("Territory");
}
var descriptor = new ChatChannelDescriptor
{
Type = ChatChannelType.Zone,
WorldId = 0,
ZoneId = 0,
CustomKey = key
};
var displayName = string.IsNullOrWhiteSpace(entry.DisplayName)
? key
: entry.DisplayName.Trim();
definition = new ZoneChannelDefinition(
key,
displayName,
descriptor,
territoryNames.ToArray(),
territoryIds);
return true;
}
private static string NormalizeZoneKey(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
public IReadOnlyList<ZoneChatChannelInfoDto> GetZoneChannelInfos() => public IReadOnlyList<ZoneChatChannelInfoDto> GetZoneChannelInfos() =>
_zoneDefinitions.Values _zoneDefinitions.Values
.Select(definition => new ZoneChatChannelInfoDto( .Select(definition => new ZoneChatChannelInfoDto(
@@ -154,7 +273,6 @@ public sealed class ChatChannelService
messageId, messageId,
channel, channel,
sentAtUtc, sentAtUtc,
participant.Token,
participant.UserUid, participant.UserUid,
participant.User, participant.User,
participant.IsLightfinder, participant.IsLightfinder,
@@ -260,23 +378,6 @@ public sealed class ChatChannelService
} }
} }
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;
}
}
participant = default;
return false;
}
public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder) public void RefreshLightfinderState(string userUid, string? hashedCid, bool isLightfinder)
{ {
ArgumentException.ThrowIfNullOrEmpty(userUid); ArgumentException.ThrowIfNullOrEmpty(userUid);
@@ -413,6 +514,8 @@ public sealed class ChatChannelService
participantsByToken[token] = finalParticipant; participantsByToken[token] = finalParticipant;
ApplyUIDMuteIfPresent(normalizedDescriptor, finalParticipant);
_logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key)); _logger.LogDebug("Chat presence updated for {User} in {Channel}", userUid, Describe(key));
return entryToStore; return entryToStore;
} }
@@ -446,9 +549,113 @@ public sealed class ChatChannelService
} }
} }
ClearMutesForChannel(userUid, key);
return true; return true;
} }
internal bool TryGetActiveParticipant(ChatChannelDescriptor channel, string token, out ChatParticipantInfo participant)
{
var key = ChannelKey.FromDescriptor(channel.WithNormalizedCustomKey());
lock (_syncRoot)
{
if (_participantsByChannel.TryGetValue(key, out var participants) &&
participants.TryGetValue(token, out participant))
{
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;
}
if (tokens.Contains(token))
{
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)
{
return ChatMuteUpdateResult.NoChange;
}
channels = new Dictionary<ChannelKey, HashSet<string>>();
_mutedTokensByUser[userUid] = channels;
}
if (!channels.TryGetValue(key, out var tokens))
{
if (!mute)
{
return ChatMuteUpdateResult.NoChange;
}
tokens = new HashSet<string>(StringComparer.Ordinal);
channels[key] = tokens;
}
if (mute)
{
if (!tokens.Contains(participant.Token) && tokens.Count >= MaxMutedParticipantsPerChannel)
{
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);
}
}
RemoveUIDMuteLocked(userUid, key, participant.UserUid);
return removed ? ChatMuteUpdateResult.Changed : ChatMuteUpdateResult.NoChange;
}
}
private static string GenerateToken() private static string GenerateToken()
{ {
Span<byte> buffer = stackalloc byte[8]; Span<byte> buffer = stackalloc byte[8];
@@ -458,4 +665,103 @@ public sealed class ChatChannelService
private static string Describe(ChannelKey key) private static string Describe(ChannelKey key)
=> $"{key.Type}:{key.WorldId}:{key.CustomKey}"; => $"{key.Type}:{key.WorldId}:{key.CustomKey}";
private void ClearMutesForChannel(string userUid, ChannelKey key)
{
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 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<ChannelKey, HashSet<string>>();
_mutedTokensByUser[muter] = tokenChannels;
}
if (!tokenChannels.TryGetValue(key, out var tokens))
{
tokens = new HashSet<string>(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<ChannelKey, HashSet<string>>();
_mutedUidsByUser[userUid] = channels;
}
if (!channels.TryGetValue(key, out var set))
{
set = new HashSet<string>(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
} }

View File

@@ -93,6 +93,7 @@ public class Startup
services.Configure<ServerConfiguration>(Configuration.GetRequiredSection("LightlessSync")); services.Configure<ServerConfiguration>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<LightlessConfigurationBase>(Configuration.GetRequiredSection("LightlessSync")); services.Configure<LightlessConfigurationBase>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<BroadcastOptions>(Configuration.GetSection("Broadcast")); services.Configure<BroadcastOptions>(Configuration.GetSection("Broadcast"));
services.Configure<ChatZoneOverridesOptions>(Configuration.GetSection("ChatZoneOverrides"));
services.AddSingleton<IBroadcastConfiguration, BroadcastConfiguration>(); services.AddSingleton<IBroadcastConfiguration, BroadcastConfiguration>();
services.AddSingleton<ServerTokenGenerator>(); services.AddSingleton<ServerTokenGenerator>();

View File

@@ -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;
}
}

View File

@@ -30,4 +30,14 @@ public class LightlessHubLogger
string formattedArgs = args != null && args.Length != 0 ? "|" + string.Join(":", args) : string.Empty; string formattedArgs = args != null && args.Length != 0 ? "|" + string.Join(":", args) : string.Empty;
_logger.LogWarning("{uid}:{method}{args}", _hub.UserUID, methodName, formattedArgs); _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);
}
} }

View File

@@ -41,6 +41,9 @@
"PairRequestRateLimit": 5, "PairRequestRateLimit": 5,
"PairRequestRateWindow": 60 "PairRequestRateWindow": 60
}, },
"ChatZoneOverrides": {
"Zones": []
},
"AllowedHosts": "*", "AllowedHosts": "*",
"Kestrel": { "Kestrel": {
"Endpoints": { "Endpoints": {

View File

@@ -1,9 +1,6 @@
using System.Collections.Generic;
using System;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Linq;
using Discord; using Discord;
using Discord.Interactions; using Discord.Interactions;
using Discord.Rest; using Discord.Rest;
@@ -204,7 +201,7 @@ internal class DiscordBot : IHostedService
var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false); var embed = await BuildChatReportEmbedAsync(dbContext, report, token).ConfigureAwait(false);
var components = new ComponentBuilder() var components = new ComponentBuilder()
.WithButton("Actioned", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger) .WithButton("Resolve", $"{ChatReportButtonPrefix}-resolve-{report.ReportId}", ButtonStyle.Danger)
.WithButton("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary) .WithButton("Dismiss", $"{ChatReportButtonPrefix}-dismiss-{report.ReportId}", ButtonStyle.Secondary)
.WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger); .WithButton("Ban From Chat", $"{ChatReportButtonPrefix}-banchat-{report.ReportId}", ButtonStyle.Danger);
@@ -395,7 +392,7 @@ internal class DiscordBot : IHostedService
switch (action) switch (action)
{ {
case "resolve": case "resolve":
resolutionLabel = "Actioned"; resolutionLabel = "Resolved";
break; break;
case "dismiss": case "dismiss":
resolutionLabel = "Dismissed"; resolutionLabel = "Dismissed";
@@ -431,7 +428,7 @@ internal class DiscordBot : IHostedService
string responseText = action switch string responseText = action switch
{ {
"resolve" => "actioned", "resolve" => "resolved",
"dismiss" => "dismissed", "dismiss" => "dismissed",
"banchat" => "chat access revoked", "banchat" => "chat access revoked",
_ => "processed" _ => "processed"
@@ -473,7 +470,7 @@ internal class DiscordBot : IHostedService
embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase)); embedBuilder.Fields.RemoveAll(f => string.Equals(f.Name, "Resolution", StringComparison.OrdinalIgnoreCase));
var resolutionText = action switch var resolutionText = action switch
{ {
"resolve" => "Actioned", "resolve" => "Resolved",
"dismiss" => "Dismissed", "dismiss" => "Dismissed",
"banchat" => "Chat access revoked", "banchat" => "Chat access revoked",
_ => "Processed" _ => "Processed"
@@ -872,16 +869,17 @@ internal class DiscordBot : IHostedService
string? SenderHashedCid, string? SenderHashedCid,
string Message); string Message);
private async Task UpdateStatusAsync(CancellationToken token) private async Task UpdateStatusAsync(CancellationToken cancellationToken)
{ {
while (!token.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
var endPoint = _connectionMultiplexer.GetEndPoints().First(); var endPoint = _connectionMultiplexer.GetEndPoints().First();
var onlineUsers = await _connectionMultiplexer.GetServer(endPoint).KeysAsync(pattern: "UID:*").CountAsync().ConfigureAwait(false); var keys = _connectionMultiplexer.GetServer(endPoint).KeysAsync(pattern: "UID:*");
var onlineUsers = await keys.CountAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Users online: " + onlineUsers); _logger.LogInformation("Users online: " + onlineUsers);
await _discordClient.SetActivityAsync(new Game("Lightless for " + onlineUsers + " Users")).ConfigureAwait(false); await _discordClient.SetActivityAsync(new Game("Lightless for " + onlineUsers + " Users")).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
} }
} }
} }

View File

@@ -43,33 +43,54 @@ public class LightlessModule : InteractionModuleBase
try try
{ {
EmbedBuilder eb = new();
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LightlessDbContext>(); var db = scope.ServiceProvider.GetRequiredService<LightlessDbContext>();
await using (db.ConfigureAwait(false)) await using (db.ConfigureAwait(false))
{ {
eb = await HandleUserInfo(eb, db, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid); var (mainEmbed, profileEmbed) = await HandleUserInfo(db, Context.User.Id, secondaryUid, discordUser?.Id ?? null, uid);
string uidToGet = await GetUserUID(db, secondaryUid, discordUser?.Id ?? null, uid).ConfigureAwait(false); string uidToGet = await GetUserUID(db, secondaryUid, discordUser?.Id ?? null, uid).ConfigureAwait(false);
var profileData = await GetUserProfileData(db, uidToGet).ConfigureAwait(false); var profileData = await GetUserProfileData(db, uidToGet).ConfigureAwait(false);
List<Embed> embeds = new() { mainEmbed };
if (profileEmbed != null)
{
embeds.Add(profileEmbed);
}
if (profileData != null) if (profileData != null)
{ {
byte[] profileImage = GetProfileImage(profileData); byte[] profileImage = GetProfileImage(profileData);
byte[] bannerImage = GetBannerImage(profileData); byte[] bannerImage = GetBannerImage(profileData);
using MemoryStream profileImgStream = new(profileImage); using MemoryStream profileImgStream = new(profileImage);
using MemoryStream bannerImgStream = new(bannerImage); using MemoryStream bannerImgStream = new(bannerImage);
eb.WithThumbnailUrl("attachment://profileimage.png");
eb.WithImageUrl("attachment://bannerimage.png"); var mainEmbedData = embeds[0];
var mainEmbedBuilder = new EmbedBuilder()
.WithTitle(mainEmbedData.Title)
.WithDescription(mainEmbedData.Description)
.WithThumbnailUrl("attachment://profileimage.png")
.WithImageUrl("attachment://bannerimage.png");
if (mainEmbedData.Fields != null)
{
foreach (var field in mainEmbedData.Fields)
{
mainEmbedBuilder.AddField(field.Name, field.Value, field.Inline);
}
}
embeds[0] = mainEmbedBuilder.Build();
await RespondWithFilesAsync( await RespondWithFilesAsync(
new[] { new FileAttachment(profileImgStream, "profileimage.png"), new FileAttachment(bannerImgStream, "bannerimage.png") }, new[] { new FileAttachment(profileImgStream, "profileimage.png"), new FileAttachment(bannerImgStream, "bannerimage.png") },
embeds: new[] { eb.Build() }, embeds: embeds.ToArray(),
ephemeral: true).ConfigureAwait(false); ephemeral: true).ConfigureAwait(false);
} }
else else
{ {
await RespondAsync( await RespondAsync(
embeds: new[] { eb.Build() }, embeds: embeds.ToArray(),
ephemeral: true).ConfigureAwait(false); ephemeral: true).ConfigureAwait(false);
} }
} }
@@ -449,7 +470,7 @@ public class LightlessModule : InteractionModuleBase
return embed.Build(); return embed.Build();
} }
private async Task<EmbedBuilder> HandleUserInfo(EmbedBuilder eb, LightlessDbContext db, ulong id, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null) private async Task<(Embed mainEmbed, Embed? profileEmbed)> HandleUserInfo(LightlessDbContext db, ulong id, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null)
{ {
bool showForSecondaryUser = secondaryUserUid != null; bool showForSecondaryUser = secondaryUserUid != null;
@@ -459,18 +480,20 @@ public class LightlessModule : InteractionModuleBase
if (primaryUser == null) if (primaryUser == null)
{ {
EmbedBuilder eb = new();
eb.WithTitle("No account"); eb.WithTitle("No account");
eb.WithDescription("No Lightless account was found associated to your Discord user"); eb.WithDescription("No Lightless account was found associated to your Discord user");
return eb; return (eb.Build(), null);
} }
bool isAdminCall = primaryUser.User.IsModerator || primaryUser.User.IsAdmin; bool isAdminCall = primaryUser.User.IsModerator || primaryUser.User.IsAdmin;
if ((optionalUser != null || uid != null) && !isAdminCall) if ((optionalUser != null || uid != null) && !isAdminCall)
{ {
EmbedBuilder eb = new();
eb.WithTitle("Unauthorized"); eb.WithTitle("Unauthorized");
eb.WithDescription("You are not authorized to view another users' information"); eb.WithDescription("You are not authorized to view another users' information");
return eb; return (eb.Build(), null);
} }
else if ((optionalUser != null || uid != null) && isAdminCall) else if ((optionalUser != null || uid != null) && isAdminCall)
{ {
@@ -486,9 +509,10 @@ public class LightlessModule : InteractionModuleBase
if (userInDb == null) if (userInDb == null)
{ {
EmbedBuilder eb = new();
eb.WithTitle("No account"); eb.WithTitle("No account");
eb.WithDescription("The Discord user has no valid Lightless account"); eb.WithDescription("The Discord user has no valid Lightless account");
return eb; return (eb.Build(), null);
} }
userToCheckForDiscordId = userInDb.DiscordId; userToCheckForDiscordId = userInDb.DiscordId;
@@ -501,9 +525,10 @@ public class LightlessModule : InteractionModuleBase
dbUser = (await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.PrimaryUserUID == dbUser.UID && u.UserUID == secondaryUserUid))?.User; dbUser = (await db.Auth.Include(u => u.User).SingleOrDefaultAsync(u => u.PrimaryUserUID == dbUser.UID && u.UserUID == secondaryUserUid))?.User;
if (dbUser == null) if (dbUser == null)
{ {
EmbedBuilder eb = new();
eb.WithTitle("No such secondary UID"); eb.WithTitle("No such secondary UID");
eb.WithDescription($"A secondary UID {secondaryUserUid} was not found attached to your primary UID {primaryUser.User.UID}."); eb.WithDescription($"A secondary UID {secondaryUserUid} was not found attached to your primary UID {primaryUser.User.UID}.");
return eb; return (eb.Build(), null);
} }
} }
@@ -513,55 +538,61 @@ public class LightlessModule : InteractionModuleBase
var profile = await db.UserProfileData.Where(u => u.UserUID == dbUser.UID).SingleOrDefaultAsync().ConfigureAwait(false); var profile = await db.UserProfileData.Where(u => u.UserUID == dbUser.UID).SingleOrDefaultAsync().ConfigureAwait(false);
var identity = await _connectionMultiplexer.GetDatabase().StringGetAsync("UID:" + dbUser.UID).ConfigureAwait(false); var identity = await _connectionMultiplexer.GetDatabase().StringGetAsync("UID:" + dbUser.UID).ConfigureAwait(false);
eb.WithTitle("User Information"); EmbedBuilder mainEmbed = new();
eb.WithDescription("This is the user information for Discord User <@" + userToCheckForDiscordId + ">" + Environment.NewLine + Environment.NewLine mainEmbed.WithTitle("User Information");
mainEmbed.WithDescription("This is the user information for Discord User <@" + userToCheckForDiscordId + ">" + Environment.NewLine + Environment.NewLine
+ "If you want to verify your secret key is valid, go to https://emn178.github.io/online-tools/sha256.html and copy your secret key into there and compare it to the Hashed Secret Key provided below."); + "If you want to verify your secret key is valid, go to https://emn178.github.io/online-tools/sha256.html and copy your secret key into there and compare it to the Hashed Secret Key provided below.");
eb.AddField("UID", dbUser.UID); mainEmbed.AddField("UID", dbUser.UID);
if (!string.IsNullOrEmpty(dbUser.Alias)) if (!string.IsNullOrEmpty(dbUser.Alias))
{ {
eb.AddField("Vanity UID", dbUser.Alias); mainEmbed.AddField("Vanity UID", dbUser.Alias);
} }
if (showForSecondaryUser) if (showForSecondaryUser)
{ {
eb.AddField("Primary UID for " + dbUser.UID, auth.PrimaryUserUID); mainEmbed.AddField("Primary UID for " + dbUser.UID, auth.PrimaryUserUID);
} }
else else
{ {
var secondaryUIDs = await db.Auth.Where(p => p.PrimaryUserUID == dbUser.UID).Select(p => p.UserUID).ToListAsync(); var secondaryUIDs = await db.Auth.Where(p => p.PrimaryUserUID == dbUser.UID).Select(p => p.UserUID).ToListAsync();
if (secondaryUIDs.Any()) if (secondaryUIDs.Any())
{ {
eb.AddField("Secondary UIDs", string.Join(Environment.NewLine, secondaryUIDs)); mainEmbed.AddField("Secondary UIDs", string.Join(Environment.NewLine, secondaryUIDs));
} }
} }
if(profile != null) mainEmbed.AddField("Last Online (UTC)", dbUser.LastLoggedIn.ToString("U"));
{ mainEmbed.AddField("Currently online ", !string.IsNullOrEmpty(identity));
eb.AddField("Profile Description", string.IsNullOrEmpty(profile.UserDescription) ? "(No description set)" : profile.UserDescription); mainEmbed.AddField("Hashed Secret Key", auth.HashedKey);
eb.AddField("Profile NSFW", profile.IsNSFW); mainEmbed.AddField("Joined Syncshells", groupsJoined.Count);
eb.AddField("Profile Disabled", profile.ProfileDisabled); mainEmbed.AddField("Owned Syncshells", groups.Count);
eb.AddField("Profile Flagged for Report", profile.FlaggedForReport);
eb.AddField("Profile Tags", profile.Tags != null && profile.Tags.Length > 0 ? string.Join(", ", profile.Tags) : "(No tags set)");
}
eb.AddField("Last Online (UTC)", dbUser.LastLoggedIn.ToString("U"));
eb.AddField("Currently online ", !string.IsNullOrEmpty(identity));
eb.AddField("Hashed Secret Key", auth.HashedKey);
eb.AddField("Joined Syncshells", groupsJoined.Count);
eb.AddField("Owned Syncshells", groups.Count);
foreach (var group in groups) foreach (var group in groups)
{ {
var syncShellUserCount = await db.GroupPairs.CountAsync(g => g.GroupGID == group.GID).ConfigureAwait(false); var syncShellUserCount = await db.GroupPairs.CountAsync(g => g.GroupGID == group.GID).ConfigureAwait(false);
if (!string.IsNullOrEmpty(group.Alias)) if (!string.IsNullOrEmpty(group.Alias))
{ {
eb.AddField("Owned Syncshell " + group.GID + " Vanity ID", group.Alias); mainEmbed.AddField("Owned Syncshell " + group.GID + " Vanity ID", group.Alias);
} }
eb.AddField("Owned Syncshell " + group.GID + " User Count", syncShellUserCount); mainEmbed.AddField("Owned Syncshell " + group.GID + " User Count", syncShellUserCount);
} }
if (isAdminCall && !string.IsNullOrEmpty(identity)) if (isAdminCall && !string.IsNullOrEmpty(identity))
{ {
eb.AddField("Character Ident", identity); mainEmbed.AddField("Character Ident", identity);
} }
return eb; Embed? profileEmbedResult = null;
if (profile != null)
{
EmbedBuilder profileEmbedBuilder = new();
profileEmbedBuilder.WithTitle("User Profile");
profileEmbedBuilder.WithDescription("Profile Description: " + (string.IsNullOrEmpty(profile.UserDescription) ? "(No description set)" : profile.UserDescription));
profileEmbedBuilder.AddField("Profile NSFW", profile.IsNSFW);
profileEmbedBuilder.AddField("Profile Disabled", profile.ProfileDisabled);
profileEmbedBuilder.AddField("Profile Flagged for Report", profile.FlaggedForReport);
profileEmbedBuilder.AddField("Profile Tags", profile.Tags != null && profile.Tags.Length > 0 ? string.Join(", ", profile.Tags) : "(No tags set)");
profileEmbedResult = profileEmbedBuilder.Build();
}
return (mainEmbed.Build(), profileEmbedResult);
} }
private async Task<string> GetUserUID(LightlessDbContext db, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null) private async Task<string> GetUserUID(LightlessDbContext db, string? secondaryUserUid = null, ulong? optionalUser = null, string? uid = null)
@@ -578,7 +609,7 @@ public class LightlessModule : InteractionModuleBase
} }
else if (uid != null) else if (uid != null)
{ {
userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid || u.User.Alias == uid).ConfigureAwait(false); userInDb = await db.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.User.UID == uid || u.User.Alias == uid). ConfigureAwait(false);
} }
if (userInDb == null) if (userInDb == null)
{ {

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -21,7 +21,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Discord.Net" Version="3.17.0" /> <PackageReference Include="Discord.Net" Version="3.18.0" />
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8"> <PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -32,6 +32,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.0" />
<PackageReference Include="System.Linq.Async" Version="7.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -47,7 +47,6 @@
<PackageReference Include="StackExchange.Redis.Extensions.Core" Version="10.2.0" /> <PackageReference Include="StackExchange.Redis.Extensions.Core" Version="10.2.0" />
<PackageReference Include="StackExchange.Redis.Extensions.System.Text.Json" Version="10.2.0" /> <PackageReference Include="StackExchange.Redis.Extensions.System.Text.Json" Version="10.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LightlessSyncServer.Migrations
{
/// <inheritdoc />
public partial class ChatReportFixes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "sender_token",
table: "reported_chat_messages");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "sender_token",
table: "reported_chat_messages",
type: "text",
nullable: false,
defaultValue: "");
}
}
}

View File

@@ -776,11 +776,6 @@ namespace LightlessSyncServer.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("sender_hashed_cid"); .HasColumnName("sender_hashed_cid");
b.Property<string>("SenderToken")
.IsRequired()
.HasColumnType("text")
.HasColumnName("sender_token");
b.Property<bool>("SenderWasLightfinder") b.Property<bool>("SenderWasLightfinder")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("sender_was_lightfinder"); .HasColumnName("sender_was_lightfinder");

View File

@@ -40,9 +40,6 @@ public class ReportedChatMessage
[Required] [Required]
public string MessageContent { get; set; } = string.Empty; public string MessageContent { get; set; } = string.Empty;
[Required]
public string SenderToken { get; set; } = string.Empty;
public string? SenderHashedCid { get; set; } public string? SenderHashedCid { get; set; }
public string? SenderDisplayName { get; set; } public string? SenderDisplayName { get; set; }

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>