lightfinder changes:

- removed all ability to provide your cid to the server through params, cid is gained from JWT claims
- improved verification of who owns a cid, which includes locking a cid to a uid
- locks and persisting entries of broadcasting are cleaned up on disconnection
- method identification logic was rewritten to fit these changes
This commit is contained in:
azyges
2025-10-08 08:40:56 +09:00
parent 43219dd1e9
commit 479b80a5a0
9 changed files with 243 additions and 87 deletions

View File

@@ -59,6 +59,14 @@ public class BroadcastConfiguration : IBroadcastConfiguration
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;
@@ -69,4 +77,7 @@ public class BroadcastConfiguration : IBroadcastConfiguration
return template;
}
public int PairRequestRateLimit => Options.PairRequestRateLimit > 0 ? Options.PairRequestRateLimit : 5;
public int PairRequestRateWindow => Options.PairRequestRateWindow > 0 ? Options.PairRequestRateWindow : 60;
}

View File

@@ -20,4 +20,10 @@ public class BroadcastOptions
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;
}

View File

@@ -12,5 +12,9 @@ public interface IBroadcastConfiguration
bool EnableSyncshellBroadcastPayloads { get; }
string BuildRedisKey(string hashedCid);
string BuildUserOwnershipKey(string userUid);
string BuildPairRequestNotification();
int PairRequestRateLimit { get; }
int PairRequestRateWindow { get; }
}

View File

@@ -1,11 +1,13 @@
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;
@@ -95,13 +97,18 @@ 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<User?> EnsureUserHasVanity(string uid, CancellationToken cancellationToken = default)
{
cancellationToken = cancellationToken == default && _contextAccessor.HttpContext != null
? _contextAccessor.HttpContext.RequestAborted
? RequestAbortedToken
: cancellationToken;
var user = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == uid, cancellationToken).ConfigureAwait(false);
@@ -120,6 +127,47 @@ public partial class LightlessHub
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<BroadcastRedisEntry>(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<GroupPair> groupUsers)
{
foreach (var pair in groupUsers)
@@ -188,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<string, UserInfo> allUserPairs, string? uid = null)

View File

