Files
LightlessServer/LightlessSyncServer/LightlessSyncServer/Hubs/LightlessHub.User.cs
2025-11-17 19:37:23 +01:00

1249 lines
54 KiB
C#

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.Models;
using LightlessSyncServer.Services;
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 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<string>(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<string>(reverseKey).ConfigureAwait(false);
if (json != null)
{
await _redis.RemoveAsync(reverseKey).ConfigureAwait(false);
try
{
var payload = JsonSerializer.Deserialize<PairingPayload>(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<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).ConfigureAwait(false);
var receiver = await _pairService.TryAddPairAsync(payload.UID, UserUID).ConfigureAwait(false);
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<BroadcastRedisEntry>(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);
}
[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<BroadcastRedisEntry>(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));
_chatChannelService.RefreshLightfinderState(UserUID, hashedCid, isLightfinder: true);
}
else
{
var value = await db.StringGetAsync(broadcastKey).ConfigureAwait(false);
if (value.IsNullOrEmpty)
return;
BroadcastRedisEntry? entry;
try
{
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(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));
_chatChannelService.RefreshLightfinderState(UserUID, null, isLightfinder: false);
}
}
private async Task<(BroadcastRedisEntry? Entry, TimeSpan? Expiry)> TryGetBroadcastEntryAsync(string hashedCid)
{
var key = _broadcastConfiguration.BuildRedisKey(hashedCid);
RedisValueWithExpiry value;
try
{
value = await _redis.Database.StringGetWithExpiryAsync(key).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileLookupFailed", "CID", hashedCid, "Error", ex));
return (null, null);
}
if (value.Value.IsNullOrEmpty || value.Expiry is null || value.Expiry <= TimeSpan.Zero)
{
return (null, value.Expiry);
}
BroadcastRedisEntry? entry;
try
{
entry = JsonSerializer.Deserialize<BroadcastRedisEntry>(value.Value!);
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileDeserializeFailed", "CID", hashedCid, "Raw", value.Value.ToString(), "Error", ex));
return (null, value.Expiry);
}
if (entry is null || !string.Equals(entry.HashedCID, hashedCid, StringComparison.Ordinal))
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileEntryMismatch", "CID", hashedCid, "EntryCID", entry?.HashedCID ?? "null"));
return (null, value.Expiry);
}
return (entry, value.Expiry);
}
private static bool HasActiveBroadcast(BroadcastRedisEntry? entry, TimeSpan? expiry) =>
entry?.HasOwner() == true && expiry.HasValue && expiry.Value > TimeSpan.Zero;
private static bool IsActiveBroadcastForUser(BroadcastRedisEntry? entry, TimeSpan? expiry, string userUid) =>
HasActiveBroadcast(entry, expiry) && entry!.OwnedBy(userUid);
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)
{
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<BroadcastRedisEntry>(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<TimeSpan?> 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<BroadcastRedisEntry>(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<BroadcastStatusBatchDto?> AreUsersBroadcasting(List<string> 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<string, Task<RedisValueWithExpiry>>(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<string, BroadcastStatusInfoDto>(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<BroadcastRedisEntry>(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<List<OnlineUserIdentDto>> 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<List<UserFullPairDto>> 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<UserProfileDto> 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, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "Due to the pause status you cannot access this users profile.", Tags: []);
}
var data = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == user.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
if (data == null) return new UserProfileDto(user.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: null, Tags: []);
if (data.FlaggedForReport) return new UserProfileDto(user.User, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile is flagged for report and pending evaluation", Tags: []);
if (data.ProfileDisabled) return new UserProfileDto(user.User, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile was permanently disabled", Tags: []);
return data.ToDTO();
}
[Authorize(Policy = "Identified")]
public async Task<UserProfileDto?> UserGetLightfinderProfile(string hashedCid)
{
_logger.LogCallInfo(LightlessHubLogger.Args("LightfinderProfile", hashedCid));
if (!_broadcastConfiguration.EnableBroadcasting)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Lightfinder is currently disabled.").ConfigureAwait(false);
return null;
}
if (!IsValidHashedCid(hashedCid))
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileInvalidCid", hashedCid));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Invalid Lightfinder target.").ConfigureAwait(false);
return null;
}
var viewerCid = UserCharaIdent;
if (!IsValidHashedCid(viewerCid))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You must be using Lightfinder to open player profiles.").ConfigureAwait(false);
return null;
}
var (viewerEntry, viewerExpiry) = await TryGetBroadcastEntryAsync(viewerCid).ConfigureAwait(false);
if (!IsActiveBroadcastForUser(viewerEntry, viewerExpiry, UserUID))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "You must be using Lightfinder to open player profiles.").ConfigureAwait(false);
return null;
}
var (targetEntry, targetExpiry) = await TryGetBroadcastEntryAsync(hashedCid).ConfigureAwait(false);
if (!HasActiveBroadcast(targetEntry, targetExpiry))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "That player is not currently using Lightfinder.").ConfigureAwait(false);
return null;
}
if (string.IsNullOrEmpty(targetEntry!.OwnerUID))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "That player is not currently using Lightfinder.").ConfigureAwait(false);
return null;
}
var targetUser = await DbContext.Users.AsNoTracking()
.SingleOrDefaultAsync(u => u.UID == targetEntry.OwnerUID, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (targetUser == null)
{
_logger.LogCallWarning(LightlessHubLogger.Args("LightfinderProfileMissingUser", hashedCid, "OwnerUID", targetEntry.OwnerUID));
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, "Unable to load the players profile at this time.").ConfigureAwait(false);
return null;
}
var displayAlias = string.IsNullOrWhiteSpace(targetUser.Alias)
? "LightfinderUser"
: targetUser.Alias;
var userData = new UserData(
UID: hashedCid,
Alias: displayAlias,
IsAdmin: false,
IsModerator: false,
HasVanity: false,
TextColorHex: targetUser.TextColorHex,
TextGlowColorHex: targetUser.TextGlowColorHex);
var profile = await DbContext.UserProfileData.AsNoTracking()
.SingleOrDefaultAsync(u => u.UserUID == targetEntry.OwnerUID, cancellationToken: RequestAbortedToken)
.ConfigureAwait(false);
if (profile == null)
{
return new UserProfileDto(userData, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: null, Tags: []);
}
if (profile.FlaggedForReport)
{
return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile is flagged for report and pending evaluation", Tags: []);
}
if (profile.ProfileDisabled)
{
return new UserProfileDto(userData, Disabled: true, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, Description: "This profile was permanently disabled", Tags: []);
}
return profile.ToDTO();
}
[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<JsonElement>(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<string> invalidGamePaths = [];
List<string> invalidFileSwapPaths = [];
var gamePathRegex = GamePathRegex();
var hashRegex = HashRegex();
foreach (var replacement in dto.CharaData.FileReplacements.SelectMany(p => p.Value))
{
var validGamePaths = replacement.GamePaths
.Where(p => gamePathRegex.IsMatch(p) &&
AllowedExtensionsForGamePaths.Any(e => p.EndsWith(e, StringComparison.OrdinalIgnoreCase)))
.ToArray();
var invalidPaths = replacement.GamePaths.Except(validGamePaths, StringComparer.OrdinalIgnoreCase).ToArray();
replacement.GamePaths = validGamePaths;
bool validHash = string.IsNullOrEmpty(replacement.Hash) || hashRegex.IsMatch(replacement.Hash);
bool validFileSwapPath = string.IsNullOrEmpty(replacement.FileSwapPath) || gamePathRegex.IsMatch(replacement.FileSwapPath);
bool validGamePathsFlag = validGamePaths.Length
!= 0;
if (!validGamePathsFlag || !validHash || !validFileSwapPath)
{
_logger.LogCallWarning(LightlessHubLogger.Args("Invalid Data", "GamePaths", validGamePathsFlag, string.Join(',', invalidPaths), "Hash", validHash, replacement.Hash, "FileSwap", validFileSwapPath, replacement.FileSwapPath));
hadInvalidData = true;
if (!validFileSwapPath) invalidFileSwapPaths.Add(replacement.FileSwapPath);
if (!validGamePathsFlag) invalidGamePaths.AddRange(invalidPaths);
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<string>(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));
var cancellationToken = RequestAbortedToken;
if (!string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal)) throw new HubException("Cannot modify profile data for anyone but yourself");
var profileData = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == dto.User.UID, cancellationToken: RequestAbortedToken).ConfigureAwait(false);
ImageCheckService.ImageLoadResult profileResult = new();
ImageCheckService.ImageLoadResult bannerResult = new();
//Avatar image validation
if (!string.IsNullOrEmpty(dto.ProfilePictureBase64))
{
profileResult = await ImageCheckService.ValidateImageAsync(dto.ProfilePictureBase64, banner: false, RequestAbortedToken).ConfigureAwait(false);
if (!profileResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, profileResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
//Banner image validation
if (!string.IsNullOrEmpty(dto.BannerPictureBase64))
{
bannerResult = await ImageCheckService.ValidateImageAsync(dto.BannerPictureBase64, banner: true, RequestAbortedToken).ConfigureAwait(false);
if (!bannerResult.Success)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, bannerResult.ErrorMessage).ConfigureAwait(false);
return;
}
}
if (profileData != null)
{
if (profileData.FlaggedForReport)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile is currently flagged for report and cannot be edited").ConfigureAwait(false);
return;
}
if (profileData.ProfileDisabled)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false);
return;
}
profileData.UpdateProfileFromDto(dto, profileResult.Base64Image, bannerResult.Base64Image);
}
else
{
profileData = new()
{
UserUID = dto.User.UID,
IsNSFW = dto.IsNSFW ?? false,
};
profileData.UpdateProfileFromDto(dto, profileResult.Base64Image, bannerResult.Base64Image);
await DbContext.UserProfileData.AddAsync(profileData, cancellationToken).ConfigureAwait(false);
}
await DbContext.SaveChangesAsync(cancellationToken).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);
}