diff --git a/.gitmodules b/.gitmodules index 308c3b1..64bfa52 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "LightlessAPI"] path = LightlessAPI url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI + branch = main diff --git a/LightlessSyncServer/LightlessSyncServer/Configuration/BroadcastConfiguration.cs b/LightlessSyncServer/LightlessSyncServer/Configuration/BroadcastConfiguration.cs new file mode 100644 index 0000000..d2a0549 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Configuration/BroadcastConfiguration.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.Extensions.Options; + +namespace LightlessSyncServer.Configuration; + +public class BroadcastConfiguration : IBroadcastConfiguration +{ + private static readonly TimeSpan DefaultEntryTtl = TimeSpan.FromMinutes(180); + private const int DefaultMaxStatusBatchSize = 30; + private const string DefaultNotificationTemplate = "{DisplayName} sent you a pair request. To accept, right-click them, open the context menu, and send a request back."; + + private readonly IOptionsMonitor _optionsMonitor; + + public BroadcastConfiguration(IOptionsMonitor optionsMonitor) + { + _optionsMonitor = optionsMonitor; + } + + private BroadcastOptions Options => _optionsMonitor.CurrentValue ?? new BroadcastOptions(); + + public string RedisKeyPrefix + { + get + { + var prefix = Options.RedisKeyPrefix; + return string.IsNullOrWhiteSpace(prefix) ? "broadcast:" : prefix!; + } + } + + public TimeSpan BroadcastEntryTtl + { + get + { + var seconds = Options.EntryTtlSeconds; + return seconds > 0 ? TimeSpan.FromSeconds(seconds) : DefaultEntryTtl; + } + } + + public int MaxStatusBatchSize + { + get + { + var value = Options.MaxStatusBatchSize; + return value > 0 ? value : DefaultMaxStatusBatchSize; + } + } + + public bool NotifyOwnerOnPairRequest => Options.NotifyOwnerOnPairRequest; + + public bool EnableBroadcasting => Options.EnableBroadcasting; + + public bool EnableSyncshellBroadcastPayloads => Options.EnableSyncshellBroadcastPayloads; + + public string BuildRedisKey(string hashedCid) + { + if (string.IsNullOrEmpty(hashedCid)) + return RedisKeyPrefix; + + return string.Concat(RedisKeyPrefix, hashedCid); + } + + public string BuildUserOwnershipKey(string userUid) + { + if (string.IsNullOrWhiteSpace(userUid)) + throw new ArgumentException("User UID must not be null or empty.", nameof(userUid)); + + return string.Concat(RedisKeyPrefix, "owner:", userUid); + } + + public string BuildPairRequestNotification() + { + var template = Options.PairRequestNotificationTemplate; + if (string.IsNullOrWhiteSpace(template)) + { + template = DefaultNotificationTemplate; + } + + return template; + } + + public int PairRequestRateLimit => Options.PairRequestRateLimit > 0 ? Options.PairRequestRateLimit : 5; + public int PairRequestRateWindow => Options.PairRequestRateWindow > 0 ? Options.PairRequestRateWindow : 60; +} diff --git a/LightlessSyncServer/LightlessSyncServer/Configuration/BroadcastOptions.cs b/LightlessSyncServer/LightlessSyncServer/Configuration/BroadcastOptions.cs new file mode 100644 index 0000000..ecfaf3a --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Configuration/BroadcastOptions.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace LightlessSyncServer.Configuration; + +public class BroadcastOptions +{ + [Required] + public string RedisKeyPrefix { get; set; } = "broadcast:"; + + [Range(1, int.MaxValue)] + public int EntryTtlSeconds { get; set; } = 10800; + + [Range(1, int.MaxValue)] + public int MaxStatusBatchSize { get; set; } = 30; + + public bool NotifyOwnerOnPairRequest { get; set; } = true; + + public bool EnableBroadcasting { get; set; } = true; + + public bool EnableSyncshellBroadcastPayloads { get; set; } = true; + + public string PairRequestNotificationTemplate { get; set; } = "{DisplayName} sent you a pair request. To accept, right-click them, open the context menu, and send a request back."; + + [Range(1, int.MaxValue)] + public int PairRequestRateLimit { get; set; } = 5; + + [Range(1, int.MaxValue)] + public int PairRequestRateWindow { get; set; } = 60; +} diff --git a/LightlessSyncServer/LightlessSyncServer/Configuration/IBroadcastConfiguration.cs b/LightlessSyncServer/LightlessSyncServer/Configuration/IBroadcastConfiguration.cs new file mode 100644 index 0000000..3a38aa2 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Configuration/IBroadcastConfiguration.cs @@ -0,0 +1,20 @@ +using System; + +namespace LightlessSyncServer.Configuration; + +public interface IBroadcastConfiguration +{ + string RedisKeyPrefix { get; } + TimeSpan BroadcastEntryTtl { get; } + int MaxStatusBatchSize { get; } + bool NotifyOwnerOnPairRequest { get; } + bool EnableBroadcasting { get; } + bool EnableSyncshellBroadcastPayloads { get; } + + string BuildRedisKey(string hashedCid); + string BuildUserOwnershipKey(string userUid); + string BuildPairRequestNotification(); + + int PairRequestRateLimit { get; } + int PairRequestRateWindow { get; } +} diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.CharaData.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.CharaData.cs similarity index 100% rename from LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.CharaData.cs rename to LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.CharaData.cs diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.ClientStubs.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.ClientStubs.cs similarity index 93% rename from LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.ClientStubs.cs rename to LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.ClientStubs.cs index d148f91..7fbb954 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.ClientStubs.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.ClientStubs.cs @@ -10,41 +10,25 @@ namespace LightlessSyncServer.Hubs public partial class LightlessHub { public Task Client_DownloadReady(Guid requestId) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_GroupChangePermissions(GroupPermissionDto groupPermission) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_GroupDelete(GroupDto groupDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_GroupPairChangeUserInfo(GroupPairUserInfoDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_GroupPairJoined(GroupPairFullInfoDto groupPairInfoDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_GroupPairLeft(GroupPairDto groupPairDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_GroupSendFullInfo(GroupFullInfoDto groupInfo) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - + public Task Client_GroupSendProfile(GroupProfileDto groupProfile) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); public Task Client_GroupSendInfo(GroupInfoDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_ReceiveServerMessage(MessageSeverity messageSeverity, string message) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - + public Task Client_ReceiveBroadcastPairRequest(UserPairNotificationDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); public Task Client_UpdateSystemInfo(SystemInfoDto systemInfo) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_UserAddClientPair(UserPairDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_UserReceiveCharacterData(OnlineUserCharaDataDto dataDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_UserReceiveUploadStatus(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_UserRemoveClientPair(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_UserSendOffline(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_UserSendOnline(OnlineUserIdentDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_UserUpdateOtherPairPermissions(UserPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_UserUpdateProfile(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); - public Task Client_UserUpdateSelfPairPermissions(UserPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); public Task Client_UserUpdateDefaultPermissions(DefaultPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); public Task Client_UpdateUserIndividualPairStatusDto(UserIndividualPairStatusDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported"); diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.Functions.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Functions.cs similarity index 82% rename from LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.Functions.cs rename to LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Functions.cs index fafc432..b7b816d 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.Functions.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Functions.cs @@ -1,11 +1,14 @@ -using LightlessSyncShared.Models; -using Microsoft.EntityFrameworkCore; -using LightlessSyncServer.Utils; -using LightlessSyncShared.Utils; using LightlessSync.API.Data; using LightlessSync.API.Dto.Group; +using LightlessSyncServer.Utils; using LightlessSyncShared.Metrics; +using LightlessSyncShared.Models; +using LightlessSyncShared.Utils; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using StackExchange.Redis; +using System.Text.Json; +using System.Threading; namespace LightlessSyncServer.Hubs; @@ -94,9 +97,77 @@ public partial class LightlessHub private async Task RemoveUserFromRedis() { + if (IsValidHashedCid(UserCharaIdent)) + { + await _redis.RemoveAsync("CID:" + UserCharaIdent, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false); + } + await _redis.RemoveAsync("UID:" + UserUID, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false); } + private async Task EnsureUserHasVanity(string uid, CancellationToken cancellationToken = default) + { + cancellationToken = cancellationToken == default && _contextAccessor.HttpContext != null + ? RequestAbortedToken + : cancellationToken; + + var user = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == uid, cancellationToken).ConfigureAwait(false); + if (user == null) + { + _logger.LogCallWarning(LightlessHubLogger.Args("vanity check", uid, "missing user")); + return null; + } + + if (!user.HasVanity) + { + _logger.LogCallWarning(LightlessHubLogger.Args("vanity check", uid, "no vanity")); + return null; + } + + return user; + } + + private async Task ClearOwnedBroadcastLock() + { + var db = _redis.Database; + var ownershipKey = _broadcastConfiguration.BuildUserOwnershipKey(UserUID); + var ownedCidValue = await db.StringGetAsync(ownershipKey).ConfigureAwait(false); + if (ownedCidValue.IsNullOrEmpty) + return; + + var ownedCid = ownedCidValue.ToString(); + + await db.KeyDeleteAsync(ownershipKey, CommandFlags.FireAndForget).ConfigureAwait(false); + + if (string.IsNullOrEmpty(ownedCid)) + return; + + var broadcastKey = _broadcastConfiguration.BuildRedisKey(ownedCid); + var broadcastValue = await db.StringGetAsync(broadcastKey).ConfigureAwait(false); + if (broadcastValue.IsNullOrEmpty) + return; + + BroadcastRedisEntry? entry; + try + { + entry = JsonSerializer.Deserialize(broadcastValue!); + } + catch (Exception ex) + { + _logger.LogCallWarning(LightlessHubLogger.Args("failed to deserialize broadcast during disconnect cleanup", "CID", ownedCid, "Value", broadcastValue, "Error", ex)); + return; + } + + if (entry is null) + return; + + if (entry.HasOwner() && !entry.OwnedBy(UserUID)) + return; + + await db.KeyDeleteAsync(broadcastKey, CommandFlags.FireAndForget).ConfigureAwait(false); + _logger.LogCallInfo(LightlessHubLogger.Args("broadcast cleaned on disconnect", UserUID, "CID", entry.HashedCID, "GID", entry.GID)); + } + private async Task SendGroupDeletedToAll(List groupUsers) { foreach (var pair in groupUsers) @@ -165,7 +236,13 @@ public partial class LightlessHub private async Task UpdateUserOnRedis() { - await _redis.AddAsync("UID:" + UserUID, UserCharaIdent, TimeSpan.FromSeconds(60), StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false); + var hashedCid = UserCharaIdent; + if (IsValidHashedCid(hashedCid)) + { + await _redis.AddAsync("CID:" + hashedCid, UserUID, TimeSpan.FromSeconds(60), StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false); + } + + await _redis.AddAsync("UID:" + UserUID, hashedCid, TimeSpan.FromSeconds(60), StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false); } private async Task UserGroupLeave(GroupPair groupUserPair, string userIdent, Dictionary allUserPairs, string? uid = null) @@ -323,7 +400,12 @@ public partial class LightlessHub GID = user.Gid, Synced = user.Synced, OwnPermissions = ownperm, - OtherPermissions = otherperm + OtherPermissions = otherperm, + OtherUserIsAdmin = u.IsAdmin, + OtherUserIsModerator = u.IsModerator, + OtherUserHasVanity = u.HasVanity, + OtherUserTextColorHex = u.TextColorHex, + OtherUserTextGlowColorHex = u.TextGlowColorHex }; var resultList = await result.AsNoTracking().ToListAsync().ConfigureAwait(false); @@ -331,12 +413,18 @@ public partial class LightlessHub if (!resultList.Any()) return null; var groups = resultList.Select(g => g.GID).ToList(); - return new UserInfo(resultList[0].OtherUserAlias, + return new UserInfo( + resultList[0].OtherUserAlias, resultList.SingleOrDefault(p => string.IsNullOrEmpty(p.GID))?.Synced ?? false, resultList.Max(p => p.Synced), resultList.Select(p => string.IsNullOrEmpty(p.GID) ? Constants.IndividualKeyword : p.GID).ToList(), resultList[0].OwnPermissions, - resultList[0].OtherPermissions); + resultList[0].OtherPermissions, + resultList[0].OtherUserIsAdmin, + resultList[0].OtherUserIsModerator, + resultList[0].OtherUserHasVanity, + resultList[0].OtherUserTextColorHex ?? string.Empty, + resultList[0].OtherUserTextGlowColorHex ?? string.Empty); } private async Task> GetAllPairInfo(string uid) @@ -408,18 +496,29 @@ public partial class LightlessHub GID = user.Gid, Synced = user.Synced, OwnPermissions = ownperm, - OtherPermissions = otherperm + OtherPermissions = otherperm, + OtherUserIsAdmin = u.IsAdmin, + OtherUserIsModerator = u.IsModerator, + OtherUserHasVanity = u.HasVanity, + OtherUserTextColorHex = u.TextColorHex, + OtherUserTextGlowColorHex = u.TextGlowColorHex }; var resultList = await result.AsNoTracking().ToListAsync().ConfigureAwait(false); return resultList.GroupBy(g => g.OtherUserUID, StringComparer.Ordinal).ToDictionary(g => g.Key, g => { - return new UserInfo(g.First().OtherUserAlias, + return new UserInfo( + g.First().OtherUserAlias, g.SingleOrDefault(p => string.IsNullOrEmpty(p.GID))?.Synced ?? false, g.Max(p => p.Synced), g.Select(p => string.IsNullOrEmpty(p.GID) ? Constants.IndividualKeyword : p.GID).ToList(), g.First().OwnPermissions, - g.First().OtherPermissions); + g.First().OtherPermissions, + g.First().OtherUserIsAdmin, + g.First().OtherUserIsModerator, + g.First().OtherUserHasVanity, + g.First().OtherUserTextColorHex ?? string.Empty, + g.First().OtherUserTextGlowColorHex ?? string.Empty); }, StringComparer.Ordinal); } @@ -484,5 +583,17 @@ public partial class LightlessHub return await result.Distinct().AsNoTracking().ToListAsync().ConfigureAwait(false); } - public record UserInfo(string Alias, bool IndividuallyPaired, bool IsSynced, List GIDs, UserPermissionSet? OwnPermissions, UserPermissionSet? OtherPermissions); + public record UserInfo( + string Alias, + bool IndividuallyPaired, + bool IsSynced, + List GIDs, + UserPermissionSet? OwnPermissions, + UserPermissionSet? OtherPermissions, + bool IsAdmin, + bool IsModerator, + bool HasVanity, + string? TextColorHex, + string? TextGlowColorHex + ); } \ No newline at end of file diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.GposeLobby.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.GposeLobby.cs similarity index 100% rename from LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.GposeLobby.cs rename to LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.GposeLobby.cs diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.Groups.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Groups.cs similarity index 63% rename from LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.Groups.cs rename to LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Groups.cs index f9895ff..025554d 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.Groups.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Groups.cs @@ -1,6 +1,8 @@ -using LightlessSync.API.Data.Enum; +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; +using LightlessSync.API.Dto.User; using LightlessSyncServer.Utils; using LightlessSyncShared.Models; using LightlessSyncShared.Utils; @@ -57,7 +59,7 @@ public partial class LightlessHub group.PreferDisableAnimations = dto.Permissions.HasFlag(GroupPermissions.PreferDisableAnimations); group.PreferDisableVFX = dto.Permissions.HasFlag(GroupPermissions.PreferDisableVFX); - await DbContext.SaveChangesAsync().ConfigureAwait(false); + 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, dto.Permissions)).ConfigureAwait(false); @@ -135,7 +137,7 @@ public partial class LightlessHub 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().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))) @@ -147,29 +149,76 @@ public partial class LightlessHub 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).ConfigureAwait(false); - var existingJoinedGroups = await DbContext.GroupPairs.CountAsync(u => u.GroupUserUID == UserUID).ConfigureAwait(false); + 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 == "MSS-" + gid).ConfigureAwait(false)) + while (await DbContext.Groups.AnyAsync(g => g.GID == "LLS-" + gid, cancellationToken: RequestAbortedToken).ConfigureAwait(false)) { gid = StringUtils.GenerateRandomString(12); } - gid = "MSS-" + gid; + 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).ConfigureAwait(false); + UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); Group newGroup = new() { @@ -179,7 +228,8 @@ public partial class LightlessHub OwnerUID = UserUID, PreferDisableAnimations = defaultPermissions.DisableGroupAnimations, PreferDisableSounds = defaultPermissions.DisableGroupSounds, - PreferDisableVFX = defaultPermissions.DisableGroupVFX + PreferDisableVFX = defaultPermissions.DisableGroupVFX, + CreatedDate = currentTime, }; GroupPair initialPair = new() @@ -187,6 +237,8 @@ public partial class LightlessHub GroupGID = newGroup.GID, GroupUserUID = UserUID, IsPinned = true, + JoinedGroupOn = currentTime, + FromFinder = false, }; GroupPairPreferredPermission initialPrefPermissions = new() @@ -195,20 +247,20 @@ public partial class LightlessHub GroupGID = newGroup.GID, DisableSounds = defaultPermissions.DisableGroupSounds, DisableAnimations = defaultPermissions.DisableGroupAnimations, - DisableVFX = defaultPermissions.DisableGroupAnimations + DisableVFX = defaultPermissions.DisableGroupAnimations, }; - await DbContext.Groups.AddAsync(newGroup).ConfigureAwait(false); - await DbContext.GroupPairs.AddAsync(initialPair).ConfigureAwait(false); - await DbContext.GroupPairPreferredPermissions.AddAsync(initialPrefPermissions).ConfigureAwait(false); - await DbContext.SaveChangesAsync().ConfigureAwait(false); + 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).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))) .ConfigureAwait(false); - + _logger.LogCallInfo(LightlessHubLogger.Args(gid)); return new GroupJoinDto(newGroup.ToGroupData(), passwd, initialPrefPermissions.ToEnum()); @@ -262,10 +314,10 @@ public partial class LightlessHub _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); - var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false); + 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().ConfigureAwait(false); + await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); await Clients.Users(groupPairs.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false); @@ -278,9 +330,9 @@ public partial class LightlessHub _logger.LogCallInfo(LightlessHubLogger.Args(dto)); var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false); - if (!userHasRights) return new List(); + if (!userHasRights) return []; - var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync().ConfigureAwait(false); + 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, @@ -298,14 +350,14 @@ public partial class LightlessHub _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 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 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).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); + 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) @@ -326,10 +378,13 @@ public partial class LightlessHub _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 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 hashedPw = StringUtils.Sha256String(dto.Password); + 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); @@ -357,9 +412,11 @@ public partial class LightlessHub { GroupGID = group.GID, GroupUserUID = UserUID, + JoinedGroupOn = DateTime.UtcNow, + FromFinder = isHashedPassword }; - var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID).ConfigureAwait(false); + var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); if (preferredPermissions == null) { GroupPairPreferredPermission newPerms = new() @@ -369,7 +426,7 @@ public partial class LightlessHub DisableSounds = dto.GroupUserPreferredPermissions.IsDisableSounds(), DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX(), DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations(), - IsPaused = false + IsPaused = false, }; DbContext.Add(newPerms); @@ -384,13 +441,13 @@ public partial class LightlessHub DbContext.Update(preferredPermissions); } - await DbContext.GroupPairs.AddAsync(newPair).ConfigureAwait(false); + await DbContext.GroupPairs.AddAsync(newPair, RequestAbortedToken).ConfigureAwait(false); _logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "Success")); - await DbContext.SaveChangesAsync().ConfigureAwait(false); + await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); - var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync().ConfigureAwait(false); + var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync(cancellationToken: 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))).ConfigureAwait(false); @@ -518,11 +575,92 @@ public partial class LightlessHub } } - await DbContext.SaveChangesAsync().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) { @@ -541,8 +679,8 @@ public partial class LightlessHub .Where(g => g.GroupGID == dto.Group.GID) .ToListAsync().ConfigureAwait(false); var usersToPrune = allGroupUsers.Where(p => !p.IsPinned && !p.IsModerator - && p.GroupUserUID != UserUID - && p.Group.OwnerUID != p.GroupUserUID + && !string.Equals(p.GroupUserUID, UserUID, StringComparison.Ordinal) + && !string.Equals(p.Group.OwnerUID, p.GroupUserUID, StringComparison.Ordinal) && p.GroupUser.LastLoggedIn.AddDays(days) < DateTime.UtcNow); if (!execute) return usersToPrune.Count(); @@ -555,7 +693,7 @@ public partial class LightlessHub .Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false); } - await DbContext.SaveChangesAsync().ConfigureAwait(false); + await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); return usersToPrune.Count(); } @@ -579,15 +717,15 @@ public partial class LightlessHub 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().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().ConfigureAwait(false); + await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); var userIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false); if (userIdent == null) { - await DbContext.SaveChangesAsync().ConfigureAwait(false); + await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); return; } @@ -600,6 +738,75 @@ public partial class LightlessHub } } + [Authorize(Policy = "Identified")] + public async Task GroupGetProfile(GroupDto dto) + { + _logger.LogCallInfo(LightlessHubLogger.Args(dto)); + + var cancellationToken = RequestAbortedToken; + + var data = await DbContext.GroupProfiles + .FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID) + .ConfigureAwait(false); + + var profileDto = new GroupProfileDto(dto.Group, Description: null, Tags: null, PictureBase64: null); + + if (data is not null) + { + profileDto = profileDto with + { + Description = data.Description, + Tags = data.Tags, + PictureBase64 = data.Base64GroupProfileImage, + }; + + await Clients.User(UserUID) + .Client_GroupSendProfile(profileDto) + .ConfigureAwait(false); + } + + return profileDto; + } + + [Authorize(Policy = "Identified")] + public async Task GroupSetProfile(GroupProfileDto dto) + { + _logger.LogCallInfo(LightlessHubLogger.Args(dto)); + + if (dto.Group == null) return; + + var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false); + if (!hasRights) return; + + var groupProfileDb = await DbContext.GroupProfiles + .FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, + RequestAbortedToken) + .ConfigureAwait(false); + + if (groupProfileDb != null) + { + groupProfileDb.Description = dto.Description; + groupProfileDb.Tags = dto.Tags; + groupProfileDb.Base64GroupProfileImage = dto.PictureBase64; + } + else + { + var groupProfile = new GroupProfile + { + GroupGID = dto.Group.GID, + Description = dto.Description, + Tags = dto.Tags, + Base64GroupProfileImage = dto.PictureBase64, + }; + + await DbContext.GroupProfiles.AddAsync(groupProfile, + RequestAbortedToken) + .ConfigureAwait(false); + } + + await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false); + } + [Authorize(Policy = "Identified")] public async Task GroupSetUserInfo(GroupPairUserInfoDto dto) { @@ -629,9 +836,9 @@ public partial class LightlessHub userPair.IsModerator = false; } - await DbContext.SaveChangesAsync().ConfigureAwait(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().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); } @@ -640,17 +847,48 @@ public partial class LightlessHub { _logger.LogCallInfo(); - var groups = await DbContext.GroupPairs.Include(g => g.Group).Include(g => g.Group.Owner).Where(g => g.GroupUserUID == UserUID).AsNoTracking().ToListAsync().ConfigureAwait(false); - var preferredPermissions = (await DbContext.GroupPairPreferredPermissions.Where(u => u.UserUID == UserUID).ToListAsync().ConfigureAwait(false)) - .Where(u => groups.Exists(k => string.Equals(k.GroupGID, u.GroupGID, StringComparison.Ordinal))) - .ToDictionary(u => groups.First(f => string.Equals(f.GroupGID, u.GroupGID, StringComparison.Ordinal)), u => u); - var groupInfos = await DbContext.GroupPairs.Where(u => groups.Select(g => g.GroupGID).Contains(u.GroupGID) && (u.IsPinned || u.IsModerator)) - .ToListAsync().ConfigureAwait(false); + var ct = RequestAbortedToken; - return preferredPermissions.Select(g => new GroupFullInfoDto(g.Key.Group.ToGroupData(), g.Key.Group.Owner.ToUserData(), - g.Key.Group.ToEnum(), g.Value.ToEnum(), g.Key.ToEnum(), - groupInfos.Where(i => string.Equals(i.GroupGID, g.Key.GroupGID, StringComparison.Ordinal)) - .ToDictionary(i => i.GroupUserUID, i => i.ToEnum(), StringComparer.Ordinal))).ToList(); + 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(), + }) + .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 + ); + }),]; + return List; } [Authorize(Policy = "Identified")] @@ -661,12 +899,97 @@ public partial class LightlessHub 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).ConfigureAwait(false); + 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().ConfigureAwait(false); + 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 (isOwner, _) = await TryValidateOwner(dto.GID).ConfigureAwait(false); + if (!isOwner) + { + _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 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; + } + + } \ No newline at end of file diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.Permissions.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Permissions.cs similarity index 100% rename from LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.Permissions.cs rename to LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.Permissions.cs diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs new file mode 100644 index 0000000..cf64d96 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs @@ -0,0 +1,1118 @@ +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.Group; +using LightlessSync.API.Dto.User; +using LightlessSyncServer.Utils; +using LightlessSyncShared.Metrics; +using LightlessSyncShared.Models; +using LightlessSyncShared.Utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using StackExchange.Redis; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace LightlessSyncServer.Hubs; + +public partial class LightlessHub +{ + private static readonly string[] AllowedExtensionsForGamePaths = { ".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb" }; + + [Authorize(Policy = "Identified")] + public async Task UserAddPair(UserDto dto) + { + _logger.LogCallInfo(LightlessHubLogger.Args(dto)); + + // don't allow adding nothing + var uid = dto.User.UID.Trim(); + if (string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(dto.User.UID)) return; + + // grab other user, check if it exists and if a pair already exists + var otherUser = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == uid || u.Alias == uid, cancellationToken: RequestAbortedToken).ConfigureAwait(false); + if (otherUser == null) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"Cannot pair with {dto.User.UID}, UID does not exist").ConfigureAwait(false); + return; + } + + if (string.Equals(otherUser.UID, UserUID, StringComparison.Ordinal)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"My god you can't pair with yourself why would you do that please stop").ConfigureAwait(false); + return; + } + + var existingEntry = + await DbContext.ClientPairs.AsNoTracking() + .FirstOrDefaultAsync(p => + p.User.UID == UserUID && p.OtherUserUID == otherUser.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); + + if (existingEntry != null) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"Cannot pair with {dto.User.UID}, already paired").ConfigureAwait(false); + return; + } + + // grab self create new client pair and save + var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false); + + _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); + + ClientPair wl = new() + { + OtherUser = otherUser, + User = user, + }; + await DbContext.ClientPairs.AddAsync(wl).ConfigureAwait(false); + + var existingData = await GetPairInfo(UserUID, otherUser.UID).ConfigureAwait(false); + + var permissions = existingData?.OwnPermissions; + if (permissions == null || !permissions.Sticky) + { + var ownDefaultPermissions = await DbContext.UserDefaultPreferredPermissions.AsNoTracking().SingleOrDefaultAsync(f => f.UserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); + + permissions = new UserPermissionSet() + { + User = user, + OtherUser = otherUser, + DisableAnimations = ownDefaultPermissions.DisableIndividualAnimations, + DisableSounds = ownDefaultPermissions.DisableIndividualSounds, + DisableVFX = ownDefaultPermissions.DisableIndividualVFX, + IsPaused = false, + Sticky = true + }; + + var existingDbPerms = await DbContext.Permissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.OtherUserUID == otherUser.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); + if (existingDbPerms == null) + { + await DbContext.Permissions.AddAsync(permissions).ConfigureAwait(false); + } + else + { + existingDbPerms.DisableAnimations = permissions.DisableAnimations; + existingDbPerms.DisableSounds = permissions.DisableSounds; + existingDbPerms.DisableVFX = permissions.DisableVFX; + existingDbPerms.IsPaused = false; + existingDbPerms.Sticky = true; + + DbContext.Permissions.Update(existingDbPerms); + } + } + + await DbContext.SaveChangesAsync().ConfigureAwait(false); + + // get the opposite entry of the client pair + var otherEntry = OppositeEntry(otherUser.UID); + var otherIdent = await GetUserIdent(otherUser.UID).ConfigureAwait(false); + + var otherPermissions = existingData?.OtherPermissions ?? null; + + var ownPerm = permissions.ToUserPermissions(setSticky: true); + var otherPerm = otherPermissions.ToUserPermissions(); + + var userPairResponse = new UserPairDto(otherUser.ToUserData(), + otherEntry == null ? IndividualPairStatus.OneSided : IndividualPairStatus.Bidirectional, + ownPerm, otherPerm); + + await Clients.User(user.UID).Client_UserAddClientPair(userPairResponse).ConfigureAwait(false); + + // check if other user is online + if (otherIdent == null || otherEntry == null) return; + + // send push with update to other user if other user is online + await Clients.User(otherUser.UID) + .Client_UserUpdateOtherPairPermissions(new UserPermissionsDto(user.ToUserData(), + permissions.ToUserPermissions())).ConfigureAwait(false); + + await Clients.User(otherUser.UID) + .Client_UpdateUserIndividualPairStatusDto(new(user.ToUserData(), IndividualPairStatus.Bidirectional)) + .ConfigureAwait(false); + + if (!ownPerm.IsPaused() && !otherPerm.IsPaused()) + { + await Clients.User(UserUID).Client_UserSendOnline(new(otherUser.ToUserData(), otherIdent)).ConfigureAwait(false); + await Clients.User(otherUser.UID).Client_UserSendOnline(new(user.ToUserData(), UserCharaIdent)).ConfigureAwait(false); + } + } + + [Authorize(Policy = "Identified")] + public async Task TryPairWithContentId(string otherCid) + { + var myCid = UserCharaIdent; + + if (string.IsNullOrWhiteSpace(otherCid) || string.IsNullOrWhiteSpace(myCid)) + return; + + if (!IsValidHashedCid(myCid) || !IsValidHashedCid(otherCid)) + return; + + if (string.Equals(otherCid, myCid, StringComparison.Ordinal)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can't pair with yourself.").ConfigureAwait(false); + return; + } + + var throttleKey = $"pairing:rate:{UserUID}"; + var existingThrottle = await _redis.GetAsync(throttleKey).ConfigureAwait(false); + if (existingThrottle != null) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You're sending requests too quickly. Please wait a moment.").ConfigureAwait(false); + return; + } + await _redis.AddAsync(throttleKey, "true", TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + var reverseKey = $"pairing:{otherCid}:{myCid}"; + var forwardKey = $"pairing:{myCid}:{otherCid}"; + + var json = await _redis.GetAsync(reverseKey).ConfigureAwait(false); + if (json != null) + { + await _redis.RemoveAsync(reverseKey).ConfigureAwait(false); + + try + { + var payload = JsonSerializer.Deserialize(json); + if (payload?.UID == null || string.IsNullOrWhiteSpace(payload.HashedCid)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("invalid payload", reverseKey)); + return; + } + + if (!IsValidHashedCid(payload.HashedCid) || !string.Equals(payload.HashedCid, otherCid, StringComparison.Ordinal)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("pairing cid mismatch", reverseKey, payload.HashedCid, otherCid)); + return; + } + + var expectedRequesterUid = await _redis.GetAsync("CID:" + payload.HashedCid).ConfigureAwait(false); + if (!string.Equals(expectedRequesterUid, payload.UID, StringComparison.Ordinal)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("pairing uid mismatch", reverseKey, payload.HashedCid, payload.UID, expectedRequesterUid ?? "null")); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Pair request could not be validated.").ConfigureAwait(false); + return; + } + + if (payload.Timestamp == default || DateTime.UtcNow - payload.Timestamp > TimeSpan.FromMinutes(5)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("stale pairing payload", reverseKey, payload.Timestamp)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Pair request expired.").ConfigureAwait(false); + return; + } + + var sender = await _pairService.TryAddPairAsync(UserUID, payload.UID); + var receiver = await _pairService.TryAddPairAsync(payload.UID, UserUID); + + var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false); + var otherUser = await DbContext.Users.SingleAsync(u => u.UID == payload.UID).ConfigureAwait(false); + + var pairData = await GetPairInfo(UserUID, payload.UID).ConfigureAwait(false); + var permissions = await DbContext.Permissions.SingleAsync(p => + p.UserUID == UserUID && p.OtherUserUID == payload.UID).ConfigureAwait(false); + + var ownPerm = permissions.ToUserPermissions(setSticky: true); + var otherPerm = pairData?.OtherPermissions.ToUserPermissions() ?? new UserPermissions(); + + var individualPairStatus = pairData?.IsSynced == true + ? IndividualPairStatus.Bidirectional + : IndividualPairStatus.OneSided; + + var dtoA = new UserPairDto(otherUser.ToUserData(), individualPairStatus, ownPerm, otherPerm); + var dtoB = new UserPairDto(user.ToUserData(), individualPairStatus, otherPerm, ownPerm); + + await Clients.User(UserUID).Client_UserAddClientPair(dtoA).ConfigureAwait(false); + await Clients.User(payload.UID).Client_UserAddClientPair(dtoB).ConfigureAwait(false); + + await Clients.User(payload.UID) + .Client_UserUpdateOtherPairPermissions(new UserPermissionsDto(user.ToUserData(), permissions.ToUserPermissions())) + .ConfigureAwait(false); + + await Clients.User(payload.UID) + .Client_UpdateUserIndividualPairStatusDto(new(user.ToUserData(), individualPairStatus)) + .ConfigureAwait(false); + + await Clients.User(UserUID) + .Client_UpdateUserIndividualPairStatusDto(new(otherUser.ToUserData(), individualPairStatus)) + .ConfigureAwait(false); + + if (!ownPerm.IsPaused() && !otherPerm.IsPaused()) + { + var ident_sender = await GetUserIdent(UserUID).ConfigureAwait(false); + var ident_receiver = await GetUserIdent(payload.UID).ConfigureAwait(false); + + if (ident_sender != null && ident_receiver != null) + { + await Clients.User(UserUID).Client_UserSendOnline(new(otherUser.ToUserData(), ident_receiver)).ConfigureAwait(false); + await Clients.User(payload.UID).Client_UserSendOnline(new(user.ToUserData(), ident_sender)).ConfigureAwait(false); + } + } + + if (sender || receiver) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, $"paired with {payload.UID}.").ConfigureAwait(false); + await Clients.User(payload.UID).Client_ReceiveServerMessage(MessageSeverity.Information, $"paired with {UserUID}.").ConfigureAwait(false); + + _logger.LogCallInfo(LightlessHubLogger.Args("pair established", UserUID, payload.UID)); + } + + await _redis.RemoveAsync(forwardKey).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogCallWarning(LightlessHubLogger.Args("failed to process pairing", reverseKey, ex.Message)); + } + } + else + { + int maxRequests = _broadcastConfiguration.PairRequestRateLimit; + int requestWindow = _broadcastConfiguration.PairRequestRateWindow; + TimeSpan window = TimeSpan.FromSeconds(requestWindow); + var rateKey = $"pairing:limit:{UserUID}"; + var db = _redis.Database; + + var count = (long)await db.StringIncrementAsync(rateKey).ConfigureAwait(false); + if (count == 1) + { + await db.KeyExpireAsync(rateKey, window).ConfigureAwait(false); + } + + if (count > maxRequests) + { + var ttl = await db.KeyTimeToLiveAsync(rateKey).ConfigureAwait(false); + var secondsLeft = ttl?.TotalSeconds > 0 ? (int)ttl.Value.TotalSeconds : requestWindow; + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"You have exceeded the pair request limit. Please wait {secondsLeft} seconds before trying again.").ConfigureAwait(false); + return; + } + + var payload = new PairingPayload + { + UID = UserUID, + HashedCid = myCid, + Timestamp = DateTime.UtcNow + }; + + var payloadJson = JsonSerializer.Serialize(payload); + await _redis.AddAsync(forwardKey, payloadJson, TimeSpan.FromMinutes(5)).ConfigureAwait(false); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, $"Pair request sent. Waiting for the other player to confirm.").ConfigureAwait(false); + + _logger.LogCallInfo(LightlessHubLogger.Args("stored pairing request", myCid, otherCid)); + await NotifyBroadcastOwnerOfPairRequest(otherCid).ConfigureAwait(false); + } + } + + + private async Task NotifyBroadcastOwnerOfPairRequest(string targetHashedCid) + { + var myHashedCid = UserCharaIdent; + + if (!IsValidHashedCid(targetHashedCid) || !IsValidHashedCid(myHashedCid)) + return; + + if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.NotifyOwnerOnPairRequest) + return; + + var db = _redis.Database; + var broadcastKey = _broadcastConfiguration.BuildRedisKey(targetHashedCid); + RedisValueWithExpiry broadcastValue; + + try + { + broadcastValue = await db.StringGetWithExpiryAsync(broadcastKey).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogCallWarning(LightlessHubLogger.Args("failed to fetch broadcast for pair notify", "CID", targetHashedCid, "Error", ex)); + return; + } + + if (broadcastValue.Value.IsNullOrEmpty || broadcastValue.Expiry is null || broadcastValue.Expiry <= TimeSpan.Zero) + return; + + BroadcastRedisEntry? entry; + try + { + entry = JsonSerializer.Deserialize(broadcastValue.Value!); + } + catch (Exception ex) + { + _logger.LogCallWarning(LightlessHubLogger.Args("failed to deserialize broadcast for pair notify", "CID", targetHashedCid, "Value", broadcastValue.Value, "Error", ex)); + return; + } + + if (entry is null || !string.Equals(entry.HashedCID, targetHashedCid, StringComparison.Ordinal)) + return; + + if (!entry.HasOwner()) + return; + + if (string.Equals(entry.OwnerUID, UserUID, StringComparison.Ordinal)) + return; + + var senderAlias = Context.User?.Claims?.SingleOrDefault(c => string.Equals(c.Type, LightlessClaimTypes.Alias, StringComparison.Ordinal))?.Value; + //var displayName = string.IsNullOrWhiteSpace(senderAlias) ? UserUID : senderAlias; + var message = _broadcastConfiguration.BuildPairRequestNotification(); + + var dto = new UserPairNotificationDto{myHashedCid = myHashedCid, message = message}; + + await Clients.User(entry.OwnerUID).Client_ReceiveBroadcastPairRequest(dto).ConfigureAwait(false); + } + private class PairingPayload + { + public string UID { get; set; } = string.Empty; + public string HashedCid { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + } + + public class BroadcastRedisEntry + { + public string HashedCID { get; set; } = string.Empty; + public string OwnerUID { get; set; } = string.Empty; + public string? GID { get; set; } + + public bool OwnedBy(string userUid) => !string.IsNullOrEmpty(userUid) && string.Equals(OwnerUID, userUid, StringComparison.Ordinal); + + public bool HasOwner() => !string.IsNullOrEmpty(OwnerUID); + } + + [Authorize(Policy = "Identified")] + public async Task SetBroadcastStatus(bool enabled, GroupBroadcastRequestDto? groupDto = null) + { + var hashedCid = UserCharaIdent; + + if (enabled && !_broadcastConfiguration.EnableBroadcasting) + { + _logger.LogCallWarning(LightlessHubLogger.Args("broadcast disabled", UserUID, "CID", hashedCid)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Broadcasting is currently disabled.").ConfigureAwait(false); + return; + } + + if (!IsValidHashedCid(hashedCid)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format for user ident", UserUID, "CID", hashedCid)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Invalid CID format.").ConfigureAwait(false); + return; + } + + if (hashedCid.All(c => c == '0')) + { + return; + } + + var db = _redis.Database; + var broadcastKey = _broadcastConfiguration.BuildRedisKey(hashedCid); + var ownershipKey = _broadcastConfiguration.BuildUserOwnershipKey(UserUID); + var ownedCidValue = await db.StringGetAsync(ownershipKey).ConfigureAwait(false); + var ownedCid = ownedCidValue.IsNullOrEmpty ? null : ownedCidValue.ToString(); + + if (enabled) + { + string? gid = null; + + if (!string.IsNullOrEmpty(ownedCid) && !string.Equals(ownedCid, hashedCid, StringComparison.Ordinal)) + { + var ownedBroadcastKey = _broadcastConfiguration.BuildRedisKey(ownedCid); + var ownedBroadcastValue = await db.StringGetAsync(ownedBroadcastKey).ConfigureAwait(false); + + if (ownedBroadcastValue.IsNullOrEmpty) + { + await db.KeyDeleteAsync(ownershipKey, CommandFlags.FireAndForget).ConfigureAwait(false); + ownedCid = null; + } + else + { + _logger.LogCallWarning(LightlessHubLogger.Args("multiple broadcast lock attempt", UserUID, "ExistingCID", ownedCid, "AttemptedCID", hashedCid)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You already have an active Lightfinder lock. Disable it before enabling another.").ConfigureAwait(false); + return; + } + } + + if (groupDto is not null) + { + if (!_broadcastConfiguration.EnableSyncshellBroadcastPayloads) + { + _logger.LogCallWarning(LightlessHubLogger.Args("syncshell broadcast disabled", UserUID, "CID", hashedCid, "GID", groupDto.GID)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Syncshell broadcasting is currently disabled.").ConfigureAwait(false); + return; + } + + groupDto.HashedCID = hashedCid; + + var valid = await SetGroupBroadcastStatus(groupDto).ConfigureAwait(false); + if (!valid) + return; + + gid = groupDto.GID; + } + + BroadcastRedisEntry? existingEntry = null; + var existingValue = await db.StringGetAsync(broadcastKey).ConfigureAwait(false); + if (!existingValue.IsNullOrEmpty) + { + try + { + existingEntry = JsonSerializer.Deserialize(existingValue!); + } + catch (Exception ex) + { + _logger.LogCallWarning(LightlessHubLogger.Args("failed to deserialize broadcast entry during enable", "CID", hashedCid, "Value", existingValue, "Error", ex)); + } + + if (existingEntry is not null && existingEntry.HasOwner() && !existingEntry.OwnedBy(UserUID)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("unauthorized attempt to take broadcast ownership", UserUID, "CID", hashedCid, "ExistingOwner", existingEntry.OwnerUID)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Another user is already broadcasting with that CID.").ConfigureAwait(false); + return; + } + } + + var entry = new BroadcastRedisEntry + { + HashedCID = hashedCid, + OwnerUID = UserUID, + GID = gid, + }; + + var json = JsonSerializer.Serialize(entry); + await db.StringSetAsync(broadcastKey, json, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); + await db.StringSetAsync(ownershipKey, hashedCid, _broadcastConfiguration.BroadcastEntryTtl).ConfigureAwait(false); + _logger.LogCallInfo(LightlessHubLogger.Args("broadcast enabled", hashedCid, "GID", gid)); + } + else + { + var value = await db.StringGetAsync(broadcastKey).ConfigureAwait(false); + if (value.IsNullOrEmpty) + return; + + BroadcastRedisEntry? entry; + try + { + entry = JsonSerializer.Deserialize(value!); + } + catch (Exception ex) + { + _logger.LogCallWarning(LightlessHubLogger.Args("failed to deserialize broadcast entry during removal", "CID", hashedCid, "Value", value, "Error", ex)); + return; + } + + if (entry is null || !string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("unauthorized attempt to remove broadcast", UserUID, "CID", hashedCid, "Stored", entry?.HashedCID)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You can only disable your own broadcast. :3").ConfigureAwait(false); + return; + } + + if (entry.HasOwner() && !entry.OwnedBy(UserUID)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("unauthorized attempt to remove broadcast", UserUID, "CID", hashedCid, "Owner", entry.OwnerUID)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "You can only disable your own broadcast. :3").ConfigureAwait(false); + return; + } + + await db.KeyDeleteAsync(broadcastKey).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(ownedCid) && string.Equals(ownedCid, hashedCid, StringComparison.Ordinal)) + { + await db.KeyDeleteAsync(ownershipKey).ConfigureAwait(false); + } + + _logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID)); + } + } + + private static bool IsValidHashedCid(string? cid) + { + if (string.IsNullOrWhiteSpace(cid)) + return false; + + return cid.Length == 64 && cid.All(Uri.IsHexDigit) && !cid.All(c => c == '0'); + } + + [Authorize(Policy = "Identified")] + public async Task IsUserBroadcasting(string hashedCid) + { + if (!_broadcastConfiguration.EnableBroadcasting) + return null; + + if (!IsValidHashedCid(hashedCid)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format", UserUID, "CID", hashedCid)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Invalid CID format.").ConfigureAwait(false); + return null; + } + + var db = _redis.Database; + var key = _broadcastConfiguration.BuildRedisKey(hashedCid); + + var result = await db.StringGetWithExpiryAsync(key).ConfigureAwait(false); + if (result.Expiry is null || result.Expiry <= TimeSpan.Zero || result.Value.IsNullOrEmpty) + return null; + + BroadcastRedisEntry? entry; + try + { + entry = JsonSerializer.Deserialize(result.Value!); + } + catch + { + return null; + } + + if (entry is not null && !string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("mismatched broadcast entry", "CID", hashedCid, "EntryCID", entry.HashedCID)); + return null; + } + + var dto = new BroadcastStatusInfoDto + { + HashedCID = entry?.HashedCID ?? hashedCid, + IsBroadcasting = true, + TTL = result.Expiry, + GID = entry?.GID + }; + + _logger.LogCallInfo(LightlessHubLogger.Args("checked broadcast status", hashedCid, "TTL", result.Expiry, "GID", dto.GID)); + return dto; + } + + [Authorize(Policy = "Identified")] + public async Task GetBroadcastTtl() + { + if (!_broadcastConfiguration.EnableBroadcasting) + return null; + + var hashedCid = UserCharaIdent; + + if (!IsValidHashedCid(hashedCid)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format for user ident", UserUID, "CID", hashedCid)); + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Invalid CID format.").ConfigureAwait(false); + return null; + } + + if (hashedCid.All(c => c == '0')) + { + return null; + } + + var db = _redis.Database; + var key = _broadcastConfiguration.BuildRedisKey(hashedCid); + + var value = await db.StringGetAsync(key).ConfigureAwait(false); + if (value.IsNullOrEmpty) + return null; + + BroadcastRedisEntry? entry; + try + { + entry = JsonSerializer.Deserialize(value!); + } + catch + { + _logger.LogCallWarning(LightlessHubLogger.Args("invalid broadcast entry format", "CID", hashedCid)); + return null; + } + + if (entry is null) + { + _logger.LogCallWarning(LightlessHubLogger.Args("missing broadcast entry during ttl query", "CID", hashedCid)); + return null; + } + + if (!string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("unauthorized ttl query", UserUID, "CID", hashedCid, "EntryCID", entry.HashedCID)); + return null; + } + + if (entry.HasOwner() && !entry.OwnedBy(UserUID)) + { + _logger.LogCallWarning(LightlessHubLogger.Args("unauthorized ttl query", UserUID, "CID", hashedCid, "Owner", entry.OwnerUID)); + return null; + } + + var ttl = await db.KeyTimeToLiveAsync(key).ConfigureAwait(false); + if (ttl is null || ttl <= TimeSpan.Zero) + return null; + + _logger.LogCallInfo(LightlessHubLogger.Args("checked broadcast ttl", UserUID, "CID", hashedCid, "TTL", ttl, "GID", entry.GID)); + return ttl; + } + + + [Authorize(Policy = "Identified")] + public async Task AreUsersBroadcasting(List hashedCids) + { + if (!_broadcastConfiguration.EnableBroadcasting) + { + _logger.LogCallInfo(LightlessHubLogger.Args("batch broadcast disabled", "Count", hashedCids.Count)); + return null; + } + + var maxBatchSize = _broadcastConfiguration.MaxStatusBatchSize; + if (hashedCids.Count > maxBatchSize) + hashedCids = hashedCids.Take(maxBatchSize).ToList(); + + var db = _redis.Database; + + var tasks = new Dictionary>(hashedCids.Count); + foreach (var cid in hashedCids) + { + if (!IsValidHashedCid(cid)) + { + tasks[cid] = Task.FromResult(new RedisValueWithExpiry(RedisValue.Null, null)); + continue; + } + + var key = _broadcastConfiguration.BuildRedisKey(cid); + tasks[cid] = db.StringGetWithExpiryAsync(key); + } + + await Task.WhenAll(tasks.Values).ConfigureAwait(false); + + var results = new Dictionary(StringComparer.Ordinal); + + foreach (var (cid, task) in tasks) + { + var result = task.Result; + var raw = result.Value; + TimeSpan? ttl = result.Expiry; + + BroadcastRedisEntry? entry = null; + string? gid = null; + bool isBroadcasting = false; + + if (!raw.IsNullOrEmpty && ttl > TimeSpan.Zero) + { + isBroadcasting = true; + + try + { + entry = JsonSerializer.Deserialize(raw!); + 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)); + entry = null; + gid = null; + isBroadcasting = false; + } + else + { + gid = entry?.GID; + } + } + catch (Exception ex) + { + _logger.LogCallWarning(LightlessHubLogger.Args("deserialization failed", "CID", cid, "Raw", raw.ToString(), "Error", ex)); + } + } + + results[cid] = new BroadcastStatusInfoDto + { + HashedCID = entry?.HashedCID ?? cid, + IsBroadcasting = isBroadcasting, + TTL = ttl, + GID = gid, + }; + } + + _logger.LogCallInfo(LightlessHubLogger.Args("batch checked broadcast", "Count", hashedCids.Count)); + return new BroadcastStatusBatchDto { Results = results }; + } + + + [Authorize(Policy = "Identified")] + public async Task UserDelete() + { + _logger.LogCallInfo(); + + var userEntry = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); + var secondaryUsers = await DbContext.Auth.Include(u => u.User).Where(u => u.PrimaryUserUID == UserUID).Select(c => c.User).ToListAsync().ConfigureAwait(false); + foreach (var user in secondaryUsers) + { + await DeleteUser(user).ConfigureAwait(false); + } + + await DeleteUser(userEntry).ConfigureAwait(false); + } + + [Authorize(Policy = "Identified")] + public async Task> UserGetOnlinePairs(CensusDataDto? censusData) + { + _logger.LogCallInfo(); + + var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false); + var pairs = await GetOnlineUsers(allPairedUsers).ConfigureAwait(false); + + await SendOnlineToAllPairedUsers().ConfigureAwait(false); + + _lightlessCensus.PublishStatistics(UserUID, censusData); + + return pairs.Select(p => new OnlineUserIdentDto(new UserData(p.Key), p.Value)).ToList(); + } + + [Authorize(Policy = "Identified")] + public async Task> UserGetPairedClients() + { + _logger.LogCallInfo(); + + var pairs = await GetAllPairInfo(UserUID).ConfigureAwait(false); + return pairs.Select(p => + { + return new UserFullPairDto(new UserData(p.Key, p.Value.Alias, p.Value.IsAdmin, p.Value.IsModerator, p.Value.HasVanity, p.Value.TextColorHex, p.Value.TextGlowColorHex), + p.Value.ToIndividualPairStatus(), + p.Value.GIDs.Where(g => !string.Equals(g, Constants.IndividualKeyword, StringComparison.OrdinalIgnoreCase)).ToList(), + p.Value.OwnPermissions.ToUserPermissions(setSticky: true), + p.Value.OtherPermissions.ToUserPermissions()); + }).ToList(); + } + + [Authorize(Policy = "Identified")] + public async Task UserGetProfile(UserDto user) + { + _logger.LogCallInfo(LightlessHubLogger.Args(user)); + + var allUserPairs = await GetAllPairedUnpausedUsers().ConfigureAwait(false); + + if (!allUserPairs.Contains(user.User.UID, StringComparer.Ordinal) && !string.Equals(user.User.UID, UserUID, StringComparison.Ordinal)) + { + return new UserProfileDto(user.User, false, null, null, "Due to the pause status you cannot access this users profile."); + } + + var data = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == user.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); + if (data == null) return new UserProfileDto(user.User, false, null, null, null); + + if (data.FlaggedForReport) return new UserProfileDto(user.User, true, null, null, "This profile is flagged for report and pending evaluation"); + if (data.ProfileDisabled) return new UserProfileDto(user.User, true, null, null, "This profile was permanently disabled"); + + return new UserProfileDto(user.User, false, data.IsNSFW, data.Base64ProfileImage, data.UserDescription); + } + + [Authorize(Policy = "Identified")] + public async Task UserPushData(UserCharaDataMessageDto dto) + { + _logger.LogCallInfo(LightlessHubLogger.Args(dto.CharaData.FileReplacements.Count)); + + // check for honorific containing . and / + try + { + var honorificJson = Encoding.Default.GetString(Convert.FromBase64String(dto.CharaData.HonorificData)); + var deserialized = JsonSerializer.Deserialize(honorificJson); + if (deserialized.TryGetProperty("Title", out var honorificTitle)) + { + var title = honorificTitle.GetString().Normalize(NormalizationForm.FormKD); + if (UrlRegex().IsMatch(title)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your data was not pushed: The usage of URLs the Honorific titles is prohibited. Remove them to be able to continue to push data.").ConfigureAwait(false); + throw new HubException("Invalid data provided, Honorific title invalid: " + title); + } + } + } + catch (HubException) + { + throw; + } + catch (Exception) + { + // swallow + } + + bool hadInvalidData = false; + List invalidGamePaths = new(); + List invalidFileSwapPaths = new(); + foreach (var replacement in dto.CharaData.FileReplacements.SelectMany(p => p.Value)) + { + var invalidPaths = replacement.GamePaths.Where(p => !GamePathRegex().IsMatch(p)).ToList(); + invalidPaths.AddRange(replacement.GamePaths.Where(p => !AllowedExtensionsForGamePaths.Any(e => p.EndsWith(e, StringComparison.OrdinalIgnoreCase)))); + replacement.GamePaths = replacement.GamePaths.Where(p => !invalidPaths.Contains(p, StringComparer.OrdinalIgnoreCase)).ToArray(); + bool validGamePaths = replacement.GamePaths.Any(); + bool validHash = string.IsNullOrEmpty(replacement.Hash) || HashRegex().IsMatch(replacement.Hash); + bool validFileSwapPath = string.IsNullOrEmpty(replacement.FileSwapPath) || GamePathRegex().IsMatch(replacement.FileSwapPath); + if (!validGamePaths || !validHash || !validFileSwapPath) + { + _logger.LogCallWarning(LightlessHubLogger.Args("Invalid Data", "GamePaths", validGamePaths, string.Join(",", invalidPaths), "Hash", validHash, replacement.Hash, "FileSwap", validFileSwapPath, replacement.FileSwapPath)); + hadInvalidData = true; + if (!validFileSwapPath) invalidFileSwapPaths.Add(replacement.FileSwapPath); + if (!validGamePaths) invalidGamePaths.AddRange(replacement.GamePaths); + if (!validHash) invalidFileSwapPaths.Add(replacement.Hash); + } + } + + if (hadInvalidData) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "One or more of your supplied mods were rejected from the server. Consult /xllog for more information.").ConfigureAwait(false); + throw new HubException("Invalid data provided, contact the appropriate mod creator to resolve those issues" + + Environment.NewLine + + string.Join(Environment.NewLine, invalidGamePaths.Select(p => "Invalid Game Path: " + p)) + + Environment.NewLine + + string.Join(Environment.NewLine, invalidFileSwapPaths.Select(p => "Invalid FileSwap Path: " + p))); + } + + var recipientUids = dto.Recipients.Select(r => r.UID).ToList(); + bool allCached = await _onlineSyncedPairCacheService.AreAllPlayersCached(UserUID, + recipientUids, Context.ConnectionAborted).ConfigureAwait(false); + + if (!allCached) + { + var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false); + + recipientUids = allPairedUsers.Where(f => recipientUids.Contains(f, StringComparer.Ordinal)).ToList(); + + await _onlineSyncedPairCacheService.CachePlayers(UserUID, allPairedUsers, Context.ConnectionAborted).ConfigureAwait(false); + } + + _logger.LogCallInfo(LightlessHubLogger.Args(recipientUids.Count)); + + await Clients.Users(recipientUids).Client_UserReceiveCharacterData(new OnlineUserCharaDataDto(new UserData(UserUID), dto.CharaData)).ConfigureAwait(false); + + _lightlessCensus.PublishStatistics(UserUID, dto.CensusDataDto); + + _lightlessMetrics.IncCounter(MetricsAPI.CounterUserPushData); + _lightlessMetrics.IncCounter(MetricsAPI.CounterUserPushDataTo, recipientUids.Count); + } + + [Authorize(Policy = "Identified")] + public async Task UserUpdateVanityColors(UserVanityColorsDto dto) + { + if (dto == null) + { + throw new HubException("Vanity color payload required"); + } + + _logger.LogCallInfo(LightlessHubLogger.Args(dto.TextColorHex, dto.TextGlowColorHex)); + + var cooldownKey = $"vanity:colors:{UserUID}"; + var existingCooldown = await _redis.GetAsync(cooldownKey).ConfigureAwait(false); + if (existingCooldown != null) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You can update vanity colors once per minute.").ConfigureAwait(false); + return; + } + + var user = await EnsureUserHasVanity(UserUID).ConfigureAwait(false); + if (user == null) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Vanity privileges are required to update colors.").ConfigureAwait(false); + return; + } + + if (!TryNormalizeColor(dto.TextColorHex, out var textColor, out var textColorError)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, textColorError).ConfigureAwait(false); + return; + } + + if (!TryNormalizeColor(dto.TextGlowColorHex, out var textGlowColor, out var textGlowError)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, textGlowError).ConfigureAwait(false); + return; + } + + var currentColor = user.TextColorHex ?? string.Empty; + var currentGlow = user.TextGlowColorHex ?? string.Empty; + + if (string.Equals(currentColor, textColor, StringComparison.Ordinal) && + string.Equals(currentGlow, textGlowColor, StringComparison.Ordinal)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Vanity colors are already set to these values.").ConfigureAwait(false); + return; + } + + user.TextColorHex = textColor; + user.TextGlowColorHex = textGlowColor; + + await DbContext.SaveChangesAsync().ConfigureAwait(false); + await _redis.AddAsync(cooldownKey, "true", TimeSpan.FromMinutes(1)).ConfigureAwait(false); + + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Vanity colors updated.").ConfigureAwait(false); + } + + [Authorize(Policy = "Identified")] + public async Task UserRemovePair(UserDto dto) + { + _logger.LogCallInfo(LightlessHubLogger.Args(dto)); + + if (string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal)) return; + + // check if client pair even exists + ClientPair callerPair = + await DbContext.ClientPairs.SingleOrDefaultAsync(w => w.UserUID == UserUID && w.OtherUserUID == dto.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); + if (callerPair == null) return; + + var pairData = await GetPairInfo(UserUID, dto.User.UID).ConfigureAwait(false); + + // delete from database, send update info to users pair list + DbContext.ClientPairs.Remove(callerPair); + await DbContext.SaveChangesAsync().ConfigureAwait(false); + + _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); + + await Clients.User(UserUID).Client_UserRemoveClientPair(dto).ConfigureAwait(false); + + // check if opposite entry exists + if (!pairData.IndividuallyPaired) return; + + // check if other user is online, if no then there is no need to do anything further + var otherIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false); + if (otherIdent == null) return; + + // if the other user had paused the user the state will be offline for either, do nothing + bool callerHadPaused = pairData.OwnPermissions?.IsPaused ?? false; + + // send updated individual pair status + await Clients.User(dto.User.UID) + .Client_UpdateUserIndividualPairStatusDto(new(new(UserUID), IndividualPairStatus.OneSided)) + .ConfigureAwait(false); + + UserPermissionSet? otherPermissions = pairData.OtherPermissions; + bool otherHadPaused = otherPermissions?.IsPaused ?? true; + + // if the either had paused, do nothing + if (callerHadPaused && otherHadPaused) return; + + var currentPairData = await GetPairInfo(UserUID, dto.User.UID).ConfigureAwait(false); + + // if neither user had paused each other and either is not in an unpaused group with each other, change state to offline + if (!currentPairData?.IsSynced ?? true) + { + await Clients.User(UserUID).Client_UserSendOffline(dto).ConfigureAwait(false); + await Clients.User(dto.User.UID).Client_UserSendOffline(new(new(UserUID))).ConfigureAwait(false); + } + } + + [Authorize(Policy = "Identified")] + public async Task UserSetProfile(UserProfileDto dto) + { + _logger.LogCallInfo(LightlessHubLogger.Args(dto)); + + if (!string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal)) throw new HubException("Cannot modify profile data for anyone but yourself"); + + var existingData = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == dto.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false); + + if (existingData?.FlaggedForReport ?? false) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile is currently flagged for report and cannot be edited").ConfigureAwait(false); + return; + } + + if (existingData?.ProfileDisabled ?? false) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false); + return; + } + + if (!string.IsNullOrEmpty(dto.ProfilePictureBase64)) + { + byte[] imageData = Convert.FromBase64String(dto.ProfilePictureBase64); + using MemoryStream ms = new(imageData); + var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false); + if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your provided image file is not in PNG format").ConfigureAwait(false); + return; + } + using var image = Image.Load(imageData); + + if (image.Width > 256 || image.Height > 256 || (imageData.Length > 250 * 1024)) + { + await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your provided image file is larger than 256x256 or more than 250KiB.").ConfigureAwait(false); + return; + } + } + + if (existingData != null) + { + if (string.Equals("", dto.ProfilePictureBase64, StringComparison.OrdinalIgnoreCase)) + { + existingData.Base64ProfileImage = null; + } + else if (dto.ProfilePictureBase64 != null) + { + existingData.Base64ProfileImage = dto.ProfilePictureBase64; + } + + if (dto.IsNSFW != null) + { + existingData.IsNSFW = dto.IsNSFW.Value; + } + + if (dto.Description != null) + { + existingData.UserDescription = dto.Description; + } + } + else + { + UserProfileData userProfileData = new() + { + UserUID = dto.User.UID, + Base64ProfileImage = dto.ProfilePictureBase64 ?? null, + UserDescription = dto.Description ?? null, + IsNSFW = dto.IsNSFW ?? false + }; + + await DbContext.UserProfileData.AddAsync(userProfileData).ConfigureAwait(false); + } + + await DbContext.SaveChangesAsync().ConfigureAwait(false); + + var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false); + var pairs = await GetOnlineUsers(allPairedUsers).ConfigureAwait(false); + + await Clients.Users(pairs.Select(p => p.Key)).Client_UserUpdateProfile(new(dto.User)).ConfigureAwait(false); + await Clients.Caller.Client_UserUpdateProfile(new(dto.User)).ConfigureAwait(false); + } + + private static bool TryNormalizeColor(string? value, out string normalized, out string errorMessage) + { + if (string.IsNullOrWhiteSpace(value)) + { + normalized = string.Empty; + errorMessage = string.Empty; + return true; + } + + var trimmed = value.Trim(); + if (trimmed.StartsWith("#", StringComparison.Ordinal)) + { + trimmed = trimmed[1..]; + } + + if (trimmed.Length != 6 && trimmed.Length != 8) + { + normalized = string.Empty; + errorMessage = "Colors must contain 6 or 8 hexadecimal characters."; + return false; + } + + foreach (var ch in trimmed) + { + if (!Uri.IsHexDigit(ch)) + { + normalized = string.Empty; + errorMessage = "Colors may only contain hexadecimal characters."; + return false; + } + } + + normalized = "#" + trimmed.ToUpperInvariant(); + errorMessage = string.Empty; + return true; + } + + [GeneratedRegex(@"^([a-z0-9_ '+&,\.\-\{\}]+\/)+([a-z0-9_ '+&,\.\-\{\}]+\.[a-z]{3,4})$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ECMAScript)] + private static partial Regex GamePathRegex(); + + [GeneratedRegex(@"^[A-Z0-9]{40}$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ECMAScript)] + private static partial Regex HashRegex(); + + [GeneratedRegex("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}[\\.,][a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$")] + private static partial Regex UrlRegex(); + + private ClientPair OppositeEntry(string otherUID) => + DbContext.ClientPairs.AsNoTracking().SingleOrDefault(w => w.User.UID == otherUID && w.OtherUser.UID == UserUID); +} \ No newline at end of file diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs similarity index 92% rename from LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.cs rename to LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs index adf1fe0..318fc99 100644 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.cs +++ b/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.cs @@ -1,8 +1,9 @@ -using LightlessSync.API.Data; +using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto; using LightlessSync.API.SignalR; using LightlessSyncServer.Services; +using LightlessSyncServer.Configuration; using LightlessSyncServer.Utils; using LightlessSyncShared; using LightlessSyncShared.Data; @@ -15,6 +16,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using StackExchange.Redis.Extensions.Core.Abstractions; using System.Collections.Concurrent; +using System.Threading; namespace LightlessSyncServer.Hubs; @@ -24,10 +26,12 @@ public partial class LightlessHub : Hub, ILightlessHub private static readonly ConcurrentDictionary _userConnections = new(StringComparer.Ordinal); private readonly LightlessMetrics _lightlessMetrics; private readonly SystemInfoService _systemInfoService; + private readonly PairService _pairService; private readonly IHttpContextAccessor _contextAccessor; private readonly LightlessHubLogger _logger; private readonly string _shardName; private readonly int _maxExistingGroupsByUser; + private readonly IBroadcastConfiguration _broadcastConfiguration; private readonly int _maxJoinedGroupsByUser; private readonly int _maxGroupUserCount; private readonly IRedisDatabase _redis; @@ -41,11 +45,13 @@ public partial class LightlessHub : Hub, ILightlessHub private readonly int _maxCharaDataByUser; private readonly int _maxCharaDataByUserVanity; + private CancellationToken RequestAbortedToken => _contextAccessor.HttpContext?.RequestAborted ?? Context?.ConnectionAborted ?? CancellationToken.None; + public LightlessHub(LightlessMetrics lightlessMetrics, IDbContextFactory lightlessDbContextFactory, ILogger logger, SystemInfoService systemInfoService, IConfigurationService configuration, IHttpContextAccessor contextAccessor, IRedisDatabase redisDb, OnlineSyncedPairCacheService onlineSyncedPairCacheService, LightlessCensus lightlessCensus, - GPoseLobbyDistributionService gPoseLobbyDistributionService) + GPoseLobbyDistributionService gPoseLobbyDistributionService, IBroadcastConfiguration broadcastConfiguration, PairService pairService) { _lightlessMetrics = lightlessMetrics; _systemInfoService = systemInfoService; @@ -64,6 +70,8 @@ public partial class LightlessHub : Hub, ILightlessHub _gPoseLobbyDistributionService = gPoseLobbyDistributionService; _logger = new LightlessHubLogger(this, logger); _dbContextLazy = new Lazy(() => lightlessDbContextFactory.CreateDbContext()); + _broadcastConfiguration = broadcastConfiguration; + _pairService = pairService; } protected override void Dispose(bool disposing) @@ -109,6 +117,9 @@ public partial class LightlessHub : Hub, ILightlessHub ServerVersion = ILightlessHub.ApiVersion, IsAdmin = dbUser.IsAdmin, IsModerator = dbUser.IsModerator, + HasVanity = dbUser.HasVanity, + TextColorHex = dbUser.TextColorHex, + TextGlowColorHex = dbUser.TextGlowColorHex, ServerInfo = new ServerInfo() { MaxGroupsCreatedByUser = _maxExistingGroupsByUser, @@ -186,6 +197,8 @@ public partial class LightlessHub : Hub, ILightlessHub if (exception != null) _logger.LogCallWarning(LightlessHubLogger.Args(_contextAccessor.GetIpAddress(), Context.ConnectionId, exception.Message, exception.StackTrace)); + await ClearOwnedBroadcastLock().ConfigureAwait(false); + await RemoveUserFromRedis().ConfigureAwait(false); _lightlessCensus.ClearStatistics(UserUID); diff --git a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.User.cs b/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.User.cs deleted file mode 100644 index 53162a2..0000000 --- a/LightlessSyncServer/LightlessSyncServer/Hubs/MareHub.User.cs +++ /dev/null @@ -1,437 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; -using LightlessSync.API.Data; -using LightlessSync.API.Data.Enum; -using LightlessSync.API.Data.Extensions; -using LightlessSync.API.Dto.User; -using LightlessSyncServer.Utils; -using LightlessSyncShared.Metrics; -using LightlessSyncShared.Models; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.SignalR; -using Microsoft.EntityFrameworkCore; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; - -namespace LightlessSyncServer.Hubs; - -public partial class LightlessHub -{ - private static readonly string[] AllowedExtensionsForGamePaths = { ".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk" }; - - [Authorize(Policy = "Identified")] - public async Task UserAddPair(UserDto dto) - { - _logger.LogCallInfo(LightlessHubLogger.Args(dto)); - - // don't allow adding nothing - var uid = dto.User.UID.Trim(); - if (string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(dto.User.UID)) return; - - // grab other user, check if it exists and if a pair already exists - var otherUser = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == uid || u.Alias == uid).ConfigureAwait(false); - if (otherUser == null) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"Cannot pair with {dto.User.UID}, UID does not exist").ConfigureAwait(false); - return; - } - - if (string.Equals(otherUser.UID, UserUID, StringComparison.Ordinal)) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"My god you can't pair with yourself why would you do that please stop").ConfigureAwait(false); - return; - } - - var existingEntry = - await DbContext.ClientPairs.AsNoTracking() - .FirstOrDefaultAsync(p => - p.User.UID == UserUID && p.OtherUserUID == otherUser.UID).ConfigureAwait(false); - - if (existingEntry != null) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"Cannot pair with {dto.User.UID}, already paired").ConfigureAwait(false); - return; - } - - // grab self create new client pair and save - var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false); - - _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); - - ClientPair wl = new ClientPair() - { - OtherUser = otherUser, - User = user, - }; - await DbContext.ClientPairs.AddAsync(wl).ConfigureAwait(false); - - var existingData = await GetPairInfo(UserUID, otherUser.UID).ConfigureAwait(false); - - var permissions = existingData?.OwnPermissions; - if (permissions == null || !permissions.Sticky) - { - var ownDefaultPermissions = await DbContext.UserDefaultPreferredPermissions.AsNoTracking().SingleOrDefaultAsync(f => f.UserUID == UserUID).ConfigureAwait(false); - - permissions = new UserPermissionSet() - { - User = user, - OtherUser = otherUser, - DisableAnimations = ownDefaultPermissions.DisableIndividualAnimations, - DisableSounds = ownDefaultPermissions.DisableIndividualSounds, - DisableVFX = ownDefaultPermissions.DisableIndividualVFX, - IsPaused = false, - Sticky = true - }; - - var existingDbPerms = await DbContext.Permissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.OtherUserUID == otherUser.UID).ConfigureAwait(false); - if (existingDbPerms == null) - { - await DbContext.Permissions.AddAsync(permissions).ConfigureAwait(false); - } - else - { - existingDbPerms.DisableAnimations = permissions.DisableAnimations; - existingDbPerms.DisableSounds = permissions.DisableSounds; - existingDbPerms.DisableVFX = permissions.DisableVFX; - existingDbPerms.IsPaused = false; - existingDbPerms.Sticky = true; - - DbContext.Permissions.Update(existingDbPerms); - } - } - - await DbContext.SaveChangesAsync().ConfigureAwait(false); - - // get the opposite entry of the client pair - var otherEntry = OppositeEntry(otherUser.UID); - var otherIdent = await GetUserIdent(otherUser.UID).ConfigureAwait(false); - - var otherPermissions = existingData?.OtherPermissions ?? null; - - var ownPerm = permissions.ToUserPermissions(setSticky: true); - var otherPerm = otherPermissions.ToUserPermissions(); - - var userPairResponse = new UserPairDto(otherUser.ToUserData(), - otherEntry == null ? IndividualPairStatus.OneSided : IndividualPairStatus.Bidirectional, - ownPerm, otherPerm); - - await Clients.User(user.UID).Client_UserAddClientPair(userPairResponse).ConfigureAwait(false); - - // check if other user is online - if (otherIdent == null || otherEntry == null) return; - - // send push with update to other user if other user is online - await Clients.User(otherUser.UID) - .Client_UserUpdateOtherPairPermissions(new UserPermissionsDto(user.ToUserData(), - permissions.ToUserPermissions())).ConfigureAwait(false); - - await Clients.User(otherUser.UID) - .Client_UpdateUserIndividualPairStatusDto(new(user.ToUserData(), IndividualPairStatus.Bidirectional)) - .ConfigureAwait(false); - - if (!ownPerm.IsPaused() && !otherPerm.IsPaused()) - { - await Clients.User(UserUID).Client_UserSendOnline(new(otherUser.ToUserData(), otherIdent)).ConfigureAwait(false); - await Clients.User(otherUser.UID).Client_UserSendOnline(new(user.ToUserData(), UserCharaIdent)).ConfigureAwait(false); - } - } - - [Authorize(Policy = "Identified")] - public async Task UserDelete() - { - _logger.LogCallInfo(); - - var userEntry = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false); - var secondaryUsers = await DbContext.Auth.Include(u => u.User).Where(u => u.PrimaryUserUID == UserUID).Select(c => c.User).ToListAsync().ConfigureAwait(false); - foreach (var user in secondaryUsers) - { - await DeleteUser(user).ConfigureAwait(false); - } - - await DeleteUser(userEntry).ConfigureAwait(false); - } - - [Authorize(Policy = "Identified")] - public async Task> UserGetOnlinePairs(CensusDataDto? censusData) - { - _logger.LogCallInfo(); - - var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false); - var pairs = await GetOnlineUsers(allPairedUsers).ConfigureAwait(false); - - await SendOnlineToAllPairedUsers().ConfigureAwait(false); - - _lightlessCensus.PublishStatistics(UserUID, censusData); - - return pairs.Select(p => new OnlineUserIdentDto(new UserData(p.Key), p.Value)).ToList(); - } - - [Authorize(Policy = "Identified")] - public async Task> UserGetPairedClients() - { - _logger.LogCallInfo(); - - var pairs = await GetAllPairInfo(UserUID).ConfigureAwait(false); - return pairs.Select(p => - { - return new UserFullPairDto(new UserData(p.Key, p.Value.Alias), - p.Value.ToIndividualPairStatus(), - p.Value.GIDs.Where(g => !string.Equals(g, Constants.IndividualKeyword, StringComparison.OrdinalIgnoreCase)).ToList(), - p.Value.OwnPermissions.ToUserPermissions(setSticky: true), - p.Value.OtherPermissions.ToUserPermissions()); - }).ToList(); - } - - [Authorize(Policy = "Identified")] - public async Task UserGetProfile(UserDto user) - { - _logger.LogCallInfo(LightlessHubLogger.Args(user)); - - var allUserPairs = await GetAllPairedUnpausedUsers().ConfigureAwait(false); - - if (!allUserPairs.Contains(user.User.UID, StringComparer.Ordinal) && !string.Equals(user.User.UID, UserUID, StringComparison.Ordinal)) - { - return new UserProfileDto(user.User, false, null, null, "Due to the pause status you cannot access this users profile."); - } - - var data = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == user.User.UID).ConfigureAwait(false); - if (data == null) return new UserProfileDto(user.User, false, null, null, null); - - if (data.FlaggedForReport) return new UserProfileDto(user.User, true, null, null, "This profile is flagged for report and pending evaluation"); - if (data.ProfileDisabled) return new UserProfileDto(user.User, true, null, null, "This profile was permanently disabled"); - - return new UserProfileDto(user.User, false, data.IsNSFW, data.Base64ProfileImage, data.UserDescription); - } - - [Authorize(Policy = "Identified")] - public async Task UserPushData(UserCharaDataMessageDto dto) - { - _logger.LogCallInfo(LightlessHubLogger.Args(dto.CharaData.FileReplacements.Count)); - - // check for honorific containing . and / - try - { - var honorificJson = Encoding.Default.GetString(Convert.FromBase64String(dto.CharaData.HonorificData)); - var deserialized = JsonSerializer.Deserialize(honorificJson); - if (deserialized.TryGetProperty("Title", out var honorificTitle)) - { - var title = honorificTitle.GetString().Normalize(NormalizationForm.FormKD); - if (UrlRegex().IsMatch(title)) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your data was not pushed: The usage of URLs the Honorific titles is prohibited. Remove them to be able to continue to push data.").ConfigureAwait(false); - throw new HubException("Invalid data provided, Honorific title invalid: " + title); - } - } - } - catch (HubException) - { - throw; - } - catch (Exception) - { - // swallow - } - - bool hadInvalidData = false; - List invalidGamePaths = new(); - List invalidFileSwapPaths = new(); - foreach (var replacement in dto.CharaData.FileReplacements.SelectMany(p => p.Value)) - { - var invalidPaths = replacement.GamePaths.Where(p => !GamePathRegex().IsMatch(p)).ToList(); - invalidPaths.AddRange(replacement.GamePaths.Where(p => !AllowedExtensionsForGamePaths.Any(e => p.EndsWith(e, StringComparison.OrdinalIgnoreCase)))); - replacement.GamePaths = replacement.GamePaths.Where(p => !invalidPaths.Contains(p, StringComparer.OrdinalIgnoreCase)).ToArray(); - bool validGamePaths = replacement.GamePaths.Any(); - bool validHash = string.IsNullOrEmpty(replacement.Hash) || HashRegex().IsMatch(replacement.Hash); - bool validFileSwapPath = string.IsNullOrEmpty(replacement.FileSwapPath) || GamePathRegex().IsMatch(replacement.FileSwapPath); - if (!validGamePaths || !validHash || !validFileSwapPath) - { - _logger.LogCallWarning(LightlessHubLogger.Args("Invalid Data", "GamePaths", validGamePaths, string.Join(",", invalidPaths), "Hash", validHash, replacement.Hash, "FileSwap", validFileSwapPath, replacement.FileSwapPath)); - hadInvalidData = true; - if (!validFileSwapPath) invalidFileSwapPaths.Add(replacement.FileSwapPath); - if (!validGamePaths) invalidGamePaths.AddRange(replacement.GamePaths); - if (!validHash) invalidFileSwapPaths.Add(replacement.Hash); - } - } - - if (hadInvalidData) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "One or more of your supplied mods were rejected from the server. Consult /xllog for more information.").ConfigureAwait(false); - throw new HubException("Invalid data provided, contact the appropriate mod creator to resolve those issues" - + Environment.NewLine - + string.Join(Environment.NewLine, invalidGamePaths.Select(p => "Invalid Game Path: " + p)) - + Environment.NewLine - + string.Join(Environment.NewLine, invalidFileSwapPaths.Select(p => "Invalid FileSwap Path: " + p))); - } - - var recipientUids = dto.Recipients.Select(r => r.UID).ToList(); - bool allCached = await _onlineSyncedPairCacheService.AreAllPlayersCached(UserUID, - recipientUids, Context.ConnectionAborted).ConfigureAwait(false); - - if (!allCached) - { - var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false); - - recipientUids = allPairedUsers.Where(f => recipientUids.Contains(f, StringComparer.Ordinal)).ToList(); - - await _onlineSyncedPairCacheService.CachePlayers(UserUID, allPairedUsers, Context.ConnectionAborted).ConfigureAwait(false); - } - - _logger.LogCallInfo(LightlessHubLogger.Args(recipientUids.Count)); - - await Clients.Users(recipientUids).Client_UserReceiveCharacterData(new OnlineUserCharaDataDto(new UserData(UserUID), dto.CharaData)).ConfigureAwait(false); - - _lightlessCensus.PublishStatistics(UserUID, dto.CensusDataDto); - - _lightlessMetrics.IncCounter(MetricsAPI.CounterUserPushData); - _lightlessMetrics.IncCounter(MetricsAPI.CounterUserPushDataTo, recipientUids.Count); - } - - [Authorize(Policy = "Identified")] - public async Task UserRemovePair(UserDto dto) - { - _logger.LogCallInfo(LightlessHubLogger.Args(dto)); - - if (string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal)) return; - - // check if client pair even exists - ClientPair callerPair = - await DbContext.ClientPairs.SingleOrDefaultAsync(w => w.UserUID == UserUID && w.OtherUserUID == dto.User.UID).ConfigureAwait(false); - if (callerPair == null) return; - - var pairData = await GetPairInfo(UserUID, dto.User.UID).ConfigureAwait(false); - - // delete from database, send update info to users pair list - DbContext.ClientPairs.Remove(callerPair); - await DbContext.SaveChangesAsync().ConfigureAwait(false); - - _logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success")); - - await Clients.User(UserUID).Client_UserRemoveClientPair(dto).ConfigureAwait(false); - - // check if opposite entry exists - if (!pairData.IndividuallyPaired) return; - - // check if other user is online, if no then there is no need to do anything further - var otherIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false); - if (otherIdent == null) return; - - // if the other user had paused the user the state will be offline for either, do nothing - bool callerHadPaused = pairData.OwnPermissions?.IsPaused ?? false; - - // send updated individual pair status - await Clients.User(dto.User.UID) - .Client_UpdateUserIndividualPairStatusDto(new(new(UserUID), IndividualPairStatus.OneSided)) - .ConfigureAwait(false); - - UserPermissionSet? otherPermissions = pairData.OtherPermissions; - bool otherHadPaused = otherPermissions?.IsPaused ?? true; - - // if the either had paused, do nothing - if (callerHadPaused && otherHadPaused) return; - - var currentPairData = await GetPairInfo(UserUID, dto.User.UID).ConfigureAwait(false); - - // if neither user had paused each other and either is not in an unpaused group with each other, change state to offline - if (!currentPairData?.IsSynced ?? true) - { - await Clients.User(UserUID).Client_UserSendOffline(dto).ConfigureAwait(false); - await Clients.User(dto.User.UID).Client_UserSendOffline(new(new(UserUID))).ConfigureAwait(false); - } - } - - [Authorize(Policy = "Identified")] - public async Task UserSetProfile(UserProfileDto dto) - { - _logger.LogCallInfo(LightlessHubLogger.Args(dto)); - - if (!string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal)) throw new HubException("Cannot modify profile data for anyone but yourself"); - - var existingData = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == dto.User.UID).ConfigureAwait(false); - - if (existingData?.FlaggedForReport ?? false) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile is currently flagged for report and cannot be edited").ConfigureAwait(false); - return; - } - - if (existingData?.ProfileDisabled ?? false) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false); - return; - } - - if (!string.IsNullOrEmpty(dto.ProfilePictureBase64)) - { - byte[] imageData = Convert.FromBase64String(dto.ProfilePictureBase64); - using MemoryStream ms = new(imageData); - var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false); - if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase)) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your provided image file is not in PNG format").ConfigureAwait(false); - return; - } - using var image = Image.Load(imageData); - - if (image.Width > 256 || image.Height > 256 || (imageData.Length > 250 * 1024)) - { - await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your provided image file is larger than 256x256 or more than 250KiB.").ConfigureAwait(false); - return; - } - } - - if (existingData != null) - { - if (string.Equals("", dto.ProfilePictureBase64, StringComparison.OrdinalIgnoreCase)) - { - existingData.Base64ProfileImage = null; - } - else if (dto.ProfilePictureBase64 != null) - { - existingData.Base64ProfileImage = dto.ProfilePictureBase64; - } - - if (dto.IsNSFW != null) - { - existingData.IsNSFW = dto.IsNSFW.Value; - } - - if (dto.Description != null) - { - existingData.UserDescription = dto.Description; - } - } - else - { - UserProfileData userProfileData = new() - { - UserUID = dto.User.UID, - Base64ProfileImage = dto.ProfilePictureBase64 ?? null, - UserDescription = dto.Description ?? null, - IsNSFW = dto.IsNSFW ?? false - }; - - await DbContext.UserProfileData.AddAsync(userProfileData).ConfigureAwait(false); - } - - await DbContext.SaveChangesAsync().ConfigureAwait(false); - - var allPairedUsers = await GetAllPairedUnpausedUsers().ConfigureAwait(false); - var pairs = await GetOnlineUsers(allPairedUsers).ConfigureAwait(false); - - await Clients.Users(pairs.Select(p => p.Key)).Client_UserUpdateProfile(new(dto.User)).ConfigureAwait(false); - await Clients.Caller.Client_UserUpdateProfile(new(dto.User)).ConfigureAwait(false); - } - - [GeneratedRegex(@"^([a-z0-9_ '+&,\.\-\{\}]+\/)+([a-z0-9_ '+&,\.\-\{\}]+\.[a-z]{3,4})$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ECMAScript)] - private static partial Regex GamePathRegex(); - - [GeneratedRegex(@"^[A-Z0-9]{40}$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ECMAScript)] - private static partial Regex HashRegex(); - - [GeneratedRegex("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}[\\.,][a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$")] - private static partial Regex UrlRegex(); - - private ClientPair OppositeEntry(string otherUID) => - DbContext.ClientPairs.AsNoTracking().SingleOrDefault(w => w.User.UID == otherUID && w.OtherUser.UID == UserUID); -} \ No newline at end of file diff --git a/LightlessSyncServer/LightlessSyncServer/Services/PairService.cs b/LightlessSyncServer/LightlessSyncServer/Services/PairService.cs new file mode 100644 index 0000000..854472f --- /dev/null +++ b/LightlessSyncServer/LightlessSyncServer/Services/PairService.cs @@ -0,0 +1,108 @@ +using LightlessSyncShared.Data; +using LightlessSyncShared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; + +public class PairService +{ + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + + public PairService(IDbContextFactory dbFactory, ILogger logger) + { + _dbFactory = dbFactory; + _logger = logger; + } + + public async Task TryAddPairAsync(string userUid, string otherUid) + { + if (userUid == otherUid || string.IsNullOrWhiteSpace(userUid) || string.IsNullOrWhiteSpace(otherUid)) + return false; + + await using var db = await _dbFactory.CreateDbContextAsync(); + + var user = await db.Users.SingleOrDefaultAsync(u => u.UID == userUid); + var other = await db.Users.SingleOrDefaultAsync(u => u.UID == otherUid); + + if (user == null || other == null) + return false; + + bool modified = false; + + if (!await db.ClientPairs.AnyAsync(p => p.UserUID == userUid && p.OtherUserUID == otherUid)) + { + db.ClientPairs.Add(new ClientPair + { + UserUID = userUid, + OtherUserUID = otherUid + }); + modified = true; + } + + if (!await db.ClientPairs.AnyAsync(p => p.UserUID == otherUid && p.OtherUserUID == userUid)) + { + db.ClientPairs.Add(new ClientPair + { + UserUID = otherUid, + OtherUserUID = userUid + }); + modified = true; + } + + if (!await db.Permissions.AnyAsync(p => p.UserUID == userUid && p.OtherUserUID == otherUid)) + { + var defaultPerms = await db.UserDefaultPreferredPermissions + .SingleOrDefaultAsync(p => p.UserUID == userUid); + + if (defaultPerms != null) + { + db.Permissions.Add(new UserPermissionSet + { + UserUID = userUid, + OtherUserUID = otherUid, + DisableAnimations = defaultPerms.DisableIndividualAnimations, + DisableSounds = defaultPerms.DisableIndividualSounds, + DisableVFX = defaultPerms.DisableIndividualVFX, + IsPaused = false, + Sticky = true, + }); + modified = true; + } + } + + if (!await db.Permissions.AnyAsync(p => p.UserUID == otherUid && p.OtherUserUID == userUid)) + { + var defaultPerms = await db.UserDefaultPreferredPermissions + .SingleOrDefaultAsync(p => p.UserUID == otherUid); + + if (defaultPerms != null) + { + db.Permissions.Add(new UserPermissionSet + { + UserUID = otherUid, + OtherUserUID = userUid, + DisableAnimations = defaultPerms.DisableIndividualAnimations, + DisableSounds = defaultPerms.DisableIndividualSounds, + DisableVFX = defaultPerms.DisableIndividualVFX, + IsPaused = false, + Sticky = true, + }); + modified = true; + } + } + + if (modified) + { + await db.SaveChangesAsync(); + _logger.LogInformation("Mutual pair established between {UserUID} and {OtherUID}", userUid, otherUid); + } + else + { + _logger.LogInformation("Pair already exists between {UserUID} and {OtherUID}", userUid, otherUid); + } + + return modified; + } +} diff --git a/LightlessSyncServer/LightlessSyncServer/Startup.cs b/LightlessSyncServer/LightlessSyncServer/Startup.cs index d9bab3f..b647e85 100644 --- a/LightlessSyncServer/LightlessSyncServer/Startup.cs +++ b/LightlessSyncServer/LightlessSyncServer/Startup.cs @@ -2,6 +2,7 @@ using AspNetCoreRateLimit; using LightlessSync.API.SignalR; using LightlessSyncAuthService.Controllers; using LightlessSyncServer.Controllers; +using LightlessSyncServer.Configuration; using LightlessSyncServer.Hubs; using LightlessSyncServer.Services; using LightlessSyncShared.Data; @@ -87,7 +88,9 @@ public class Startup services.Configure(Configuration.GetRequiredSection("LightlessSync")); services.Configure(Configuration.GetRequiredSection("LightlessSync")); + services.Configure(Configuration.GetSection("Broadcast")); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -105,6 +108,7 @@ public class Startup services.AddSingleton(); services.AddHostedService(provider => provider.GetService()); services.AddHostedService(); + services.AddScoped(); } services.AddSingleton(); diff --git a/LightlessSyncServer/LightlessSyncServer/Utils/Extensions.cs b/LightlessSyncServer/LightlessSyncServer/Utils/Extensions.cs index 070ff9e..34bedac 100644 --- a/LightlessSyncServer/LightlessSyncServer/Utils/Extensions.cs +++ b/LightlessSyncServer/LightlessSyncServer/Utils/Extensions.cs @@ -10,7 +10,7 @@ public static class Extensions { public static GroupData ToGroupData(this Group group) { - return new GroupData(group.GID, group.Alias); + return new GroupData(group.GID, group.Alias, group.CreatedDate); } public static UserData ToUserData(this GroupPair pair) diff --git a/LightlessSyncServer/LightlessSyncServer/appsettings.json b/LightlessSyncServer/LightlessSyncServer/appsettings.json index 880bf73..991b30a 100644 --- a/LightlessSyncServer/LightlessSyncServer/appsettings.json +++ b/LightlessSyncServer/LightlessSyncServer/appsettings.json @@ -29,6 +29,17 @@ "ServiceAddress": "http://localhost:5002", "StaticFileServiceAddress": "http://localhost:5003" }, + "Broadcast": { + "RedisKeyPrefix": "broadcast:", + "EntryTtlSeconds": 10800, + "MaxStatusBatchSize": 30, + "NotifyOwnerOnPairRequest": true, + "EnableBroadcasting": true, + "EnableSyncshellBroadcastPayloads": true, + "PairRequestNotificationTemplate": "{DisplayName} sent you a pair request. To accept, right-click them, open the context menu, and send a request back.", + "PairRequestRateLimit": 5, + "PairRequestRateWindow": 60 + }, "AllowedHosts": "*", "Kestrel": { "Endpoints": { diff --git a/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs b/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs index bbf96c4..4a2951f 100644 --- a/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs +++ b/LightlessSyncServer/LightlessSyncServices/Discord/DiscordBot.cs @@ -1,4 +1,4 @@ -using Discord; +using Discord; using Discord.Interactions; using Discord.Rest; using Discord.WebSocket; @@ -384,13 +384,50 @@ internal class DiscordBot : IHostedService _logger.LogInformation($"Checking Group: {group.GID} [{group.Alias}], owned by {group.OwnerUID} ({groupPrimaryUser}), User in Roles: {string.Join(", ", discordUser?.RoleIds ?? new List())}"); - if (lodestoneUser == null || discordUser == null || !discordUser.RoleIds.Any(allowedRoleIds.Keys.Contains)) + var hasAllowedRole = lodestoneUser != null && discordUser != null && discordUser.RoleIds.Any(allowedRoleIds.Keys.Contains); + + if (!hasAllowedRole) { await _botServices.LogToChannel($"VANITY GID REMOVAL: <@{lodestoneUser?.DiscordId ?? 0}> ({lodestoneUser?.User?.UID}) - GID: {group.GID}, Vanity: {group.Alias}").ConfigureAwait(false); _logger.LogInformation($"User {lodestoneUser?.User?.UID ?? "unknown"} not in allowed roles, deleting group alias for {group.GID}"); group.Alias = null; db.Update(group); + + if (lodestoneUser?.User != null) + { + lodestoneUser.User.HasVanity = false; + db.Update(lodestoneUser.User); + + var secondaryUsers = await db.Auth.Include(u => u.User) + .Where(u => u.PrimaryUserUID == lodestoneUser.User.UID).ToListAsync().ConfigureAwait(false); + + foreach (var secondaryUser in secondaryUsers) + { + secondaryUser.User.HasVanity = false; + db.Update(secondaryUser.User); + } + } + + await db.SaveChangesAsync(token).ConfigureAwait(false); + } + else if (lodestoneUser?.User != null && !lodestoneUser.User.HasVanity) + { + lodestoneUser.User.HasVanity = true; + db.Update(lodestoneUser.User); + + var secondaryUsers = await db.Auth.Include(u => u.User) + .Where(u => u.PrimaryUserUID == lodestoneUser.User.UID).ToListAsync().ConfigureAwait(false); + + foreach (var secondaryUser in secondaryUsers) + { + if (!secondaryUser.User.HasVanity) + { + secondaryUser.User.HasVanity = true; + db.Update(secondaryUser.User); + } + } + await db.SaveChangesAsync(token).ConfigureAwait(false); } } @@ -400,22 +437,55 @@ internal class DiscordBot : IHostedService var discordUser = await restGuild.GetUserAsync(lodestoneAuth.DiscordId).ConfigureAwait(false); _logger.LogInformation($"Checking User: {lodestoneAuth.DiscordId}, {lodestoneAuth.User.UID} ({lodestoneAuth.User.Alias}), User in Roles: {string.Join(", ", discordUser?.RoleIds ?? new List())}"); - if (discordUser == null || !discordUser.RoleIds.Any(u => allowedRoleIds.Keys.Contains(u))) + var hasAllowedRole = discordUser != null && discordUser.RoleIds.Any(u => allowedRoleIds.Keys.Contains(u)); + + if (!hasAllowedRole) { _logger.LogInformation($"User {lodestoneAuth.User.UID} not in allowed roles, deleting alias"); await _botServices.LogToChannel($"VANITY UID REMOVAL: <@{lodestoneAuth.DiscordId}> - UID: {lodestoneAuth.User.UID}, Vanity: {lodestoneAuth.User.Alias}").ConfigureAwait(false); lodestoneAuth.User.Alias = null; + lodestoneAuth.User.HasVanity = false; var secondaryUsers = await db.Auth.Include(u => u.User).Where(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ToListAsync().ConfigureAwait(false); foreach (var secondaryUser in secondaryUsers) { _logger.LogInformation($"Secondary User {secondaryUser.User.UID} not in allowed roles, deleting alias"); secondaryUser.User.Alias = null; + secondaryUser.User.HasVanity = false; db.Update(secondaryUser.User); } db.Update(lodestoneAuth.User); await db.SaveChangesAsync(token).ConfigureAwait(false); } + else + { + var secondaryUsers = await db.Auth.Include(u => u.User) + .Where(u => u.PrimaryUserUID == lodestoneAuth.User.UID).ToListAsync().ConfigureAwait(false); + + var hasChanges = false; + + if (!lodestoneAuth.User.HasVanity) + { + lodestoneAuth.User.HasVanity = true; + db.Update(lodestoneAuth.User); + hasChanges = true; + } + + foreach (var secondaryUser in secondaryUsers) + { + if (!secondaryUser.User.HasVanity) + { + secondaryUser.User.HasVanity = true; + db.Update(secondaryUser.User); + hasChanges = true; + } + } + + if (hasChanges) + { + await db.SaveChangesAsync(token).ConfigureAwait(false); + } + } } private async Task UpdateStatusAsync(CancellationToken token) diff --git a/LightlessSyncServer/LightlessSyncServices/Discord/MareWizardModule.Vanity.cs b/LightlessSyncServer/LightlessSyncServices/Discord/MareWizardModule.Vanity.cs index 917dafc..fb075e3 100644 --- a/LightlessSyncServer/LightlessSyncServices/Discord/MareWizardModule.Vanity.cs +++ b/LightlessSyncServer/LightlessSyncServices/Discord/MareWizardModule.Vanity.cs @@ -1,4 +1,4 @@ -using Discord.Interactions; +using Discord.Interactions; using Discord; using Microsoft.EntityFrameworkCore; using System.Text.RegularExpressions; @@ -123,6 +123,20 @@ public partial class LightlessWizardModule { var user = await db.Users.SingleAsync(u => u.UID == uid).ConfigureAwait(false); user.Alias = desiredVanityUid; + user.HasVanity = true; + + var secondaryUsers = await db.Auth.Include(u => u.User) + .Where(u => u.PrimaryUserUID == user.UID).ToListAsync().ConfigureAwait(false); + + foreach (var secondaryUser in secondaryUsers) + { + if (!secondaryUser.User.HasVanity) + { + secondaryUser.User.HasVanity = true; + db.Update(secondaryUser.User); + } + } + db.Update(user); await db.SaveChangesAsync().ConfigureAwait(false); eb.WithColor(Color.Green); @@ -199,6 +213,25 @@ public partial class LightlessWizardModule { var group = await db.Groups.SingleAsync(u => u.GID == gid).ConfigureAwait(false); group.Alias = desiredVanityGid; + + var ownerAuth = await db.Auth.SingleOrDefaultAsync(u => u.UserUID == group.OwnerUID).ConfigureAwait(false); + var ownerUid = string.IsNullOrEmpty(ownerAuth?.PrimaryUserUID) ? group.OwnerUID : ownerAuth.PrimaryUserUID; + var ownerUser = await db.Users.SingleAsync(u => u.UID == ownerUid).ConfigureAwait(false); + ownerUser.HasVanity = true; + db.Update(ownerUser); + + var secondaryUsers = await db.Auth.Include(u => u.User) + .Where(u => u.PrimaryUserUID == ownerUser.UID).ToListAsync().ConfigureAwait(false); + + foreach (var secondaryUser in secondaryUsers) + { + if (!secondaryUser.User.HasVanity) + { + secondaryUser.User.HasVanity = true; + db.Update(secondaryUser.User); + } + } + db.Update(group); await db.SaveChangesAsync().ConfigureAwait(false); eb.WithColor(Color.Green); diff --git a/LightlessSyncServer/LightlessSyncShared/Data/MareDbContext.cs b/LightlessSyncServer/LightlessSyncShared/Data/MareDbContext.cs index dc7b17f..fe17706 100644 --- a/LightlessSyncServer/LightlessSyncShared/Data/MareDbContext.cs +++ b/LightlessSyncServer/LightlessSyncShared/Data/MareDbContext.cs @@ -53,6 +53,7 @@ public class LightlessDbContext : DbContext public DbSet CharaDataOriginalFiles { get; set; } public DbSet CharaDataPoses { get; set; } public DbSet CharaDataAllowances { get; set; } + public DbSet GroupProfiles { get; set; } protected override void OnModelCreating(ModelBuilder mb) { @@ -70,6 +71,14 @@ public class LightlessDbContext : DbContext mb.Entity().ToTable("banned_registrations"); mb.Entity().ToTable("groups"); mb.Entity().HasIndex(c => c.OwnerUID); + mb.Entity() + .Property(g => g.CreatedDate) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + mb.Entity() + .HasOne(g => g.Profile) + .WithOne(p => p.Group) + .HasForeignKey(p => p.GroupGID) + .IsRequired(false); mb.Entity().ToTable("group_pairs"); mb.Entity().HasKey(u => new { u.GroupGID, u.GroupUserUID }); mb.Entity().HasIndex(c => c.GroupUserUID); @@ -78,6 +87,9 @@ public class LightlessDbContext : DbContext mb.Entity().HasKey(u => new { u.GroupGID, u.BannedUserUID }); mb.Entity().HasIndex(c => c.BannedUserUID); mb.Entity().HasIndex(c => c.GroupGID); + mb.Entity().ToTable("group_profiles"); + mb.Entity().HasKey(u => u.GroupGID); + mb.Entity().HasIndex(c => c.GroupGID); mb.Entity().ToTable("group_temp_invites"); mb.Entity().HasKey(u => new { u.GroupGID, u.Invite }); mb.Entity().HasIndex(c => c.GroupGID); diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/20250916200240_AddGroupProfilesAndDates.Designer.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/20250916200240_AddGroupProfilesAndDates.Designer.cs new file mode 100644 index 0000000..0ad25ec --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/20250916200240_AddGroupProfilesAndDates.Designer.cs @@ -0,0 +1,1151 @@ +// +using System; +using LightlessSyncShared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LightlessSyncServer.Migrations +{ + [DbContext(typeof(LightlessDbContext))] + [Migration("20250916200240_AddGroupProfilesAndDates")] + partial class AddGroupProfilesAndDates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LightlessSyncShared.Models.Auth", b => + { + b.Property("HashedKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hashed_key"); + + b.Property("IsBanned") + .HasColumnType("boolean") + .HasColumnName("is_banned"); + + b.Property("MarkForBan") + .HasColumnType("boolean") + .HasColumnName("mark_for_ban"); + + b.Property("PrimaryUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("primary_user_uid"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("HashedKey") + .HasName("pk_auth"); + + b.HasIndex("PrimaryUserUID") + .HasDatabaseName("ix_auth_primary_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_auth_user_uid"); + + b.ToTable("auth", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Banned", b => + { + b.Property("CharacterIdentification") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("character_identification"); + + b.Property("BannedUid") + .HasColumnType("text") + .HasColumnName("banned_uid"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("CharacterIdentification") + .HasName("pk_banned_users"); + + b.ToTable("banned_users", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.BannedRegistrations", b => + { + b.Property("DiscordIdOrLodestoneAuth") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("discord_id_or_lodestone_auth"); + + b.HasKey("DiscordIdOrLodestoneAuth") + .HasName("pk_banned_registrations"); + + b.ToTable("banned_registrations", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("UploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.Property("AccessType") + .HasColumnType("integer") + .HasColumnName("access_type"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_date"); + + b.Property("CustomizeData") + .HasColumnType("text") + .HasColumnName("customize_data"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("DownloadCount") + .HasColumnType("integer") + .HasColumnName("download_count"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry_date"); + + b.Property("GlamourerData") + .HasColumnType("text") + .HasColumnName("glamourer_data"); + + b.Property("ManipulationData") + .HasColumnType("text") + .HasColumnName("manipulation_data"); + + b.Property("ShareType") + .HasColumnType("integer") + .HasColumnName("share_type"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_date"); + + b.HasKey("Id", "UploaderUID") + .HasName("pk_chara_data"); + + b.HasIndex("Id") + .HasDatabaseName("ix_chara_data_id"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_chara_data_uploader_uid"); + + b.ToTable("chara_data", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataAllowance", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowedGroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("allowed_group_gid"); + + b.Property("AllowedUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("allowed_user_uid"); + + b.HasKey("ParentId", "ParentUploaderUID", "Id") + .HasName("pk_chara_data_allowance"); + + b.HasIndex("AllowedGroupGID") + .HasDatabaseName("ix_chara_data_allowance_allowed_group_gid"); + + b.HasIndex("AllowedUserUID") + .HasDatabaseName("ix_chara_data_allowance_allowed_user_uid"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_allowance_parent_id"); + + b.ToTable("chara_data_allowance", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFile", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("FileCacheHash") + .HasColumnType("character varying(40)") + .HasColumnName("file_cache_hash"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_files"); + + b.HasIndex("FileCacheHash") + .HasDatabaseName("ix_chara_data_files_file_cache_hash"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_files_parent_id"); + + b.ToTable("chara_data_files", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFileSwap", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("FilePath") + .HasColumnType("text") + .HasColumnName("file_path"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_file_swaps"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_file_swaps_parent_id"); + + b.ToTable("chara_data_file_swaps", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataOriginalFile", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("Hash") + .HasColumnType("text") + .HasColumnName("hash"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_orig_files"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_orig_files_parent_id"); + + b.ToTable("chara_data_orig_files", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataPose", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("PoseData") + .HasColumnType("text") + .HasColumnName("pose_data"); + + b.Property("WorldData") + .HasColumnType("text") + .HasColumnName("world_data"); + + b.HasKey("ParentId", "ParentUploaderUID", "Id") + .HasName("pk_chara_data_poses"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_poses_parent_id"); + + b.ToTable("chara_data_poses", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ClientPair", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_client_pairs"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_client_pairs_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_client_pairs_user_uid"); + + b.ToTable("client_pairs", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.FileCache", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("RawSize") + .HasColumnType("bigint") + .HasColumnName("raw_size"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.Property("UploadDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("upload_date"); + + b.Property("Uploaded") + .HasColumnType("boolean") + .HasColumnName("uploaded"); + + b.Property("UploaderUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.HasKey("Hash") + .HasName("pk_file_caches"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_file_caches_uploader_uid"); + + b.ToTable("file_caches", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ForbiddenUploadEntry", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("ForbiddenBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("forbidden_by"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("Hash") + .HasName("pk_forbidden_upload_entries"); + + b.ToTable("forbidden_upload_entries", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.Property("GID") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gid"); + + b.Property("Alias") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alias"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_date") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("HashedPassword") + .HasColumnType("text") + .HasColumnName("hashed_password"); + + b.Property("InvitesEnabled") + .HasColumnType("boolean") + .HasColumnName("invites_enabled"); + + b.Property("OwnerUID") + .HasColumnType("character varying(10)") + .HasColumnName("owner_uid"); + + b.Property("PreferDisableAnimations") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_animations"); + + b.Property("PreferDisableSounds") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_sounds"); + + b.Property("PreferDisableVFX") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_vfx"); + + b.HasKey("GID") + .HasName("pk_groups"); + + b.HasIndex("OwnerUID") + .HasDatabaseName("ix_groups_owner_uid"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupBan", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("BannedUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("banned_user_uid"); + + b.Property("BannedByUID") + .HasColumnType("character varying(10)") + .HasColumnName("banned_by_uid"); + + b.Property("BannedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("banned_on"); + + b.Property("BannedReason") + .HasColumnType("text") + .HasColumnName("banned_reason"); + + b.HasKey("GroupGID", "BannedUserUID") + .HasName("pk_group_bans"); + + b.HasIndex("BannedByUID") + .HasDatabaseName("ix_group_bans_banned_by_uid"); + + b.HasIndex("BannedUserUID") + .HasDatabaseName("ix_group_bans_banned_user_uid"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_bans_group_gid"); + + b.ToTable("group_bans", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPair", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("GroupUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("group_user_uid"); + + b.Property("FromFinder") + .HasColumnType("boolean") + .HasColumnName("from_finder"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("IsPinned") + .HasColumnType("boolean") + .HasColumnName("is_pinned"); + + b.Property("JoinedGroupOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_group_on"); + + b.HasKey("GroupGID", "GroupUserUID") + .HasName("pk_group_pairs"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_pairs_group_gid"); + + b.HasIndex("GroupUserUID") + .HasDatabaseName("ix_group_pairs_group_user_uid"); + + b.ToTable("group_pairs", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPairPreferredPermission", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("DisableAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_animations"); + + b.Property("DisableSounds") + .HasColumnType("boolean") + .HasColumnName("disable_sounds"); + + b.Property("DisableVFX") + .HasColumnType("boolean") + .HasColumnName("disable_vfx"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.HasKey("UserUID", "GroupGID") + .HasName("pk_group_pair_preferred_permissions"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_pair_preferred_permissions_group_gid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_group_pair_preferred_permissions_user_uid"); + + b.ToTable("group_pair_preferred_permissions", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("Base64GroupProfileImage") + .HasColumnType("text") + .HasColumnName("base64group_profile_image"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Tags") + .HasColumnType("text") + .HasColumnName("tags"); + + b.HasKey("GroupGID") + .HasName("pk_group_profiles"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_profiles_group_gid"); + + b.ToTable("group_profiles", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("Invite") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("invite"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b.HasKey("GroupGID", "Invite") + .HasName("pk_group_temp_invites"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_temp_invites_group_gid"); + + b.HasIndex("Invite") + .HasDatabaseName("ix_group_temp_invites_invite"); + + b.ToTable("group_temp_invites", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.LodeStoneAuth", b => + { + b.Property("DiscordId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_id"); + + b.Property("HashedLodestoneId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("hashed_lodestone_id"); + + b.Property("LodestoneAuthString") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("lodestone_auth_string"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("DiscordId") + .HasName("pk_lodestone_auth"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_lodestone_auth_user_uid"); + + b.ToTable("lodestone_auth", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.User", b => + { + b.Property("UID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uid"); + + b.Property("Alias") + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("alias"); + + b.Property("IsAdmin") + .HasColumnType("boolean") + .HasColumnName("is_admin"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("LastLoggedIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_logged_in"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UID") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserDefaultPreferredPermission", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("DisableGroupAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_group_animations"); + + b.Property("DisableGroupSounds") + .HasColumnType("boolean") + .HasColumnName("disable_group_sounds"); + + b.Property("DisableGroupVFX") + .HasColumnType("boolean") + .HasColumnName("disable_group_vfx"); + + b.Property("DisableIndividualAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_individual_animations"); + + b.Property("DisableIndividualSounds") + .HasColumnType("boolean") + .HasColumnName("disable_individual_sounds"); + + b.Property("DisableIndividualVFX") + .HasColumnType("boolean") + .HasColumnName("disable_individual_vfx"); + + b.Property("IndividualIsSticky") + .HasColumnType("boolean") + .HasColumnName("individual_is_sticky"); + + b.HasKey("UserUID") + .HasName("pk_user_default_preferred_permissions"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_user_default_preferred_permissions_user_uid"); + + b.ToTable("user_default_preferred_permissions", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserPermissionSet", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("DisableAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_animations"); + + b.Property("DisableSounds") + .HasColumnType("boolean") + .HasColumnName("disable_sounds"); + + b.Property("DisableVFX") + .HasColumnType("boolean") + .HasColumnName("disable_vfx"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.Property("Sticky") + .HasColumnType("boolean") + .HasColumnName("sticky"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_user_permission_sets"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_user_permission_sets_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_user_permission_sets_user_uid"); + + b.HasIndex("UserUID", "OtherUserUID", "IsPaused") + .HasDatabaseName("ix_user_permission_sets_user_uid_other_user_uid_is_paused"); + + b.ToTable("user_permission_sets", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserProfileData", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("Base64ProfileImage") + .HasColumnType("text") + .HasColumnName("base64profile_image"); + + b.Property("FlaggedForReport") + .HasColumnType("boolean") + .HasColumnName("flagged_for_report"); + + b.Property("IsNSFW") + .HasColumnType("boolean") + .HasColumnName("is_nsfw"); + + b.Property("ProfileDisabled") + .HasColumnType("boolean") + .HasColumnName("profile_disabled"); + + b.Property("UserDescription") + .HasColumnType("text") + .HasColumnName("user_description"); + + b.HasKey("UserUID") + .HasName("pk_user_profile_data"); + + b.ToTable("user_profile_data", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Auth", b => + { + b.HasOne("LightlessSyncShared.Models.User", "PrimaryUser") + .WithMany() + .HasForeignKey("PrimaryUserUID") + .HasConstraintName("fk_auth_users_primary_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_auth_users_user_uid"); + + b.Navigation("PrimaryUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataAllowance", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "AllowedGroup") + .WithMany() + .HasForeignKey("AllowedGroupGID") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_allowance_groups_allowed_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "AllowedUser") + .WithMany() + .HasForeignKey("AllowedUserUID") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_allowance_users_allowed_user_uid"); + + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("AllowedIndividiuals") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_allowance_chara_data_parent_id_parent_uploader_u"); + + b.Navigation("AllowedGroup"); + + b.Navigation("AllowedUser"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFile", b => + { + b.HasOne("LightlessSyncShared.Models.FileCache", "FileCache") + .WithMany() + .HasForeignKey("FileCacheHash") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_files_files_file_cache_hash"); + + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("Files") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_files_chara_data_parent_id_parent_uploader_uid"); + + b.Navigation("FileCache"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFileSwap", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("FileSwaps") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_file_swaps_chara_data_parent_id_parent_uploader_"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataOriginalFile", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("OriginalFiles") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_orig_files_chara_data_parent_id_parent_uploader_"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataPose", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("Poses") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_poses_chara_data_parent_id_parent_uploader_uid"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ClientPair", b => + { + b.HasOne("LightlessSyncShared.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_other_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_user_uid"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.FileCache", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .HasConstraintName("fk_file_caches_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUID") + .HasConstraintName("fk_groups_users_owner_uid"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupBan", b => + { + b.HasOne("LightlessSyncShared.Models.User", "BannedBy") + .WithMany() + .HasForeignKey("BannedByUID") + .HasConstraintName("fk_group_bans_users_banned_by_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "BannedUser") + .WithMany() + .HasForeignKey("BannedUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_bans_users_banned_user_uid"); + + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_bans_groups_group_gid"); + + b.Navigation("BannedBy"); + + b.Navigation("BannedUser"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPair", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pairs_groups_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "GroupUser") + .WithMany() + .HasForeignKey("GroupUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pairs_users_group_user_uid"); + + b.Navigation("Group"); + + b.Navigation("GroupUser"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPairPreferredPermission", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pair_preferred_permissions_groups_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pair_preferred_permissions_users_user_uid"); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_profiles_groups_group_gid"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_temp_invites_groups_group_gid"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.LodeStoneAuth", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_lodestone_auth_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserDefaultPreferredPermission", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_default_preferred_permissions_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserPermissionSet", b => + { + b.HasOne("LightlessSyncShared.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_permission_sets_users_other_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_permission_sets_users_user_uid"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserProfileData", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_profile_data_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.Navigation("AllowedIndividiuals"); + + b.Navigation("FileSwaps"); + + b.Navigation("Files"); + + b.Navigation("OriginalFiles"); + + b.Navigation("Poses"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/20250916200240_AddGroupProfilesAndDates.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/20250916200240_AddGroupProfilesAndDates.cs new file mode 100644 index 0000000..523ceee --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/20250916200240_AddGroupProfilesAndDates.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LightlessSyncServer.Migrations +{ + /// + public partial class AddGroupProfilesAndDates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "created_date", + table: "groups", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP"); + + migrationBuilder.AddColumn( + name: "from_finder", + table: "group_pairs", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "joined_group_on", + table: "group_pairs", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateTable( + name: "group_profiles", + columns: table => new + { + group_gid = table.Column(type: "character varying(20)", nullable: false), + description = table.Column(type: "text", nullable: true), + tags = table.Column(type: "text", nullable: true), + base64group_profile_image = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_group_profiles", x => x.group_gid); + table.ForeignKey( + name: "fk_group_profiles_groups_group_gid", + column: x => x.group_gid, + principalTable: "groups", + principalColumn: "gid", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_group_profiles_group_gid", + table: "group_profiles", + column: "group_gid"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "group_profiles"); + + migrationBuilder.DropColumn( + name: "created_date", + table: "groups"); + + migrationBuilder.DropColumn( + name: "from_finder", + table: "group_pairs"); + + migrationBuilder.DropColumn( + name: "joined_group_on", + table: "group_pairs"); + } + } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/20250917004805_AddProfilesToGroup.Designer.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/20250917004805_AddProfilesToGroup.Designer.cs new file mode 100644 index 0000000..95bcd9b --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/20250917004805_AddProfilesToGroup.Designer.cs @@ -0,0 +1,1155 @@ +// +using System; +using LightlessSyncShared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LightlessSyncServer.Migrations +{ + [DbContext(typeof(LightlessDbContext))] + [Migration("20250917004805_AddProfilesToGroup")] + partial class AddProfilesToGroup + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LightlessSyncShared.Models.Auth", b => + { + b.Property("HashedKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hashed_key"); + + b.Property("IsBanned") + .HasColumnType("boolean") + .HasColumnName("is_banned"); + + b.Property("MarkForBan") + .HasColumnType("boolean") + .HasColumnName("mark_for_ban"); + + b.Property("PrimaryUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("primary_user_uid"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("HashedKey") + .HasName("pk_auth"); + + b.HasIndex("PrimaryUserUID") + .HasDatabaseName("ix_auth_primary_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_auth_user_uid"); + + b.ToTable("auth", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Banned", b => + { + b.Property("CharacterIdentification") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("character_identification"); + + b.Property("BannedUid") + .HasColumnType("text") + .HasColumnName("banned_uid"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("CharacterIdentification") + .HasName("pk_banned_users"); + + b.ToTable("banned_users", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.BannedRegistrations", b => + { + b.Property("DiscordIdOrLodestoneAuth") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("discord_id_or_lodestone_auth"); + + b.HasKey("DiscordIdOrLodestoneAuth") + .HasName("pk_banned_registrations"); + + b.ToTable("banned_registrations", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("UploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.Property("AccessType") + .HasColumnType("integer") + .HasColumnName("access_type"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_date"); + + b.Property("CustomizeData") + .HasColumnType("text") + .HasColumnName("customize_data"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("DownloadCount") + .HasColumnType("integer") + .HasColumnName("download_count"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry_date"); + + b.Property("GlamourerData") + .HasColumnType("text") + .HasColumnName("glamourer_data"); + + b.Property("ManipulationData") + .HasColumnType("text") + .HasColumnName("manipulation_data"); + + b.Property("ShareType") + .HasColumnType("integer") + .HasColumnName("share_type"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_date"); + + b.HasKey("Id", "UploaderUID") + .HasName("pk_chara_data"); + + b.HasIndex("Id") + .HasDatabaseName("ix_chara_data_id"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_chara_data_uploader_uid"); + + b.ToTable("chara_data", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataAllowance", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowedGroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("allowed_group_gid"); + + b.Property("AllowedUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("allowed_user_uid"); + + b.HasKey("ParentId", "ParentUploaderUID", "Id") + .HasName("pk_chara_data_allowance"); + + b.HasIndex("AllowedGroupGID") + .HasDatabaseName("ix_chara_data_allowance_allowed_group_gid"); + + b.HasIndex("AllowedUserUID") + .HasDatabaseName("ix_chara_data_allowance_allowed_user_uid"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_allowance_parent_id"); + + b.ToTable("chara_data_allowance", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFile", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("FileCacheHash") + .HasColumnType("character varying(40)") + .HasColumnName("file_cache_hash"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_files"); + + b.HasIndex("FileCacheHash") + .HasDatabaseName("ix_chara_data_files_file_cache_hash"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_files_parent_id"); + + b.ToTable("chara_data_files", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFileSwap", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("FilePath") + .HasColumnType("text") + .HasColumnName("file_path"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_file_swaps"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_file_swaps_parent_id"); + + b.ToTable("chara_data_file_swaps", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataOriginalFile", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("Hash") + .HasColumnType("text") + .HasColumnName("hash"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_orig_files"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_orig_files_parent_id"); + + b.ToTable("chara_data_orig_files", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataPose", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("PoseData") + .HasColumnType("text") + .HasColumnName("pose_data"); + + b.Property("WorldData") + .HasColumnType("text") + .HasColumnName("world_data"); + + b.HasKey("ParentId", "ParentUploaderUID", "Id") + .HasName("pk_chara_data_poses"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_poses_parent_id"); + + b.ToTable("chara_data_poses", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ClientPair", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_client_pairs"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_client_pairs_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_client_pairs_user_uid"); + + b.ToTable("client_pairs", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.FileCache", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("RawSize") + .HasColumnType("bigint") + .HasColumnName("raw_size"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.Property("UploadDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("upload_date"); + + b.Property("Uploaded") + .HasColumnType("boolean") + .HasColumnName("uploaded"); + + b.Property("UploaderUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.HasKey("Hash") + .HasName("pk_file_caches"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_file_caches_uploader_uid"); + + b.ToTable("file_caches", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ForbiddenUploadEntry", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("ForbiddenBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("forbidden_by"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("Hash") + .HasName("pk_forbidden_upload_entries"); + + b.ToTable("forbidden_upload_entries", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.Property("GID") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gid"); + + b.Property("Alias") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alias"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_date") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("HashedPassword") + .HasColumnType("text") + .HasColumnName("hashed_password"); + + b.Property("InvitesEnabled") + .HasColumnType("boolean") + .HasColumnName("invites_enabled"); + + b.Property("OwnerUID") + .HasColumnType("character varying(10)") + .HasColumnName("owner_uid"); + + b.Property("PreferDisableAnimations") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_animations"); + + b.Property("PreferDisableSounds") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_sounds"); + + b.Property("PreferDisableVFX") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_vfx"); + + b.HasKey("GID") + .HasName("pk_groups"); + + b.HasIndex("OwnerUID") + .HasDatabaseName("ix_groups_owner_uid"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupBan", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("BannedUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("banned_user_uid"); + + b.Property("BannedByUID") + .HasColumnType("character varying(10)") + .HasColumnName("banned_by_uid"); + + b.Property("BannedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("banned_on"); + + b.Property("BannedReason") + .HasColumnType("text") + .HasColumnName("banned_reason"); + + b.HasKey("GroupGID", "BannedUserUID") + .HasName("pk_group_bans"); + + b.HasIndex("BannedByUID") + .HasDatabaseName("ix_group_bans_banned_by_uid"); + + b.HasIndex("BannedUserUID") + .HasDatabaseName("ix_group_bans_banned_user_uid"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_bans_group_gid"); + + b.ToTable("group_bans", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPair", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("GroupUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("group_user_uid"); + + b.Property("FromFinder") + .HasColumnType("boolean") + .HasColumnName("from_finder"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("IsPinned") + .HasColumnType("boolean") + .HasColumnName("is_pinned"); + + b.Property("JoinedGroupOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_group_on"); + + b.HasKey("GroupGID", "GroupUserUID") + .HasName("pk_group_pairs"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_pairs_group_gid"); + + b.HasIndex("GroupUserUID") + .HasDatabaseName("ix_group_pairs_group_user_uid"); + + b.ToTable("group_pairs", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPairPreferredPermission", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("DisableAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_animations"); + + b.Property("DisableSounds") + .HasColumnType("boolean") + .HasColumnName("disable_sounds"); + + b.Property("DisableVFX") + .HasColumnType("boolean") + .HasColumnName("disable_vfx"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.HasKey("UserUID", "GroupGID") + .HasName("pk_group_pair_preferred_permissions"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_pair_preferred_permissions_group_gid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_group_pair_preferred_permissions_user_uid"); + + b.ToTable("group_pair_preferred_permissions", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.Property("GroupGID") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("Base64GroupProfileImage") + .HasColumnType("text") + .HasColumnName("base64group_profile_image"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Tags") + .HasColumnType("text") + .HasColumnName("tags"); + + b.HasKey("GroupGID") + .HasName("pk_group_profiles"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_profiles_group_gid"); + + b.ToTable("group_profiles", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("Invite") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("invite"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b.HasKey("GroupGID", "Invite") + .HasName("pk_group_temp_invites"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_temp_invites_group_gid"); + + b.HasIndex("Invite") + .HasDatabaseName("ix_group_temp_invites_invite"); + + b.ToTable("group_temp_invites", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.LodeStoneAuth", b => + { + b.Property("DiscordId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_id"); + + b.Property("HashedLodestoneId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("hashed_lodestone_id"); + + b.Property("LodestoneAuthString") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("lodestone_auth_string"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("DiscordId") + .HasName("pk_lodestone_auth"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_lodestone_auth_user_uid"); + + b.ToTable("lodestone_auth", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.User", b => + { + b.Property("UID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uid"); + + b.Property("Alias") + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("alias"); + + b.Property("IsAdmin") + .HasColumnType("boolean") + .HasColumnName("is_admin"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("LastLoggedIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_logged_in"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UID") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserDefaultPreferredPermission", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("DisableGroupAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_group_animations"); + + b.Property("DisableGroupSounds") + .HasColumnType("boolean") + .HasColumnName("disable_group_sounds"); + + b.Property("DisableGroupVFX") + .HasColumnType("boolean") + .HasColumnName("disable_group_vfx"); + + b.Property("DisableIndividualAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_individual_animations"); + + b.Property("DisableIndividualSounds") + .HasColumnType("boolean") + .HasColumnName("disable_individual_sounds"); + + b.Property("DisableIndividualVFX") + .HasColumnType("boolean") + .HasColumnName("disable_individual_vfx"); + + b.Property("IndividualIsSticky") + .HasColumnType("boolean") + .HasColumnName("individual_is_sticky"); + + b.HasKey("UserUID") + .HasName("pk_user_default_preferred_permissions"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_user_default_preferred_permissions_user_uid"); + + b.ToTable("user_default_preferred_permissions", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserPermissionSet", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("DisableAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_animations"); + + b.Property("DisableSounds") + .HasColumnType("boolean") + .HasColumnName("disable_sounds"); + + b.Property("DisableVFX") + .HasColumnType("boolean") + .HasColumnName("disable_vfx"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.Property("Sticky") + .HasColumnType("boolean") + .HasColumnName("sticky"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_user_permission_sets"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_user_permission_sets_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_user_permission_sets_user_uid"); + + b.HasIndex("UserUID", "OtherUserUID", "IsPaused") + .HasDatabaseName("ix_user_permission_sets_user_uid_other_user_uid_is_paused"); + + b.ToTable("user_permission_sets", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserProfileData", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("Base64ProfileImage") + .HasColumnType("text") + .HasColumnName("base64profile_image"); + + b.Property("FlaggedForReport") + .HasColumnType("boolean") + .HasColumnName("flagged_for_report"); + + b.Property("IsNSFW") + .HasColumnType("boolean") + .HasColumnName("is_nsfw"); + + b.Property("ProfileDisabled") + .HasColumnType("boolean") + .HasColumnName("profile_disabled"); + + b.Property("UserDescription") + .HasColumnType("text") + .HasColumnName("user_description"); + + b.HasKey("UserUID") + .HasName("pk_user_profile_data"); + + b.ToTable("user_profile_data", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Auth", b => + { + b.HasOne("LightlessSyncShared.Models.User", "PrimaryUser") + .WithMany() + .HasForeignKey("PrimaryUserUID") + .HasConstraintName("fk_auth_users_primary_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_auth_users_user_uid"); + + b.Navigation("PrimaryUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataAllowance", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "AllowedGroup") + .WithMany() + .HasForeignKey("AllowedGroupGID") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_allowance_groups_allowed_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "AllowedUser") + .WithMany() + .HasForeignKey("AllowedUserUID") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_allowance_users_allowed_user_uid"); + + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("AllowedIndividiuals") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_allowance_chara_data_parent_id_parent_uploader_u"); + + b.Navigation("AllowedGroup"); + + b.Navigation("AllowedUser"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFile", b => + { + b.HasOne("LightlessSyncShared.Models.FileCache", "FileCache") + .WithMany() + .HasForeignKey("FileCacheHash") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_files_files_file_cache_hash"); + + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("Files") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_files_chara_data_parent_id_parent_uploader_uid"); + + b.Navigation("FileCache"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFileSwap", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("FileSwaps") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_file_swaps_chara_data_parent_id_parent_uploader_"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataOriginalFile", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("OriginalFiles") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_orig_files_chara_data_parent_id_parent_uploader_"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataPose", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("Poses") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_poses_chara_data_parent_id_parent_uploader_uid"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ClientPair", b => + { + b.HasOne("LightlessSyncShared.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_other_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_user_uid"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.FileCache", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .HasConstraintName("fk_file_caches_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUID") + .HasConstraintName("fk_groups_users_owner_uid"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupBan", b => + { + b.HasOne("LightlessSyncShared.Models.User", "BannedBy") + .WithMany() + .HasForeignKey("BannedByUID") + .HasConstraintName("fk_group_bans_users_banned_by_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "BannedUser") + .WithMany() + .HasForeignKey("BannedUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_bans_users_banned_user_uid"); + + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_bans_groups_group_gid"); + + b.Navigation("BannedBy"); + + b.Navigation("BannedUser"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPair", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pairs_groups_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "GroupUser") + .WithMany() + .HasForeignKey("GroupUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pairs_users_group_user_uid"); + + b.Navigation("Group"); + + b.Navigation("GroupUser"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPairPreferredPermission", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pair_preferred_permissions_groups_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pair_preferred_permissions_users_user_uid"); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithOne("Profile") + .HasForeignKey("LightlessSyncShared.Models.GroupProfile", "GroupGID") + .HasConstraintName("fk_group_profiles_groups_group_gid"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_temp_invites_groups_group_gid"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.LodeStoneAuth", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_lodestone_auth_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserDefaultPreferredPermission", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_default_preferred_permissions_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserPermissionSet", b => + { + b.HasOne("LightlessSyncShared.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_permission_sets_users_other_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_permission_sets_users_user_uid"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserProfileData", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_profile_data_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.Navigation("AllowedIndividiuals"); + + b.Navigation("FileSwaps"); + + b.Navigation("Files"); + + b.Navigation("OriginalFiles"); + + b.Navigation("Poses"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.Navigation("Profile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/20250917004805_AddProfilesToGroup.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/20250917004805_AddProfilesToGroup.cs new file mode 100644 index 0000000..858430c --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/20250917004805_AddProfilesToGroup.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LightlessSyncServer.Migrations +{ + /// + public partial class AddProfilesToGroup : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_group_profiles_groups_group_gid", + table: "group_profiles"); + + migrationBuilder.AddForeignKey( + name: "fk_group_profiles_groups_group_gid", + table: "group_profiles", + column: "group_gid", + principalTable: "groups", + principalColumn: "gid"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_group_profiles_groups_group_gid", + table: "group_profiles"); + + migrationBuilder.AddForeignKey( + name: "fk_group_profiles_groups_group_gid", + table: "group_profiles", + column: "group_gid", + principalTable: "groups", + principalColumn: "gid", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/20250926133055_AddUserVanity.Designer.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/20250926133055_AddUserVanity.Designer.cs new file mode 100644 index 0000000..c2956de --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/20250926133055_AddUserVanity.Designer.cs @@ -0,0 +1,1169 @@ +// +using System; +using LightlessSyncShared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LightlessSyncServer.Migrations +{ + [DbContext(typeof(LightlessDbContext))] + [Migration("20250926133055_AddUserVanity")] + partial class AddUserVanity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LightlessSyncShared.Models.Auth", b => + { + b.Property("HashedKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hashed_key"); + + b.Property("IsBanned") + .HasColumnType("boolean") + .HasColumnName("is_banned"); + + b.Property("MarkForBan") + .HasColumnType("boolean") + .HasColumnName("mark_for_ban"); + + b.Property("PrimaryUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("primary_user_uid"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("HashedKey") + .HasName("pk_auth"); + + b.HasIndex("PrimaryUserUID") + .HasDatabaseName("ix_auth_primary_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_auth_user_uid"); + + b.ToTable("auth", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Banned", b => + { + b.Property("CharacterIdentification") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("character_identification"); + + b.Property("BannedUid") + .HasColumnType("text") + .HasColumnName("banned_uid"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("CharacterIdentification") + .HasName("pk_banned_users"); + + b.ToTable("banned_users", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.BannedRegistrations", b => + { + b.Property("DiscordIdOrLodestoneAuth") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("discord_id_or_lodestone_auth"); + + b.HasKey("DiscordIdOrLodestoneAuth") + .HasName("pk_banned_registrations"); + + b.ToTable("banned_registrations", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("UploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.Property("AccessType") + .HasColumnType("integer") + .HasColumnName("access_type"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_date"); + + b.Property("CustomizeData") + .HasColumnType("text") + .HasColumnName("customize_data"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("DownloadCount") + .HasColumnType("integer") + .HasColumnName("download_count"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry_date"); + + b.Property("GlamourerData") + .HasColumnType("text") + .HasColumnName("glamourer_data"); + + b.Property("ManipulationData") + .HasColumnType("text") + .HasColumnName("manipulation_data"); + + b.Property("ShareType") + .HasColumnType("integer") + .HasColumnName("share_type"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_date"); + + b.HasKey("Id", "UploaderUID") + .HasName("pk_chara_data"); + + b.HasIndex("Id") + .HasDatabaseName("ix_chara_data_id"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_chara_data_uploader_uid"); + + b.ToTable("chara_data", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataAllowance", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowedGroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("allowed_group_gid"); + + b.Property("AllowedUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("allowed_user_uid"); + + b.HasKey("ParentId", "ParentUploaderUID", "Id") + .HasName("pk_chara_data_allowance"); + + b.HasIndex("AllowedGroupGID") + .HasDatabaseName("ix_chara_data_allowance_allowed_group_gid"); + + b.HasIndex("AllowedUserUID") + .HasDatabaseName("ix_chara_data_allowance_allowed_user_uid"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_allowance_parent_id"); + + b.ToTable("chara_data_allowance", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFile", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("FileCacheHash") + .HasColumnType("character varying(40)") + .HasColumnName("file_cache_hash"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_files"); + + b.HasIndex("FileCacheHash") + .HasDatabaseName("ix_chara_data_files_file_cache_hash"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_files_parent_id"); + + b.ToTable("chara_data_files", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFileSwap", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("FilePath") + .HasColumnType("text") + .HasColumnName("file_path"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_file_swaps"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_file_swaps_parent_id"); + + b.ToTable("chara_data_file_swaps", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataOriginalFile", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("GamePath") + .HasColumnType("text") + .HasColumnName("game_path"); + + b.Property("Hash") + .HasColumnType("text") + .HasColumnName("hash"); + + b.HasKey("ParentId", "ParentUploaderUID", "GamePath") + .HasName("pk_chara_data_orig_files"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_orig_files_parent_id"); + + b.ToTable("chara_data_orig_files", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataPose", b => + { + b.Property("ParentId") + .HasColumnType("text") + .HasColumnName("parent_id"); + + b.Property("ParentUploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("parent_uploader_uid"); + + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("PoseData") + .HasColumnType("text") + .HasColumnName("pose_data"); + + b.Property("WorldData") + .HasColumnType("text") + .HasColumnName("world_data"); + + b.HasKey("ParentId", "ParentUploaderUID", "Id") + .HasName("pk_chara_data_poses"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_chara_data_poses_parent_id"); + + b.ToTable("chara_data_poses", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ClientPair", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_client_pairs"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_client_pairs_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_client_pairs_user_uid"); + + b.ToTable("client_pairs", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.FileCache", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("RawSize") + .HasColumnType("bigint") + .HasColumnName("raw_size"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.Property("UploadDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("upload_date"); + + b.Property("Uploaded") + .HasColumnType("boolean") + .HasColumnName("uploaded"); + + b.Property("UploaderUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.HasKey("Hash") + .HasName("pk_file_caches"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_file_caches_uploader_uid"); + + b.ToTable("file_caches", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ForbiddenUploadEntry", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("ForbiddenBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("forbidden_by"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("Hash") + .HasName("pk_forbidden_upload_entries"); + + b.ToTable("forbidden_upload_entries", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.Property("GID") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("gid"); + + b.Property("Alias") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alias"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_date") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("HashedPassword") + .HasColumnType("text") + .HasColumnName("hashed_password"); + + b.Property("InvitesEnabled") + .HasColumnType("boolean") + .HasColumnName("invites_enabled"); + + b.Property("OwnerUID") + .HasColumnType("character varying(10)") + .HasColumnName("owner_uid"); + + b.Property("PreferDisableAnimations") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_animations"); + + b.Property("PreferDisableSounds") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_sounds"); + + b.Property("PreferDisableVFX") + .HasColumnType("boolean") + .HasColumnName("prefer_disable_vfx"); + + b.HasKey("GID") + .HasName("pk_groups"); + + b.HasIndex("OwnerUID") + .HasDatabaseName("ix_groups_owner_uid"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupBan", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("BannedUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("banned_user_uid"); + + b.Property("BannedByUID") + .HasColumnType("character varying(10)") + .HasColumnName("banned_by_uid"); + + b.Property("BannedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("banned_on"); + + b.Property("BannedReason") + .HasColumnType("text") + .HasColumnName("banned_reason"); + + b.HasKey("GroupGID", "BannedUserUID") + .HasName("pk_group_bans"); + + b.HasIndex("BannedByUID") + .HasDatabaseName("ix_group_bans_banned_by_uid"); + + b.HasIndex("BannedUserUID") + .HasDatabaseName("ix_group_bans_banned_user_uid"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_bans_group_gid"); + + b.ToTable("group_bans", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPair", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("GroupUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("group_user_uid"); + + b.Property("FromFinder") + .HasColumnType("boolean") + .HasColumnName("from_finder"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("IsPinned") + .HasColumnType("boolean") + .HasColumnName("is_pinned"); + + b.Property("JoinedGroupOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_group_on"); + + b.HasKey("GroupGID", "GroupUserUID") + .HasName("pk_group_pairs"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_pairs_group_gid"); + + b.HasIndex("GroupUserUID") + .HasDatabaseName("ix_group_pairs_group_user_uid"); + + b.ToTable("group_pairs", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPairPreferredPermission", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("DisableAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_animations"); + + b.Property("DisableSounds") + .HasColumnType("boolean") + .HasColumnName("disable_sounds"); + + b.Property("DisableVFX") + .HasColumnType("boolean") + .HasColumnName("disable_vfx"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.HasKey("UserUID", "GroupGID") + .HasName("pk_group_pair_preferred_permissions"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_pair_preferred_permissions_group_gid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_group_pair_preferred_permissions_user_uid"); + + b.ToTable("group_pair_preferred_permissions", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.Property("GroupGID") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("Base64GroupProfileImage") + .HasColumnType("text") + .HasColumnName("base64group_profile_image"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Tags") + .HasColumnType("text") + .HasColumnName("tags"); + + b.HasKey("GroupGID") + .HasName("pk_group_profiles"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_profiles_group_gid"); + + b.ToTable("group_profiles", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => + { + b.Property("GroupGID") + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("Invite") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("invite"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b.HasKey("GroupGID", "Invite") + .HasName("pk_group_temp_invites"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_temp_invites_group_gid"); + + b.HasIndex("Invite") + .HasDatabaseName("ix_group_temp_invites_invite"); + + b.ToTable("group_temp_invites", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.LodeStoneAuth", b => + { + b.Property("DiscordId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_id"); + + b.Property("HashedLodestoneId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("hashed_lodestone_id"); + + b.Property("LodestoneAuthString") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("lodestone_auth_string"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("DiscordId") + .HasName("pk_lodestone_auth"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_lodestone_auth_user_uid"); + + b.ToTable("lodestone_auth", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.User", b => + { + b.Property("UID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uid"); + + b.Property("Alias") + .HasMaxLength(15) + .HasColumnType("character varying(15)") + .HasColumnName("alias"); + + b.Property("HasVanity") + .HasColumnType("boolean") + .HasColumnName("has_vanity"); + + b.Property("IsAdmin") + .HasColumnType("boolean") + .HasColumnName("is_admin"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("LastLoggedIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_logged_in"); + + b.Property("TextColorHex") + .HasMaxLength(9) + .HasColumnType("character varying(9)") + .HasColumnName("text_color_hex"); + + b.Property("TextGlowColorHex") + .HasMaxLength(9) + .HasColumnType("character varying(9)") + .HasColumnName("text_glow_color_hex"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UID") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserDefaultPreferredPermission", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("DisableGroupAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_group_animations"); + + b.Property("DisableGroupSounds") + .HasColumnType("boolean") + .HasColumnName("disable_group_sounds"); + + b.Property("DisableGroupVFX") + .HasColumnType("boolean") + .HasColumnName("disable_group_vfx"); + + b.Property("DisableIndividualAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_individual_animations"); + + b.Property("DisableIndividualSounds") + .HasColumnType("boolean") + .HasColumnName("disable_individual_sounds"); + + b.Property("DisableIndividualVFX") + .HasColumnType("boolean") + .HasColumnName("disable_individual_vfx"); + + b.Property("IndividualIsSticky") + .HasColumnType("boolean") + .HasColumnName("individual_is_sticky"); + + b.HasKey("UserUID") + .HasName("pk_user_default_preferred_permissions"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_user_default_preferred_permissions_user_uid"); + + b.ToTable("user_default_preferred_permissions", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserPermissionSet", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("DisableAnimations") + .HasColumnType("boolean") + .HasColumnName("disable_animations"); + + b.Property("DisableSounds") + .HasColumnType("boolean") + .HasColumnName("disable_sounds"); + + b.Property("DisableVFX") + .HasColumnType("boolean") + .HasColumnName("disable_vfx"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.Property("Sticky") + .HasColumnType("boolean") + .HasColumnName("sticky"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_user_permission_sets"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_user_permission_sets_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_user_permission_sets_user_uid"); + + b.HasIndex("UserUID", "OtherUserUID", "IsPaused") + .HasDatabaseName("ix_user_permission_sets_user_uid_other_user_uid_is_paused"); + + b.ToTable("user_permission_sets", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserProfileData", b => + { + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("Base64ProfileImage") + .HasColumnType("text") + .HasColumnName("base64profile_image"); + + b.Property("FlaggedForReport") + .HasColumnType("boolean") + .HasColumnName("flagged_for_report"); + + b.Property("IsNSFW") + .HasColumnType("boolean") + .HasColumnName("is_nsfw"); + + b.Property("ProfileDisabled") + .HasColumnType("boolean") + .HasColumnName("profile_disabled"); + + b.Property("UserDescription") + .HasColumnType("text") + .HasColumnName("user_description"); + + b.HasKey("UserUID") + .HasName("pk_user_profile_data"); + + b.ToTable("user_profile_data", (string)null); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Auth", b => + { + b.HasOne("LightlessSyncShared.Models.User", "PrimaryUser") + .WithMany() + .HasForeignKey("PrimaryUserUID") + .HasConstraintName("fk_auth_users_primary_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_auth_users_user_uid"); + + b.Navigation("PrimaryUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataAllowance", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "AllowedGroup") + .WithMany() + .HasForeignKey("AllowedGroupGID") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_allowance_groups_allowed_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "AllowedUser") + .WithMany() + .HasForeignKey("AllowedUserUID") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_allowance_users_allowed_user_uid"); + + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("AllowedIndividiuals") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_allowance_chara_data_parent_id_parent_uploader_u"); + + b.Navigation("AllowedGroup"); + + b.Navigation("AllowedUser"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFile", b => + { + b.HasOne("LightlessSyncShared.Models.FileCache", "FileCache") + .WithMany() + .HasForeignKey("FileCacheHash") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_chara_data_files_files_file_cache_hash"); + + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("Files") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_files_chara_data_parent_id_parent_uploader_uid"); + + b.Navigation("FileCache"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataFileSwap", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("FileSwaps") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_file_swaps_chara_data_parent_id_parent_uploader_"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataOriginalFile", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("OriginalFiles") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_orig_files_chara_data_parent_id_parent_uploader_"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaDataPose", b => + { + b.HasOne("LightlessSyncShared.Models.CharaData", "Parent") + .WithMany("Poses") + .HasForeignKey("ParentId", "ParentUploaderUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chara_data_poses_chara_data_parent_id_parent_uploader_uid"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.ClientPair", b => + { + b.HasOne("LightlessSyncShared.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_other_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_user_uid"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.FileCache", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .HasConstraintName("fk_file_caches_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.HasOne("LightlessSyncShared.Models.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUID") + .HasConstraintName("fk_groups_users_owner_uid"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupBan", b => + { + b.HasOne("LightlessSyncShared.Models.User", "BannedBy") + .WithMany() + .HasForeignKey("BannedByUID") + .HasConstraintName("fk_group_bans_users_banned_by_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "BannedUser") + .WithMany() + .HasForeignKey("BannedUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_bans_users_banned_user_uid"); + + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_bans_groups_group_gid"); + + b.Navigation("BannedBy"); + + b.Navigation("BannedUser"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPair", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pairs_groups_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "GroupUser") + .WithMany() + .HasForeignKey("GroupUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pairs_users_group_user_uid"); + + b.Navigation("Group"); + + b.Navigation("GroupUser"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupPairPreferredPermission", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pair_preferred_permissions_groups_group_gid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_pair_preferred_permissions_users_user_uid"); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithOne("Profile") + .HasForeignKey("LightlessSyncShared.Models.GroupProfile", "GroupGID") + .HasConstraintName("fk_group_profiles_groups_group_gid"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupGID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_temp_invites_groups_group_gid"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.LodeStoneAuth", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_lodestone_auth_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserDefaultPreferredPermission", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_default_preferred_permissions_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserPermissionSet", b => + { + b.HasOne("LightlessSyncShared.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_permission_sets_users_other_user_uid"); + + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_permission_sets_users_user_uid"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.UserProfileData", b => + { + b.HasOne("LightlessSyncShared.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_profile_data_users_user_uid"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.CharaData", b => + { + b.Navigation("AllowedIndividiuals"); + + b.Navigation("FileSwaps"); + + b.Navigation("Files"); + + b.Navigation("OriginalFiles"); + + b.Navigation("Poses"); + }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.Navigation("Profile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/20250926133055_AddUserVanity.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/20250926133055_AddUserVanity.cs new file mode 100644 index 0000000..714c6e1 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/20250926133055_AddUserVanity.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LightlessSyncServer.Migrations +{ + /// + public partial class AddUserVanity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "has_vanity", + table: "users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "text_color_hex", + table: "users", + type: "character varying(9)", + maxLength: 9, + nullable: true); + + migrationBuilder.AddColumn( + name: "text_glow_color_hex", + table: "users", + type: "character varying(9)", + maxLength: 9, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "has_vanity", + table: "users"); + + migrationBuilder.DropColumn( + name: "text_color_hex", + table: "users"); + + migrationBuilder.DropColumn( + name: "text_glow_color_hex", + table: "users"); + } + } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Migrations/LightlessDbContextModelSnapshot.cs b/LightlessSyncServer/LightlessSyncShared/Migrations/LightlessDbContextModelSnapshot.cs index e2ce4c6..578c910 100644 --- a/LightlessSyncServer/LightlessSyncShared/Migrations/LightlessDbContextModelSnapshot.cs +++ b/LightlessSyncServer/LightlessSyncShared/Migrations/LightlessDbContextModelSnapshot.cs @@ -430,6 +430,12 @@ namespace LightlessSyncServer.Migrations .HasColumnType("character varying(50)") .HasColumnName("alias"); + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_date") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("HashedPassword") .HasColumnType("text") .HasColumnName("hashed_password"); @@ -510,6 +516,10 @@ namespace LightlessSyncServer.Migrations .HasColumnType("character varying(10)") .HasColumnName("group_user_uid"); + b.Property("FromFinder") + .HasColumnType("boolean") + .HasColumnName("from_finder"); + b.Property("IsModerator") .HasColumnType("boolean") .HasColumnName("is_moderator"); @@ -518,6 +528,10 @@ namespace LightlessSyncServer.Migrations .HasColumnType("boolean") .HasColumnName("is_pinned"); + b.Property("JoinedGroupOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_group_on"); + b.HasKey("GroupGID", "GroupUserUID") .HasName("pk_group_pairs"); @@ -568,6 +582,34 @@ namespace LightlessSyncServer.Migrations b.ToTable("group_pair_preferred_permissions", (string)null); }); + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.Property("GroupGID") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("group_gid"); + + b.Property("Base64GroupProfileImage") + .HasColumnType("text") + .HasColumnName("base64group_profile_image"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Tags") + .HasColumnType("text") + .HasColumnName("tags"); + + b.HasKey("GroupGID") + .HasName("pk_group_profiles"); + + b.HasIndex("GroupGID") + .HasDatabaseName("ix_group_profiles_group_gid"); + + b.ToTable("group_profiles", (string)null); + }); + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => { b.Property("GroupGID") @@ -641,6 +683,10 @@ namespace LightlessSyncServer.Migrations .HasColumnType("character varying(15)") .HasColumnName("alias"); + b.Property("HasVanity") + .HasColumnType("boolean") + .HasColumnName("has_vanity"); + b.Property("IsAdmin") .HasColumnType("boolean") .HasColumnName("is_admin"); @@ -653,6 +699,16 @@ namespace LightlessSyncServer.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("last_logged_in"); + b.Property("TextColorHex") + .HasMaxLength(9) + .HasColumnType("character varying(9)") + .HasColumnName("text_color_hex"); + + b.Property("TextGlowColorHex") + .HasMaxLength(9) + .HasColumnType("character varying(9)") + .HasColumnName("text_glow_color_hex"); + b.Property("Timestamp") .IsConcurrencyToken() .ValueGeneratedOnAddOrUpdate() @@ -1010,6 +1066,16 @@ namespace LightlessSyncServer.Migrations b.Navigation("User"); }); + modelBuilder.Entity("LightlessSyncShared.Models.GroupProfile", b => + { + b.HasOne("LightlessSyncShared.Models.Group", "Group") + .WithOne("Profile") + .HasForeignKey("LightlessSyncShared.Models.GroupProfile", "GroupGID") + .HasConstraintName("fk_group_profiles_groups_group_gid"); + + b.Navigation("Group"); + }); + modelBuilder.Entity("LightlessSyncShared.Models.GroupTempInvite", b => { b.HasOne("LightlessSyncShared.Models.Group", "Group") @@ -1089,6 +1155,11 @@ namespace LightlessSyncServer.Migrations b.Navigation("Poses"); }); + + modelBuilder.Entity("LightlessSyncShared.Models.Group", b => + { + b.Navigation("Profile"); + }); #pragma warning restore 612, 618 } } diff --git a/LightlessSyncServer/LightlessSyncShared/Models/Group.cs b/LightlessSyncServer/LightlessSyncShared/Models/Group.cs index 67de63d..522c7d8 100644 --- a/LightlessSyncServer/LightlessSyncShared/Models/Group.cs +++ b/LightlessSyncServer/LightlessSyncShared/Models/Group.cs @@ -11,9 +11,11 @@ public class Group public User Owner { get; set; } [MaxLength(50)] public string Alias { get; set; } + public GroupProfile? Profile { get; set; } public bool InvitesEnabled { get; set; } public string HashedPassword { get; set; } public bool PreferDisableSounds { get; set; } public bool PreferDisableAnimations { get; set; } public bool PreferDisableVFX { get; set; } + public DateTime CreatedDate { get; set; } = DateTime.UtcNow; } diff --git a/LightlessSyncServer/LightlessSyncShared/Models/GroupPair.cs b/LightlessSyncServer/LightlessSyncShared/Models/GroupPair.cs index bb5824e..f04db3f 100644 --- a/LightlessSyncServer/LightlessSyncShared/Models/GroupPair.cs +++ b/LightlessSyncServer/LightlessSyncShared/Models/GroupPair.cs @@ -8,4 +8,6 @@ public class GroupPair public User GroupUser { get; set; } public bool IsPinned { get; set; } public bool IsModerator { get; set; } + public bool FromFinder { get; set; } = false; + public DateTime? JoinedGroupOn { get; set; } } diff --git a/LightlessSyncServer/LightlessSyncShared/Models/GroupProfile.cs b/LightlessSyncServer/LightlessSyncShared/Models/GroupProfile.cs new file mode 100644 index 0000000..fdc1717 --- /dev/null +++ b/LightlessSyncServer/LightlessSyncShared/Models/GroupProfile.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LightlessSyncShared.Models; +public class GroupProfile +{ + [Key] + [MaxLength(20)] + public string GroupGID { get; set; } + public Group Group { get; set; } + public string Description { get; set; } + public string Tags { get; set; } + public string Base64GroupProfileImage { get; set; } +} diff --git a/LightlessSyncServer/LightlessSyncShared/Models/User.cs b/LightlessSyncServer/LightlessSyncShared/Models/User.cs index fcaef37..6e7f612 100644 --- a/LightlessSyncServer/LightlessSyncShared/Models/User.cs +++ b/LightlessSyncServer/LightlessSyncShared/Models/User.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace LightlessSyncShared.Models; @@ -14,6 +14,14 @@ public class User public bool IsAdmin { get; set; } = false; + public bool HasVanity { get; set; } = false; + + [MaxLength(9)] + public string? TextColorHex { get; set; } = string.Empty; + + [MaxLength(9)] + public string? TextGlowColorHex { get; set; } = string.Empty; + public DateTime LastLoggedIn { get; set; } [MaxLength(15)] public string Alias { get; set; } diff --git a/LightlessSyncServer/LightlessSyncShared/Utils/Configuration/StaticFilesServerConfiguration.cs b/LightlessSyncServer/LightlessSyncShared/Utils/Configuration/StaticFilesServerConfiguration.cs index 85b9585..af4b3b6 100644 --- a/LightlessSyncServer/LightlessSyncShared/Utils/Configuration/StaticFilesServerConfiguration.cs +++ b/LightlessSyncServer/LightlessSyncShared/Utils/Configuration/StaticFilesServerConfiguration.cs @@ -20,6 +20,8 @@ public class StaticFilesServerConfiguration : LightlessConfigurationBase public string ColdStorageDirectory { get; set; } = null; public double ColdStorageSizeHardLimitInGiB { get; set; } = -1; public int ColdStorageUnusedFileRetentionPeriodInDays { get; set; } = 30; + public bool EnableDirectDownloads { get; set; } = true; + public int DirectDownloadTokenLifetimeSeconds { get; set; } = 300; [RemoteConfiguration] public double SpeedTestHoursRateLimit { get; set; } = 0.5; [RemoteConfiguration] @@ -40,6 +42,8 @@ public class StaticFilesServerConfiguration : LightlessConfigurationBase sb.AppendLine($"{nameof(CacheDirectory)} => {CacheDirectory}"); sb.AppendLine($"{nameof(DownloadQueueSize)} => {DownloadQueueSize}"); sb.AppendLine($"{nameof(DownloadQueueReleaseSeconds)} => {DownloadQueueReleaseSeconds}"); + sb.AppendLine($"{nameof(EnableDirectDownloads)} => {EnableDirectDownloads}"); + sb.AppendLine($"{nameof(DirectDownloadTokenLifetimeSeconds)} => {DirectDownloadTokenLifetimeSeconds}"); return sb.ToString(); } } diff --git a/LightlessSyncServer/LightlessSyncStaticFilesServer/Services/CDNDownloadUrlService.cs b/LightlessSyncServer/LightlessSyncStaticFilesServer/Services/CDNDownloadUrlService.cs new file mode 100644 index 0000000..4bbc32b --- /dev/null +++ b/LightlessSyncServer/LightlessSyncStaticFilesServer/Services/CDNDownloadUrlService.cs @@ -0,0 +1,108 @@ +using LightlessSync.API.Routes; +using LightlessSyncShared.Services; +using LightlessSyncShared.Utils.Configuration; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.WebUtilities; +using System.Security.Cryptography; +using System.Text; +using System.Globalization; +using System.Linq; + +namespace LightlessSyncStaticFilesServer.Services; + +public class CDNDownloadUrlService +{ + private readonly IConfigurationService _staticConfig; + private readonly IConfigurationService _globalConfig; + private readonly ILogger _logger; + + public CDNDownloadUrlService(IConfigurationService staticConfig, + IConfigurationService globalConfig, ILogger logger) + { + _staticConfig = staticConfig; + _globalConfig = globalConfig; + _logger = logger; + } + + public bool DirectDownloadsEnabled => + _staticConfig.GetValueOrDefault(nameof(StaticFilesServerConfiguration.EnableDirectDownloads), false); + + public Uri? TryCreateDirectDownloadUri(Uri? baseUri, string hash) + { + if (!DirectDownloadsEnabled || baseUri == null) + { + return null; + } + + if (!IsSupportedHash(hash)) + { + _logger.LogDebug("Skipping direct download link generation for invalid hash {hash}", hash); + return null; + } + + var normalizedHash = hash.ToUpperInvariant(); + + var lifetimeSeconds = Math.Max(5, + _staticConfig.GetValueOrDefault(nameof(StaticFilesServerConfiguration.DirectDownloadTokenLifetimeSeconds), 300)); + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(lifetimeSeconds); + var signature = CreateSignature(normalizedHash, expiresAt.ToUnixTimeSeconds()); + + var directPath = $"{LightlessFiles.ServerFiles}/{LightlessFiles.ServerFiles_DirectDownload}/{normalizedHash}"; + var builder = new UriBuilder(new Uri(baseUri, directPath)); + var query = new QueryBuilder + { + { "expires", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, + { "signature", signature } + }; + builder.Query = query.ToQueryString().Value!.TrimStart('?'); + return builder.Uri; + } + + public bool TryValidateSignature(string hash, long expiresUnixSeconds, string signature) + { + if (!DirectDownloadsEnabled) + { + return false; + } + + if (string.IsNullOrEmpty(signature) || !IsSupportedHash(hash)) + { + return false; + } + + var normalizedHash = hash.ToUpperInvariant(); + + DateTimeOffset expiresAt; + try + { + expiresAt = DateTimeOffset.FromUnixTimeSeconds(expiresUnixSeconds); + } + catch (ArgumentOutOfRangeException) + { + return false; + } + + if (expiresAt < DateTimeOffset.UtcNow) + { + return false; + } + + var expected = CreateSignature(normalizedHash, expiresAt.ToUnixTimeSeconds()); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(signature)); + } + + private string CreateSignature(string hash, long expiresUnixSeconds) + { + var signingKey = _globalConfig.GetValue(nameof(LightlessConfigurationBase.Jwt)); + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingKey)); + var payload = Encoding.UTF8.GetBytes($"{hash}:{expiresUnixSeconds}"); + return WebEncoders.Base64UrlEncode(hmac.ComputeHash(payload)); + } + + private static bool IsSupportedHash(string hash) + { + return hash.Length == 40 && hash.All(char.IsAsciiLetterOrDigit); + } +} diff --git a/LightlessSyncServer/LightlessSyncStaticFilesServer/appsettings.json b/LightlessSyncServer/LightlessSyncStaticFilesServer/appsettings.json index dc53c47..35f6258 100644 --- a/LightlessSyncServer/LightlessSyncStaticFilesServer/appsettings.json +++ b/LightlessSyncServer/LightlessSyncStaticFilesServer/appsettings.json @@ -25,7 +25,9 @@ "UnusedFileRetentionPeriodInDays": 7, "CacheDirectory": "G:\\ServerTest", "ServiceAddress": "http://localhost:5002", - "RemoteCacheSourceUri": "" + "RemoteCacheSourceUri": "", + "EnableDirectDownloads": true, + "DirectDownloadTokenLifetimeSeconds": 300 }, "AllowedHosts": "*" }