@@ -59,7 +59,7 @@ public partial class LightlessHub
group.PreferDisableAnimations = dto.Permissions.HasFlag(GroupPermissions.PreferDisableAnimations);
group.PreferDisableVFX = dto.Permissions.HasFlag(GroupPermissions.PreferDisableVFX);
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).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);
@@ -137,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(cancellationToken: _contextAccessor.HttpContext.RequestAborted).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)))
@@ -181,7 +181,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(cancellationToken: _contextAccessor.HttpContext.RequestAborted).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);
@@ -199,15 +199,15 @@ public partial class LightlessHub
public async Task<GroupJoinDto> GroupCreate()
{
_logger.LogCallInfo();
var existingGroupsByUser = await DbContext.Groups.CountAsync(u => u.OwnerUID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
var existingJoinedGroups = await DbContext.GroupPairs.CountAsync(u => u.GroupUserUID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).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 == "LLS-" + gid, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false))
while (await DbContext.Groups.AnyAsync(g => g.GID == "LLS-" + gid, cancellationToken: RequestAbortedToken).ConfigureAwait(false))
{
gid = StringUtils.GenerateRandomString(12);
}
@@ -218,7 +218,7 @@ public partial class LightlessHub
var hashedPw = StringUtils.Sha256String(passwd);
var currentTime = DateTime.UtcNow;
UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
Group newGroup = new()
{
@@ -250,12 +250,12 @@ public partial class LightlessHub
DisableVFX = defaultPermissions.DisableGroupAnimations,
};
await DbContext.Groups.AddAsync(newGroup, _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(initialPair, _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.GroupPairPreferredPermissions.AddAsync(initialPrefPermissions, _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).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, cancellationToken: _contextAccessor.HttpContext.RequestAborted).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)))
@@ -314,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(cancellationToken: _contextAccessor.HttpContext.RequestAborted).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(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
await Clients.Users(groupPairs.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
@@ -332,7 +332,7 @@ public partial class LightlessHub
var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false);
if (!userHasRights) return [];
var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync(cancellationToken: _contextAccessor.HttpContext.RequestAborted).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<BannedGroupUserDto> bannedGroupUsers = banEntries.Select(b =>
new BannedGroupUserDto(group.ToGroupData(), b.BannedUser.ToUserData(), b.BannedReason, b.BannedOn,
@@ -350,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, cancellationToken: _contextAccessor.HttpContext.RequestAborted).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, cancellationToken: _contextAccessor.HttpContext.RequestAborted).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, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw, cancellationToken: _contextAccessor.HttpContext.RequestAborted).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)
@@ -378,7 +378,7 @@ 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, cancellationToken: _contextAccessor.HttpContext.RequestAborted).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 isHashedPassword = dto.Password.Length == 64 && dto.Password.All(Uri.IsHexDigit);
@@ -416,7 +416,7 @@ public partial class LightlessHub
FromFinder = isHashedPassword
};
var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).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()
@@ -441,13 +441,13 @@ public partial class LightlessHub
DbContext.Update(preferredPermissions);
}
await DbContext.GroupPairs.AddAsync(newPair, _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(newPair, RequestAbortedToken).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "Success"));
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync(cancellationToken: _contextAccessor.HttpContext.RequestAborted).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);
@@ -575,7 +575,7 @@ public partial class LightlessHub
}
}
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
return true;
}
@@ -693,7 +693,7 @@ public partial class LightlessHub
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false);
}
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
return usersToPrune.Count();
}
@@ -717,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(cancellationToken: _contextAccessor.HttpContext.RequestAborted).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(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
var userIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false);
if (userIdent == null)
{
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
return;
}
@@ -743,7 +743,7 @@ public partial class LightlessHub
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var cancellationToken = _contextAccessor.HttpContext.RequestAborted;
var cancellationToken = RequestAbortedToken;
var data = await DbContext.GroupProfiles
.FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID, cancellationToken)
@@ -780,7 +780,7 @@ public partial class LightlessHub
var groupProfileDb = await DbContext.GroupProfiles
.FirstOrDefaultAsync(g => g.GroupGID == dto.Group.GID,
_contextAccessor.HttpContext.RequestAborted)
RequestAbortedToken)
.ConfigureAwait(false);
if (groupProfileDb != null)
@@ -800,11 +800,11 @@ public partial class LightlessHub
};
await DbContext.GroupProfiles.AddAsync(groupProfile,
_contextAccessor.HttpContext.RequestAborted)
RequestAbortedToken)
.ConfigureAwait(false);
}
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
@@ -836,9 +836,9 @@ public partial class LightlessHub
userPair.IsModerator = false;
}
await DbContext.SaveChangesAsync(_contextAccessor.HttpContext.RequestAborted).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(cancellationToken: _contextAccessor.HttpContext.RequestAborted).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);
}
@@ -847,7 +847,7 @@ public partial class LightlessHub
{
_logger.LogCallInfo();
var ct = _contextAccessor.HttpContext.RequestAborted;
var ct = RequestAbortedToken;
var result = await (
from gp in DbContext.GroupPairs
@@ -899,11 +899,11 @@ 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, cancellationToken: _contextAccessor.HttpContext.RequestAborted).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(_contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
await DbContext.SaveChangesAsync(RequestAbortedToken).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
}
@@ -991,5 +991,4 @@ public partial class LightlessHub
return results;
}
}

View File

