755 lines
33 KiB
C#
755 lines
33 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.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;
|
|
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" };
|
|
|
|
[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: _contextAccessor.HttpContext.RequestAborted).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: _contextAccessor.HttpContext.RequestAborted).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: _contextAccessor.HttpContext.RequestAborted).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: _contextAccessor.HttpContext.RequestAborted).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, string myCid)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(otherCid) || string.IsNullOrWhiteSpace(myCid))
|
|
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)
|
|
{
|
|
_logger.LogCallWarning(LightlessHubLogger.Args("invalid payload", reverseKey));
|
|
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
|
|
{
|
|
var payload = new PairingPayload
|
|
{
|
|
UID = UserUID,
|
|
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));
|
|
}
|
|
}
|
|
|
|
private class PairingPayload
|
|
{
|
|
public string UID { get; set; } = string.Empty;
|
|
public DateTime Timestamp { get; set; }
|
|
}
|
|
|
|
public class BroadcastRedisEntry
|
|
{
|
|
public string HashedCID { get; set; } = string.Empty;
|
|
public string? GID { get; set; }
|
|
}
|
|
|
|
[Authorize(Policy = "Identified")]
|
|
public async Task SetBroadcastStatus(string hashedCid, bool enabled, GroupBroadcastRequestDto? groupDto = null)
|
|
{
|
|
var db = _redis.Database;
|
|
var broadcastKey = $"broadcast:{hashedCid}";
|
|
|
|
if (enabled)
|
|
{
|
|
string? gid = null;
|
|
|
|
if (groupDto is not null)
|
|
{
|
|
groupDto.HashedCID = hashedCid;
|
|
|
|
var valid = await SetGroupBroadcastStatus(groupDto).ConfigureAwait(false);
|
|
if (!valid)
|
|
return;
|
|
|
|
gid = groupDto.GID;
|
|
}
|
|
|
|
var entry = new BroadcastRedisEntry
|
|
{
|
|
HashedCID = hashedCid,
|
|
GID = gid,
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(entry);
|
|
await db.StringSetAsync(broadcastKey, json, TimeSpan.FromMinutes(5)).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<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 || entry.HashedCID != hashedCid)
|
|
{
|
|
_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");
|
|
return;
|
|
}
|
|
|
|
await db.KeyDeleteAsync(broadcastKey).ConfigureAwait(false);
|
|
_logger.LogCallInfo(LightlessHubLogger.Args("broadcast disabled", hashedCid, "GID", entry.GID));
|
|
}
|
|
}
|
|
|
|
|
|
[Authorize(Policy = "Identified")]
|
|
public async Task<BroadcastStatusInfoDto?> IsUserBroadcasting(string hashedCid)
|
|
{
|
|
var db = _redis.Database;
|
|
var key = $"broadcast:{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;
|
|
}
|
|
|
|
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(string hashedCid)
|
|
{
|
|
var db = _redis.Database;
|
|
var key = $"broadcast:{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?.HashedCID != hashedCid)
|
|
{
|
|
_logger.LogCallWarning(LightlessHubLogger.Args("unauthorized ttl query", UserUID, "CID", hashedCid, "EntryCID", entry?.HashedCID));
|
|
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;
|
|
}
|
|
|
|
|
|
private const int MaxBatchSize = 30;
|
|
|
|
[Authorize(Policy = "Identified")]
|
|
public async Task<BroadcastStatusBatchDto> AreUsersBroadcasting(List<string> hashedCids)
|
|
{
|
|
var db = _redis.Database;
|
|
if (hashedCids.Count > MaxBatchSize)
|
|
hashedCids = hashedCids.Take(MaxBatchSize).ToList();
|
|
|
|
var tasks = new Dictionary<string, Task<RedisValueWithExpiry>>(hashedCids.Count);
|
|
foreach (var cid in hashedCids)
|
|
{
|
|
var key = $"broadcast:{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!);
|
|
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: _contextAccessor.HttpContext.RequestAborted).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.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, 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);
|
|
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<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 = new();
|
|
List<string> 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, cancellationToken: _contextAccessor.HttpContext.RequestAborted).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: _contextAccessor.HttpContext.RequestAborted).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<Rgba32>(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);
|
|
} |