using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSyncServer.Models; using LightlessSyncServer.Services; using LightlessSyncServer.Utils; using LightlessSyncShared.Models; using LightlessSyncShared.Utils; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using SixLabors.ImageSharp; using System.Collections.Concurrent; using System.Security.Cryptography; namespace LightlessSyncServer.Hubs; public partial class LightlessHub { [Authorize(Policy = "Identified")] public async Task GroupBanUser(GroupPairDto dto, string reason) { _logger.LogCallInfo(LightlessHubLogger.Args(dto, reason)); var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); if (!userHasRights) return; var targetUid = dto.User.UID?.Trim(); if (string.IsNullOrWhiteSpace(targetUid)) return; if (string.Equals(group.OwnerUID, targetUid, StringComparison.Ordinal)) return; var groupPair = await DbContext.GroupPairs .Include(p => p.GroupUser) .SingleOrDefaultAsync(p => p.GroupGID == dto.Group.GID && p.GroupUserUID == targetUid, cancellationToken: RequestAbortedToken).ConfigureAwait(false); if (groupPair?.IsModerator == true) return; var now = DateTime.UtcNow; var existingBan = await DbContext.Set().SingleOrDefaultAsync(b => b.GroupGID == dto.Group.GID && b.BannedUserUID == targetUid, cancellationToken: RequestAbortedToken).ConfigureAwait(false); var userExists = await DbContext.Users.AsNoTracking().AnyAsync(u => u.UID == targetUid || u.Alias == targetUid, RequestAbortedToken).ConfigureAwait(false); if (!userExists && existingBan == null) return; const string marker = " (Alias at time of ban:"; string suffix; if (existingBan?.BannedReason is { } existingReason) { var idx = existingReason.IndexOf(marker, StringComparison.Ordinal); suffix = idx >= 0 ? existingReason.Substring(startIndex: idx) : string.Empty; } else { var alias = groupPair?.GroupUser?.Alias; alias = string.IsNullOrWhiteSpace(alias) ? "-" : alias; suffix = $" (Alias at time of ban: {alias})"; } var baseReason = (reason ?? string.Empty).Trim(); var finalReason = string.IsNullOrEmpty(suffix) ? baseReason : (baseReason + suffix); if (existingBan != null) { existingBan.BannedByUID = UserUID; existingBan.BannedReason = finalReason; DbContext.Update(existingBan); } else { var ban = new GroupBan { BannedByUID = UserUID, BannedReason = finalReason, BannedOn = now, BannedUserUID = targetUid, GroupGID = dto.Group.GID, }; DbContext.Add(ban); } await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); if (groupPair != null) { await GroupRemoveUser(dto).ConfigureAwait(false); } _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); } private static readonly ConcurrentDictionary GroupChatToggleCooldowns = new(StringComparer.Ordinal); [Authorize(Policy = "Identified")] public async Task GroupChangeGroupPermissionState(GroupPermissionDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); if (!hasRights) return; var permissions = dto.Permissions; var isOwner = string.Equals(group.OwnerUID, UserUID, StringComparison.Ordinal); var chatEnabled = group.ChatEnabled; var chatChanged = false; if (!isOwner) { permissions.SetDisableChat(!group.ChatEnabled); } else { var requestedChatEnabled = !permissions.IsDisableChat(); if (requestedChatEnabled != group.ChatEnabled) { var now = DateTime.UtcNow; if (GroupChatToggleCooldowns.TryGetValue(group.GID, out var lockedUntil) && lockedUntil > now) { var remaining = lockedUntil - now; var minutes = Math.Max(1, (int)Math.Ceiling(remaining.TotalMinutes)); await Clients.Caller.Client_ReceiveServerMessage( MessageSeverity.Warning, $"Syncshell chat can be toggled again in {minutes} minute{(minutes == 1 ? string.Empty : "s")}." ).ConfigureAwait(false); permissions.SetDisableChat(!group.ChatEnabled); } else { chatEnabled = requestedChatEnabled; group.ChatEnabled = chatEnabled; GroupChatToggleCooldowns[group.GID] = now.AddMinutes(5); chatChanged = true; } } } group.InvitesEnabled = !permissions.HasFlag(GroupPermissions.DisableInvites); group.PreferDisableSounds = permissions.HasFlag(GroupPermissions.PreferDisableSounds); group.PreferDisableAnimations = permissions.HasFlag(GroupPermissions.PreferDisableAnimations); group.PreferDisableVFX = permissions.HasFlag(GroupPermissions.PreferDisableVFX); await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); var groupPairs = DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToList(); await Clients.Users(groupPairs).Client_GroupChangePermissions(new GroupPermissionDto(dto.Group, permissions)).ConfigureAwait(false); if (isOwner && chatChanged && !chatEnabled) { var groupDisplayName = string.IsNullOrWhiteSpace(group.Alias) ? group.GID : group.Alias; var descriptor = new ChatChannelDescriptor { Type = ChatChannelType.Group, WorldId = 0, ZoneId = 0, CustomKey = group.GID }; foreach (var uid in groupPairs) { TryInvokeChatService(() => _chatChannelService.RemovePresence(uid, descriptor), "removing group chat presence", descriptor, uid); } await Clients.Users(groupPairs) .Client_ReceiveServerMessage( MessageSeverity.Information, $"Syncshell chat for '{groupDisplayName}' has been disabled.") .ConfigureAwait(false); } } [Authorize(Policy = "Identified")] public async Task GroupChangeOwnership(GroupPairDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (isOwner, group) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false); if (!isOwner) return; var (isInGroup, newOwnerPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false); if (!isInGroup) return; var ownedShells = await DbContext.Groups.CountAsync(g => g.OwnerUID == dto.User.UID).ConfigureAwait(false); if (ownedShells >= _maxExistingGroupsByUser) return; var prevOwner = await DbContext.GroupPairs.SingleOrDefaultAsync(g => g.GroupGID == dto.Group.GID && g.GroupUserUID == UserUID).ConfigureAwait(false); prevOwner.IsPinned = false; group.Owner = newOwnerPair.GroupUser; group.Alias = null; newOwnerPair.IsPinned = true; newOwnerPair.IsModerator = false; await DbContext.SaveChangesAsync().ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).AsNoTracking().ToListAsync().ConfigureAwait(false); await Clients.Users(groupPairs).Client_GroupSendInfo(new GroupInfoDto(group.ToGroupData(), newOwnerPair.GroupUser.ToUserData(), group.ToEnum())).ConfigureAwait(false); } [Authorize(Policy = "Identified")] public async Task GroupChangePassword(GroupPasswordDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (isOwner, group) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false); if (!isOwner || dto.Password.Length < 10) return false; _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); group.HashedPassword = StringUtils.Sha256String(dto.Password); await DbContext.SaveChangesAsync().ConfigureAwait(false); return true; } [Authorize(Policy = "Identified")] public async Task GroupClear(GroupDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); if (!hasRights) return; var groupPairs = await DbContext.GroupPairs.Include(p => p.GroupUser).Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false); var notPinned = groupPairs.Where(g => !g.IsPinned && !g.IsModerator).ToList(); await Clients.Users(notPinned.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); DbContext.GroupPairs.RemoveRange(notPinned); foreach (var pair in notPinned) { await Clients.Users(groupPairs.Where(p => p.IsPinned || p.IsModerator).Select(g => g.GroupUserUID)) .Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false); var pairIdent = await GetUserIdent(pair.GroupUserUID).ConfigureAwait(false); if (string.IsNullOrEmpty(pairIdent)) continue; var allUserPairs = await GetAllPairInfo(pair.GroupUserUID).ConfigureAwait(false); var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == pair.GroupUserUID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false); DbContext.CharaDataAllowances.RemoveRange(sharedData); foreach (var groupUserPair in groupPairs.Where(p => !string.Equals(p.GroupUserUID, pair.GroupUserUID, StringComparison.Ordinal))) { await UserGroupLeave(pair, pairIdent, allUserPairs, pair.GroupUserUID).ConfigureAwait(false); } } await DbContext.SaveChangesAsync().ConfigureAwait(false); } [Authorize(Policy = "Identified")] public async Task GroupClearFinder(GroupDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); if (!hasRights) return; var groupPairs = await DbContext.GroupPairs.Include(p => p.GroupUser).Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false); var finder_only = groupPairs.Where(g => g.FromFinder && !g.IsPinned && !g.IsModerator).ToList(); if (finder_only.Count == 0) { _logger.LogCallInfo(LightlessHubLogger.Args(dto, "No Users To Clear")); return; } await Clients.Users(finder_only.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Cleared Finder users ", finder_only.Count)); DbContext.GroupPairs.RemoveRange(finder_only); foreach (var pair in finder_only) { await Clients.Users(groupPairs.Where(p => p.IsPinned || p.IsModerator).Select(g => g.GroupUserUID)).Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false); var pairIdent = await GetUserIdent(pair.GroupUserUID).ConfigureAwait(false); if (string.IsNullOrEmpty(pairIdent)) continue; var allUserPairs = await GetAllPairInfo(pair.GroupUserUID).ConfigureAwait(false); var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == pair.GroupUserUID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false); DbContext.CharaDataAllowances.RemoveRange(sharedData); foreach (var groupUserPair in groupPairs.Where(p => !string.Equals(p.GroupUserUID, pair.GroupUserUID, StringComparison.Ordinal))) { await UserGroupLeave(pair, pairIdent, allUserPairs, pair.GroupUserUID).ConfigureAwait(false); } } await DbContext.SaveChangesAsync().ConfigureAwait(false); } [Authorize(Policy = "Identified")] public async Task GroupCreate() { _logger.LogCallInfo(); var existingGroupsByUser = await DbContext.Groups.CountAsync(u => u.OwnerUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); var existingJoinedGroups = await DbContext.GroupPairs.CountAsync(u => u.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); if (existingGroupsByUser >= _maxExistingGroupsByUser || existingJoinedGroups >= _maxJoinedGroupsByUser) { throw new System.Exception($"Max groups for user is {_maxExistingGroupsByUser}, max joined groups is {_maxJoinedGroupsByUser}."); } var gid = StringUtils.GenerateRandomString(12); while (await DbContext.Groups.AnyAsync(g => g.GID == "LLS-" + gid, cancellationToken: RequestAbortedToken).ConfigureAwait(false)) { gid = StringUtils.GenerateRandomString(12); } gid = "LLS-" + gid; var passwd = StringUtils.GenerateRandomString(16); using var sha = SHA256.Create(); var hashedPw = StringUtils.Sha256String(passwd); var currentTime = DateTime.UtcNow; UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); Group newGroup = new() { GID = gid, HashedPassword = hashedPw, InvitesEnabled = true, OwnerUID = UserUID, PreferDisableAnimations = defaultPermissions.DisableGroupAnimations, PreferDisableSounds = defaultPermissions.DisableGroupSounds, PreferDisableVFX = defaultPermissions.DisableGroupVFX, ChatEnabled = true, CreatedDate = currentTime, }; GroupPair initialPair = new() { GroupGID = newGroup.GID, GroupUserUID = UserUID, IsPinned = true, JoinedGroupOn = currentTime, FromFinder = false, }; GroupPairPreferredPermission initialPrefPermissions = new() { UserUID = UserUID, GroupGID = newGroup.GID, DisableSounds = defaultPermissions.DisableGroupSounds, DisableAnimations = defaultPermissions.DisableGroupAnimations, DisableVFX = defaultPermissions.DisableGroupAnimations, }; await DbContext.Groups.AddAsync(newGroup, RequestAbortedToken).ConfigureAwait(false); await DbContext.GroupPairs.AddAsync(initialPair, RequestAbortedToken).ConfigureAwait(false); await DbContext.GroupPairPreferredPermissions.AddAsync(initialPrefPermissions, RequestAbortedToken).ConfigureAwait(false); await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(), newGroup.ToEnum(), initialPrefPermissions.ToEnum(), initialPair.ToEnum(), new(StringComparer.Ordinal), 1)) .ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args(gid)); return new GroupJoinDto(newGroup.ToGroupData(), passwd, initialPrefPermissions.ToEnum()); } [Authorize(Policy = "Identified")] public async Task> GroupCreateTempInvite(GroupDto dto, int amount) { _logger.LogCallInfo(LightlessHubLogger.Args(dto, amount)); List inviteCodes = new(); List tempInvites = new(); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); if (!hasRights) return new(); var existingInvites = await DbContext.GroupTempInvites.Where(g => g.GroupGID == group.GID).ToListAsync().ConfigureAwait(false); for (int i = 0; i < amount; i++) { bool hasValidInvite = false; string invite = string.Empty; string hashedInvite = string.Empty; while (!hasValidInvite) { invite = StringUtils.GenerateRandomString(10, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"); hashedInvite = StringUtils.Sha256String(invite); if (existingInvites.Any(i => string.Equals(i.Invite, hashedInvite, StringComparison.Ordinal))) continue; hasValidInvite = true; inviteCodes.Add(invite); } tempInvites.Add(new GroupTempInvite() { ExpirationDate = DateTime.UtcNow.AddDays(1), GroupGID = group.GID, Invite = hashedInvite, }); } DbContext.GroupTempInvites.AddRange(tempInvites); await DbContext.SaveChangesAsync().ConfigureAwait(false); return inviteCodes; } [Authorize(Policy = "Identified")] public async Task GroupDelete(GroupDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (hasRights, group) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false); DbContext.RemoveRange(groupPairs); DbContext.Remove(group); await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); await Clients.Users(groupPairs.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false); await SendGroupDeletedToAll(groupPairs).ConfigureAwait(false); } [Authorize(Policy = "Identified")] public async Task> GroupGetBannedUsers(GroupDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false); if (!userHasRights) return []; var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false); List bannedGroupUsers = [.. banEntries.Select(b => new BannedGroupUserDto(group.ToGroupData(), b.BannedUser.ToUserData(), b.BannedReason, b.BannedOn, b.BannedByUID))]; _logger.LogCallInfo(LightlessHubLogger.Args(dto, bannedGroupUsers.Count)); return bannedGroupUsers; } [Authorize(Policy = "Identified")] public async Task GroupJoin(GroupPasswordDto dto) { var aliasOrGid = dto.Group.GID.Trim(); _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid, cancellationToken: RequestAbortedToken).ConfigureAwait(false); var groupGid = group?.GID ?? string.Empty; var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); var hashedPw = StringUtils.Sha256String(dto.Password); var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid, cancellationToken: RequestAbortedToken).ConfigureAwait(false); var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw, cancellationToken: RequestAbortedToken).ConfigureAwait(false); if (group == null || (!string.Equals(group.HashedPassword, hashedPw, StringComparison.Ordinal) && oneTimeInvite == null) || existingPair != null || existingUserCount >= _maxGroupUserCount || !group.InvitesEnabled || joinedGroups >= _maxJoinedGroupsByUser || isBanned) return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false); return new GroupJoinInfoDto(group.ToGroupData(), group.Owner.ToUserData(), group.ToEnum(), true); } [Authorize(Policy = "Identified")] public async Task GroupJoinFinalize(GroupJoinDto dto) { var aliasOrGid = dto.Group.GID.Trim(); _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid, cancellationToken: RequestAbortedToken).ConfigureAwait(false); var groupGid = group?.GID ?? string.Empty; var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID).ConfigureAwait(false); var isHashedPassword = dto.Password.Length == 64 && dto.Password.All(Uri.IsHexDigit); var hashedPw = isHashedPassword ? dto.Password : StringUtils.Sha256String(dto.Password); var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid).ConfigureAwait(false); var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID).ConfigureAwait(false); var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID).ConfigureAwait(false); var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw).ConfigureAwait(false); if (group == null || (!string.Equals(group.HashedPassword, hashedPw, StringComparison.Ordinal) && oneTimeInvite == null) || existingPair != null || existingUserCount >= _maxGroupUserCount || !group.InvitesEnabled || joinedGroups >= _maxJoinedGroupsByUser || isBanned) return false; // get all pairs before we join var allUserPairs = (await GetAllPairInfo(UserUID).ConfigureAwait(false)); if (oneTimeInvite != null) { _logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "TempInvite", oneTimeInvite.Invite)); DbContext.Remove(oneTimeInvite); } GroupPair newPair = new() { GroupGID = group.GID, GroupUserUID = UserUID, JoinedGroupOn = DateTime.UtcNow, FromFinder = isHashedPassword }; var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); if (preferredPermissions == null) { GroupPairPreferredPermission newPerms = new() { GroupGID = group.GID, UserUID = UserUID, DisableSounds = dto.GroupUserPreferredPermissions.IsDisableSounds(), DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX(), DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations(), IsPaused = false, }; DbContext.Add(newPerms); preferredPermissions = newPerms; } else { preferredPermissions.DisableSounds = dto.GroupUserPreferredPermissions.IsDisableSounds(); preferredPermissions.DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX(); preferredPermissions.DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations(); preferredPermissions.IsPaused = false; DbContext.Update(preferredPermissions); } await DbContext.GroupPairs.AddAsync(newPair, RequestAbortedToken).ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "Success")); await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false); var totalUserCount = await DbContext.GroupPairs .AsNoTracking() .CountAsync(u => u.GroupGID == group.GID, RequestAbortedToken) .ConfigureAwait(false); await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(group.ToGroupData(), group.Owner.ToUserData(), group.ToEnum(), preferredPermissions.ToEnum(), newPair.ToEnum(), groupInfos.ToDictionary(u => u.GroupUserUID, u => u.ToEnum(), StringComparer.Ordinal), totalUserCount)).ConfigureAwait(false); var self = DbContext.Users.Single(u => u.UID == UserUID); var groupPairs = await DbContext.GroupPairs.Include(p => p.GroupUser) .Where(p => p.GroupGID == group.GID && p.GroupUserUID != UserUID).ToListAsync().ConfigureAwait(false); var userPairsAfterJoin = await GetAllPairInfo(UserUID).ConfigureAwait(false); foreach (var pair in groupPairs) { var perms = userPairsAfterJoin.TryGetValue(pair.GroupUserUID, out var userinfo); // check if we have had prior permissions to that pair, if not add them var ownPermissionsToOther = userinfo?.OwnPermissions ?? null; if (ownPermissionsToOther == null) { var existingPermissionsOnDb = await DbContext.Permissions.SingleOrDefaultAsync(p => p.UserUID == UserUID && p.OtherUserUID == pair.GroupUserUID).ConfigureAwait(false); if (existingPermissionsOnDb == null) { ownPermissionsToOther = new() { UserUID = UserUID, OtherUserUID = pair.GroupUserUID, DisableAnimations = preferredPermissions.DisableAnimations, DisableSounds = preferredPermissions.DisableSounds, DisableVFX = preferredPermissions.DisableVFX, IsPaused = preferredPermissions.IsPaused, Sticky = false }; await DbContext.Permissions.AddAsync(ownPermissionsToOther).ConfigureAwait(false); } else { existingPermissionsOnDb.DisableAnimations = preferredPermissions.DisableAnimations; existingPermissionsOnDb.DisableSounds = preferredPermissions.DisableSounds; existingPermissionsOnDb.DisableVFX = preferredPermissions.DisableVFX; existingPermissionsOnDb.IsPaused = false; existingPermissionsOnDb.Sticky = false; DbContext.Update(existingPermissionsOnDb); ownPermissionsToOther = existingPermissionsOnDb; } } else if (!ownPermissionsToOther.Sticky) { ownPermissionsToOther = await DbContext.Permissions.SingleAsync(u => u.UserUID == UserUID && u.OtherUserUID == pair.GroupUserUID).ConfigureAwait(false); // update the existing permission only if it was not set to sticky ownPermissionsToOther.DisableAnimations = preferredPermissions.DisableAnimations; ownPermissionsToOther.DisableVFX = preferredPermissions.DisableVFX; ownPermissionsToOther.DisableSounds = preferredPermissions.DisableSounds; ownPermissionsToOther.IsPaused = false; DbContext.Update(ownPermissionsToOther); } // get others permissionset to self and eventually update it var otherPermissionToSelf = userinfo?.OtherPermissions ?? null; if (otherPermissionToSelf == null) { var otherExistingPermsOnDb = await DbContext.Permissions.SingleOrDefaultAsync(p => p.UserUID == pair.GroupUserUID && p.OtherUserUID == UserUID).ConfigureAwait(false); if (otherExistingPermsOnDb == null) { var otherPreferred = await DbContext.GroupPairPreferredPermissions.SingleAsync(u => u.GroupGID == group.GID && u.UserUID == pair.GroupUserUID).ConfigureAwait(false); otherExistingPermsOnDb = new() { UserUID = pair.GroupUserUID, OtherUserUID = UserUID, DisableAnimations = otherPreferred.DisableAnimations, DisableSounds = otherPreferred.DisableSounds, DisableVFX = otherPreferred.DisableVFX, IsPaused = otherPreferred.IsPaused, Sticky = false }; await DbContext.AddAsync(otherExistingPermsOnDb).ConfigureAwait(false); } else if (!otherExistingPermsOnDb.Sticky) { var otherPreferred = await DbContext.GroupPairPreferredPermissions.SingleAsync(u => u.GroupGID == group.GID && u.UserUID == pair.GroupUserUID).ConfigureAwait(false); otherExistingPermsOnDb.DisableAnimations = otherPreferred.DisableAnimations; otherExistingPermsOnDb.DisableSounds = otherPreferred.DisableSounds; otherExistingPermsOnDb.DisableVFX = otherPreferred.DisableVFX; otherExistingPermsOnDb.IsPaused = otherPreferred.IsPaused; DbContext.Update(otherExistingPermsOnDb); } otherPermissionToSelf = otherExistingPermsOnDb; } else if (!otherPermissionToSelf.Sticky) { var otherPreferred = await DbContext.GroupPairPreferredPermissions.SingleAsync(u => u.GroupGID == group.GID && u.UserUID == pair.GroupUserUID).ConfigureAwait(false); otherPermissionToSelf.DisableAnimations = otherPreferred.DisableAnimations; otherPermissionToSelf.DisableSounds = otherPreferred.DisableSounds; otherPermissionToSelf.DisableVFX = otherPreferred.DisableVFX; otherPermissionToSelf.IsPaused = otherPreferred.IsPaused; DbContext.Update(otherPermissionToSelf); } await Clients.User(UserUID).Client_GroupPairJoined(new GroupPairFullInfoDto(group.ToGroupData(), pair.ToUserData(), ownPermissionsToOther.ToUserPermissions(setSticky: ownPermissionsToOther.Sticky), otherPermissionToSelf.ToUserPermissions(setSticky: false))).ConfigureAwait(false); await Clients.User(pair.GroupUserUID).Client_GroupPairJoined(new GroupPairFullInfoDto(group.ToGroupData(), self.ToUserData(), otherPermissionToSelf.ToUserPermissions(setSticky: otherPermissionToSelf.Sticky), ownPermissionsToOther.ToUserPermissions(setSticky: false))).ConfigureAwait(false); // if not paired prior and neither has the permissions set to paused, send online if ((!allUserPairs.ContainsKey(pair.GroupUserUID) || (allUserPairs.TryGetValue(pair.GroupUserUID, out var info) && !info.IsSynced)) && !otherPermissionToSelf.IsPaused && !ownPermissionsToOther.IsPaused) { var groupUserIdent = await GetUserIdent(pair.GroupUserUID).ConfigureAwait(false); if (!string.IsNullOrEmpty(groupUserIdent)) { await Clients.User(UserUID).Client_UserSendOnline(new(pair.ToUserData(), groupUserIdent)).ConfigureAwait(false); await Clients.User(pair.GroupUserUID).Client_UserSendOnline(new(self.ToUserData(), UserCharaIdent)).ConfigureAwait(false); } } } await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); return true; } [Authorize(Policy = "Identified")] public async Task GroupJoinHashed(GroupJoinHashedDto dto) { var aliasOrGid = dto.Group.GID.Trim(); _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var group = await DbContext.Groups.Include(g => g.Owner) .AsNoTracking() .SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid) .ConfigureAwait(false); var groupGid = group?.GID ?? string.Empty; var existingPair = await DbContext.GroupPairs .AsNoTracking() .SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID) .ConfigureAwait(false); var isBanned = await DbContext.GroupBans .AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID) .ConfigureAwait(false); var oneTimeInvite = await DbContext.GroupTempInvites .SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == dto.HashedPassword) .ConfigureAwait(false); var existingUserCount = await DbContext.GroupPairs .AsNoTracking() .CountAsync(g => g.GroupGID == groupGid) .ConfigureAwait(false); var joinedGroups = await DbContext.GroupPairs .CountAsync(g => g.GroupUserUID == UserUID) .ConfigureAwait(false); if (group == null) { await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "Syncshell not found."); return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false); } if (!string.Equals(group.HashedPassword, dto.HashedPassword, StringComparison.Ordinal) && oneTimeInvite == null) { await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "Incorrect or expired password."); return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false); } if (existingPair != null) { await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "You are already a member of this syncshell."); return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false); } if (existingUserCount >= _maxGroupUserCount) { await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "This syncshell is full."); return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false); } if (!group.InvitesEnabled) { await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "Invites to this syncshell are currently disabled."); return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false); } if (joinedGroups >= _maxJoinedGroupsByUser) { await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "You have reached the maximum number of syncshells you can join."); return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false); } if (isBanned) { await Clients.User(UserUID).Client_ReceiveServerMessage(MessageSeverity.Warning, "You are banned from this syncshell."); return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false); } return new GroupJoinInfoDto(group.ToGroupData(), group.Owner.ToUserData(), group.ToEnum(), true); } [Authorize(Policy = "Identified")] public async Task GroupLeave(GroupDto dto) { await UserLeaveGroup(dto, UserUID).ConfigureAwait(false); } [Authorize(Policy = "Identified")] public async Task GroupPrune(GroupDto dto, int days, bool execute) { _logger.LogCallInfo(LightlessHubLogger.Args(dto, days, execute)); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID) .ConfigureAwait(false); if (!hasRights) return -1; if (!execute) { var count = await _pruneService.CountPrunableUsersAsync(dto.Group.GID, days, RequestAbortedToken).ConfigureAwait(false); return count; } var allGroupUsers = await DbContext.GroupPairs .Include(p => p.GroupUser) .Include(p => p.Group) .Where(g => g.GroupGID == dto.Group.GID) .ToListAsync(RequestAbortedToken).ConfigureAwait(false); var prunedPairs = await _pruneService.ExecutePruneAsync(dto.Group.GID, days, RequestAbortedToken).ConfigureAwait(false); var remainingUserIds = allGroupUsers .Where(p => !prunedPairs.Any(x => string.Equals(x.GroupUserUID, p.GroupUserUID, StringComparison.Ordinal))) .Select(p => p.GroupUserUID) .Distinct(StringComparer.Ordinal) .ToList(); foreach (var pair in prunedPairs) { await Clients.Users(remainingUserIds) .Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())) .ConfigureAwait(false); } return prunedPairs.Count; } [Authorize(Policy = "Identified")] public async Task GroupRemoveUser(GroupPairDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); if (!hasRights) return; var (userExists, groupPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false); if (!userExists) return; if (groupPair.IsModerator || string.Equals(group.OwnerUID, dto.User.UID, StringComparison.Ordinal)) return; _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); DbContext.GroupPairs.Remove(groupPair); var groupPairs = DbContext.GroupPairs.Where(p => p.GroupGID == group.GID).AsNoTracking().ToList(); await Clients.Users(groupPairs.Select(p => p.GroupUserUID)).Client_GroupPairLeft(dto).ConfigureAwait(false); var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == dto.UID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false); DbContext.CharaDataAllowances.RemoveRange(sharedData); await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); var userIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false); if (userIdent == null) { await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); return; } await Clients.User(dto.User.UID).Client_GroupDelete(new GroupDto(dto.Group)).ConfigureAwait(false); var userPairs = await GetAllPairInfo(dto.User.UID).ConfigureAwait(false); foreach (var groupUserPair in groupPairs) { await UserGroupLeave(groupUserPair, userIdent, userPairs, dto.User.UID).ConfigureAwait(false); } } [Authorize(Policy = "Identified")] public async Task GroupGetProfile(GroupDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var cancellationToken = RequestAbortedToken; if (dto?.Group == null) { _logger.LogCallWarning(LightlessHubLogger.Args("GroupGetProfile: dto.Group is null")); return new GroupProfileDto(Group: null, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false); } var data = await DbContext.GroupProfiles .Include(gp => gp.Group) .FirstOrDefaultAsync( g => g.Group.GID == dto.Group.GID || g.Group.Alias == dto.Group.AliasOrGID, cancellationToken ) .ConfigureAwait(false); if (data == null) { return new GroupProfileDto(dto.Group, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false); } if (data.ProfileDisabled) { return new GroupProfileDto(Group: dto.Group, Description: "This profile was permanently disabled", Tags: [], PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: true); } try { return data.ToDTO(); } catch (Exception ex) { _logger.LogCallWarning(LightlessHubLogger.Args(ex, "GroupGetProfile: failed to map GroupProfileDto for {Group}", dto.Group.GID ?? dto.Group.AliasOrGID)); return new GroupProfileDto(dto.Group, Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: false, IsDisabled: false); } } [Authorize(Policy = "Identified")] public async Task GroupSetProfile(GroupProfileDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var cancellationToken = RequestAbortedToken; if (dto.Group == null) return; var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); if (!hasRights) return; var groupProfileDb = await DbContext.GroupProfiles .Include(g => g.Group) .FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, cancellationToken) .ConfigureAwait(false); ImageCheckService.ImageLoadResult profileResult = new(); ImageCheckService.ImageLoadResult bannerResult = new(); //Avatar image validation if (!string.IsNullOrEmpty(dto.PictureBase64)) { profileResult = await ImageCheckService.ValidateImageAsync(dto.PictureBase64, banner: false, RequestAbortedToken).ConfigureAwait(false); if (!profileResult.Success) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, profileResult.ErrorMessage).ConfigureAwait(false); return; } } //Banner image validation if (!string.IsNullOrEmpty(dto.BannerBase64)) { bannerResult = await ImageCheckService.ValidateImageAsync(dto.BannerBase64, banner: true, RequestAbortedToken).ConfigureAwait(false); if (!bannerResult.Success) { await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, bannerResult.ErrorMessage).ConfigureAwait(false); return; } } var sanitizedProfileImage = profileResult?.Base64Image; var sanitizedBannerImage = bannerResult?.Base64Image; if (groupProfileDb == null) { groupProfileDb = new GroupProfile { GroupGID = dto.Group.GID, Group = group, ProfileDisabled = dto.IsDisabled ?? false, IsNSFW = dto.IsNsfw ?? false, }; groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage); await DbContext.GroupProfiles.AddAsync(groupProfileDb, cancellationToken).ConfigureAwait(false); } else { groupProfileDb.Group ??= group; groupProfileDb.UpdateProfileFromDto(dto, sanitizedProfileImage, sanitizedBannerImage); } var userIds = await DbContext.GroupPairs .Where(p => p.GroupGID == groupProfileDb.GroupGID) .Select(p => p.GroupUserUID) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (userIds.Count > 0) { var profileDto = groupProfileDb.ToDTO(); await Clients.Users(userIds).Client_GroupSendProfile(profileDto) .ConfigureAwait(false); } await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } [Authorize(Policy = "Identified")] public async Task GroupSetUserInfo(GroupPairUserInfoDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (userExists, userPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false); if (!userExists) return; var (userIsOwner, _) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false); var (userIsModerator, _) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); if (dto.GroupUserInfo.HasFlag(GroupPairUserInfo.IsPinned) && userIsModerator && !userPair.IsPinned) { userPair.IsPinned = true; } else if (userIsModerator && userPair.IsPinned) { userPair.IsPinned = false; } if (dto.GroupUserInfo.HasFlag(GroupPairUserInfo.IsModerator) && userIsOwner && !userPair.IsModerator) { userPair.IsModerator = true; } else if (userIsOwner && userPair.IsModerator) { userPair.IsModerator = false; } await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); var groupPairs = await DbContext.GroupPairs.AsNoTracking().Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToListAsync(cancellationToken: RequestAbortedToken).ConfigureAwait(false); await Clients.Users(groupPairs).Client_GroupPairChangeUserInfo(new GroupPairUserInfoDto(dto.Group, dto.User, userPair.ToEnum())).ConfigureAwait(false); } [Authorize(Policy = "Identified")] public async Task GroupGetPruneSettings(GroupDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID) .ConfigureAwait(false); if (!hasRights || group == null) return null; return new GroupPruneSettingsDto( Group: group.ToGroupData(), AutoPruneEnabled: group.AutoPruneEnabled, AutoPruneDays: group.AutoPruneDays ); } [Authorize(Policy = "Identified")] public async Task GroupSetPruneSettings(GroupPruneSettingsDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID) .ConfigureAwait(false); if (!hasRights || group == null) return; // if days == 0, auto prune is OFF var days = dto.AutoPruneDays; var enabled = dto.AutoPruneEnabled && days > 0; group.AutoPruneEnabled = enabled; group.AutoPruneDays = enabled ? days : 0; await DbContext.SaveChangesAsync(RequestAbortedToken) .ConfigureAwait(false); } [Authorize(Policy = "Identified")] public async Task> GroupsGetAll() { _logger.LogCallInfo(); var ct = RequestAbortedToken; var result = await ( from gp in DbContext.GroupPairs .Include(gp => gp.Group) .ThenInclude(g => g.Owner) join pp in DbContext.GroupPairPreferredPermissions on new { gp.GroupGID, UserUID } equals new { pp.GroupGID, pp.UserUID } where gp.GroupUserUID == UserUID select new { GroupPair = gp, PreferredPermission = pp, GroupInfos = DbContext.GroupPairs .Where(x => x.GroupGID == gp.GroupGID && (x.IsPinned || x.IsModerator)) .Select(x => new { x.GroupUserUID, EnumValue = x.ToEnum() }) .ToList(), UserCount = DbContext.GroupPairs .Count(x => x.GroupGID == gp.GroupGID), }) .AsNoTracking() .ToListAsync() .ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args(result)); List List = [.. result.Select(r => { var groupInfoDict = r.GroupInfos .ToDictionary(x => x.GroupUserUID, x => x.EnumValue, StringComparer.Ordinal); _logger.LogCallInfo(LightlessHubLogger.Args(r)); return new GroupFullInfoDto( r.GroupPair.Group.ToGroupData(), r.GroupPair.Group.Owner.ToUserData(), r.GroupPair.Group.ToEnum(), r.PreferredPermission.ToEnum(), r.GroupPair.ToEnum(), groupInfoDict, r.UserCount ); }),]; return List; } [Authorize(Policy = "Identified")] public async Task GroupUnbanUser(GroupPairDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (userHasRights, _) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); if (!userHasRights) return; var banEntry = await DbContext.GroupBans.SingleOrDefaultAsync(g => g.GroupGID == dto.Group.GID && g.BannedUserUID == dto.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); if (banEntry == null) return; DbContext.Remove(banEntry); await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); } [Authorize(Policy = "Identified")] public async Task SetGroupBroadcastStatus(GroupBroadcastRequestDto dto) { _logger.LogCallInfo(LightlessHubLogger.Args(dto)); if (string.IsNullOrEmpty(dto.HashedCID)) { _logger.LogCallWarning(LightlessHubLogger.Args("missing CID in syncshell broadcast request", "User", UserUID, "GID", dto.GID)); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Internal error: missing CID."); return false; } if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.EnableSyncshellBroadcastPayloads) { _logger.LogCallWarning(LightlessHubLogger.Args("syncshell broadcast disabled", "User", UserUID, "GID", dto.GID)); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Syncshell broadcasting is currently disabled.").ConfigureAwait(false); return false; } var (isOwnerOrMod, _) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false); if (!isOwnerOrMod) { _logger.LogCallWarning(LightlessHubLogger.Args("Unauthorized syncshell broadcast change", "User", UserUID, "GID", dto.GID)); await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You must be the owner or moderator of the syncshell to broadcast it."); return false; } return true; } [Authorize(Policy = "Identified")] public async Task> GetBroadcastedGroups(List broadcastEntries) { _logger.LogCallInfo(LightlessHubLogger.Args("Requested Syncshells", broadcastEntries.Select(b => b.GID))); if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.EnableSyncshellBroadcastPayloads) return new List(); var results = new List(); var gidsToValidate = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var entry in broadcastEntries) { if (string.IsNullOrWhiteSpace(entry.HashedCID) || string.IsNullOrWhiteSpace(entry.GID)) continue; var redisKey = _broadcastConfiguration.BuildRedisKey(entry.HashedCID); var redisEntry = await _redis.GetAsync(redisKey).ConfigureAwait(false); if (redisEntry is null) continue; if (!string.IsNullOrEmpty(redisEntry.HashedCID) && !string.Equals(redisEntry.HashedCID, entry.HashedCID, StringComparison.Ordinal)) { _logger.LogCallWarning(LightlessHubLogger.Args("mismatched broadcast cid for group lookup", "Requested", entry.HashedCID, "EntryCID", redisEntry.HashedCID)); continue; } if (redisEntry.GID != null && string.Equals(redisEntry.GID, entry.GID, StringComparison.OrdinalIgnoreCase)) gidsToValidate.Add(entry.GID); } if (gidsToValidate.Count == 0) return results; var groups = await DbContext.Groups .AsNoTracking() .Where(g => gidsToValidate.Contains(g.GID) && g.InvitesEnabled) .ToListAsync() .ConfigureAwait(false); foreach (var group in groups) { results.Add(new GroupJoinDto( Group: new GroupData(group.GID, group.Alias), Password: group.HashedPassword, GroupUserPreferredPermissions: new GroupUserPreferredPermissions() )); } return results; } }