@@ -1,4 +1,4 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
@@ -35,7 +35,7 @@ public partial class LightlessHub
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: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
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);
@@ -51,7 +51,7 @@ public partial class LightlessHub
var existingEntry =
await DbContext.ClientPairs.AsNoTracking()
.FirstOrDefaultAsync(p =>
p.User.UID == UserUID && p.OtherUserUID == otherUser.UID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
p.User.UID == UserUID && p.OtherUserUID == otherUser.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (existingEntry != null)
{
@@ -60,7 +60,7 @@ public partial class LightlessHub
}
// grab self create new client pair and save
var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
@@ -76,7 +76,7 @@ public partial class LightlessHub
var permissions = existingData?.OwnPermissions;
if (permissions == null || !permissions.Sticky)
{
var ownDefaultPermissions = await DbContext.UserDefaultPreferredPermissions.AsNoTracking().SingleOrDefaultAsync(f => f.UserUID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
var ownDefaultPermissions = await DbContext.UserDefaultPreferredPermissions.AsNoTracking().SingleOrDefaultAsync(f => f.UserUID == UserUID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
permissions = new UserPermissionSet()
{
@@ -89,7 +89,7 @@ public partial class LightlessHub
Sticky = true
};
var existingDbPerms = await DbContext.Permissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.OtherUserUID == otherUser.UID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
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);
@@ -143,17 +143,15 @@ public partial class LightlessHub
}
[Authorize(Policy = "Identified")]
public async Task TryPairWithContentId(string otherCid, string myCid)
public async Task TryPairWithContentId(string otherCid)
{
var myCid = UserCharaIdent;
if (string.IsNullOrWhiteSpace(otherCid) || string.IsNullOrWhiteSpace(myCid))
return;
bool IsValidCid(string cid) => cid.Length == 64 && cid.All(Uri.IsHexDigit) && !cid.All(c => c == '0');
if (!IsValidCid(myCid) || !IsValidCid(otherCid))
{
if (!IsValidHashedCid(myCid) || !IsValidHashedCid(otherCid))
return;
}
if (string.Equals(otherCid, myCid, StringComparison.Ordinal))
{
@@ -181,12 +179,33 @@ public partial class LightlessHub
try
{
var payload = JsonSerializer.Deserialize<PairingPayload>(json);
if (payload?.UID == null)
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<string>("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);
@@ -251,9 +270,30 @@ public partial class LightlessHub
}
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
};
@@ -262,14 +302,16 @@ public partial class LightlessHub
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(myCid, otherCid).ConfigureAwait(false);
await NotifyBroadcastOwnerOfPairRequest(otherCid).ConfigureAwait(false);
}
}
private async Task NotifyBroadcastOwnerOfPairRequest(string myHashedCid, string targetHashedCid)
private async Task NotifyBroadcastOwnerOfPairRequest(string targetHashedCid)
{
if (string.IsNullOrWhiteSpace(targetHashedCid) || string.IsNullOrWhiteSpace(myHashedCid))
var myHashedCid = UserCharaIdent;
if (!IsValidHashedCid(targetHashedCid) || !IsValidHashedCid(myHashedCid))
return;
if (!_broadcastConfiguration.EnableBroadcasting || !_broadcastConfiguration.NotifyOwnerOnPairRequest)
@@ -323,6 +365,7 @@ public partial class LightlessHub
private class PairingPayload
{
public string UID { get; set; } = string.Empty;
public string HashedCid { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
@@ -338,8 +381,10 @@ public partial class LightlessHub
}
[Authorize(Policy = "Identified")]
public async Task SetBroadcastStatus(string hashedCid, bool enabled, GroupBroadcastRequestDto? groupDto = null)
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));
@@ -347,9 +392,9 @@ public partial class LightlessHub
return;
}
if (string.IsNullOrWhiteSpace(hashedCid) || hashedCid.Length != 64 || !hashedCid.All(c => Uri.IsHexDigit(c)))
if (!IsValidHashedCid(hashedCid))
{
_logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format", UserUID, "CID", 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;
}
@@ -361,11 +406,32 @@ public partial class LightlessHub
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)
@@ -400,7 +466,7 @@ public partial class LightlessHub
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.");
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Another user is already broadcasting with that CID.").ConfigureAwait(false);
return;
}
}
@@ -414,6 +480,7 @@ public partial class LightlessHub
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
@@ -436,22 +503,35 @@ public partial class LightlessHub
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");
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");
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<BroadcastStatusInfoDto?> IsUserBroadcasting(string hashedCid)
@@ -459,18 +539,13 @@ public partial class LightlessHub
if (!_broadcastConfiguration.EnableBroadcasting)
return null;
if (string.IsNullOrWhiteSpace(hashedCid) || hashedCid.Length != 64 || !hashedCid.All(c => Uri.IsHexDigit(c)))
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;
}
if (hashedCid.All(c => c == '0'))
{
return null;
}
var db = _redis.Database;
var key = _broadcastConfiguration.BuildRedisKey(hashedCid);
@@ -507,14 +582,16 @@ public partial class LightlessHub
}
[Authorize(Policy = "Identified")]
public async Task<TimeSpan?> GetBroadcastTtl(string hashedCid)
public async Task<TimeSpan?> GetBroadcastTtl()
{
if (!_broadcastConfiguration.EnableBroadcasting)
return null;
if (string.IsNullOrWhiteSpace(hashedCid) || hashedCid.Length != 64 || !hashedCid.All(c => Uri.IsHexDigit(c)))
var hashedCid = UserCharaIdent;
if (!IsValidHashedCid(hashedCid))
{
_logger.LogCallWarning(LightlessHubLogger.Args("invalid cid format", UserUID, "CID", 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;
}
@@ -587,9 +664,7 @@ public partial class LightlessHub
var tasks = new Dictionary<string, Task<RedisValueWithExpiry>>(hashedCids.Count);
foreach (var cid in hashedCids)
{
bool validHash = !string.IsNullOrWhiteSpace(cid) && cid.Length == 64 && cid.All(Uri.IsHexDigit) && !cid.All(c => c == '0');
if (!validHash)
if (!IsValidHashedCid(cid))
{
tasks[cid] = Task.FromResult(new RedisValueWithExpiry(RedisValue.Null, null));
continue;
@@ -657,7 +732,7 @@ public partial class LightlessHub
{
_logger.LogCallInfo();
var userEntry = await DbContext.Users.SingleAsync(u => u.UID == UserUID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
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)
{
@@ -710,7 +785,7 @@ public partial class LightlessHub
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: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
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");
@@ -867,7 +942,7 @@ public partial class LightlessHub
// check if client pair even exists
ClientPair callerPair =
await DbContext.ClientPairs.SingleOrDefaultAsync(w => w.UserUID == UserUID && w.OtherUserUID == dto.User.UID, cancellationToken: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
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);
@@ -918,7 +993,7 @@ public partial class LightlessHub
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: _contextAccessor.HttpContext.RequestAborted).ConfigureAwait(false);
var existingData = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == dto.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (existingData?.FlaggedForReport ?? false)
{

View File

@@ -16,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;
@@ -44,6 +45,8 @@ public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
private readonly int _maxCharaDataByUser;
private readonly int _maxCharaDataByUserVanity;
private CancellationToken RequestAbortedToken => _contextAccessor.HttpContext?.RequestAborted ?? Context?.ConnectionAborted ?? CancellationToken.None;
public LightlessHub(LightlessMetrics lightlessMetrics,
IDbContextFactory<LightlessDbContext> lightlessDbContextFactory, ILogger<LightlessHub> logger, SystemInfoService systemInfoService,
IConfigurationService<ServerConfiguration> configuration, IHttpContextAccessor contextAccessor,
@@ -194,6 +197,8 @@ public partial class LightlessHub : Hub<ILightlessHub>, 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);

View File

@@ -36,7 +36,9 @@
"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."
"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": {