This commit is contained in:
Zurazan
2025-08-27 03:02:29 +02:00
commit 80235a174b
344 changed files with 43249 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
using LightlessSync.API.SignalR;
using LightlessSyncServer.Hubs;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
namespace LightlessSyncServer.Controllers;
[Route("/msgc")]
[Authorize(Policy = "Internal")]
public class ClientMessageController : Controller
{
private ILogger<ClientMessageController> _logger;
private IHubContext<LightlessHub, ILightlessHub> _hubContext;
public ClientMessageController(ILogger<ClientMessageController> logger, IHubContext<LightlessHub, ILightlessHub> hubContext)
{
_logger = logger;
_hubContext = hubContext;
}
[Route("sendMessage")]
[HttpPost]
public async Task<IActionResult> SendMessage(ClientMessage msg)
{
bool hasUid = !string.IsNullOrEmpty(msg.UID);
if (!hasUid)
{
_logger.LogInformation("Sending Message of severity {severity} to all online users: {message}", msg.Severity, msg.Message);
await _hubContext.Clients.All.Client_ReceiveServerMessage(msg.Severity, msg.Message).ConfigureAwait(false);
}
else
{
_logger.LogInformation("Sending Message of severity {severity} to user {uid}: {message}", msg.Severity, msg.UID, msg.Message);
await _hubContext.Clients.User(msg.UID).Client_ReceiveServerMessage(msg.Severity, msg.Message).ConfigureAwait(false);
}
return Empty;
}
}

View File

@@ -0,0 +1,108 @@
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.AspNetCore.SignalR;
using System.Threading.RateLimiting;
namespace LightlessSyncServer.Hubs;
public sealed class ConcurrencyFilter : IHubFilter, IDisposable
{
private ConcurrencyLimiter _limiter;
private int _setLimit = 0;
private readonly IConfigurationService<ServerConfiguration> _config;
private readonly CancellationTokenSource _cts = new();
private bool _disposed;
public ConcurrencyFilter(IConfigurationService<ServerConfiguration> config, LightlessMetrics lightlessMetrics)
{
_config = config;
_config.ConfigChangedEvent += OnConfigChange;
RecreateLimiter();
_ = Task.Run(async () =>
{
var token = _cts.Token;
while (!token.IsCancellationRequested)
{
var stats = _limiter?.GetStatistics();
if (stats != null)
{
lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeHubConcurrency, stats.CurrentAvailablePermits);
lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeHubQueuedConcurrency, stats.CurrentQueuedCount);
}
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
}
});
}
private void OnConfigChange(object sender, EventArgs e)
{
RecreateLimiter();
}
private void RecreateLimiter()
{
var newLimit = _config.GetValueOrDefault(nameof(ServerConfiguration.HubExecutionConcurrencyFilter), 50);
if (newLimit == _setLimit && _limiter is not null)
{
return;
}
_setLimit = newLimit;
_limiter?.Dispose();
_limiter = new(new ConcurrencyLimiterOptions()
{
PermitLimit = newLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = newLimit * 100,
});
}
public async ValueTask<object> InvokeMethodAsync(
HubInvocationContext invocationContext, Func<HubInvocationContext, ValueTask<object>> next)
{
if (string.Equals(invocationContext.HubMethodName, nameof(LightlessHub.CheckClientHealth), StringComparison.Ordinal))
{
return await next(invocationContext).ConfigureAwait(false);
}
var ct = invocationContext.Context.ConnectionAborted;
RateLimitLease lease;
try
{
lease = await _limiter.AcquireAsync(1, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
if (!lease.IsAcquired)
{
throw new HubException("Concurrency limit exceeded. Try again later.");
}
using (lease)
{
return await next(invocationContext).ConfigureAwait(false);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_cts.Cancel();
_limiter?.Dispose();
_config.ConfigChangedEvent -= OnConfigChange;
_cts.Dispose();
}
}

View File

@@ -0,0 +1,641 @@
using LightlessSync.API.Data;
using LightlessSync.API.Dto.CharaData;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
[Authorize(Policy = "Identified")]
public async Task<CharaDataFullDto?> CharaDataCreate()
{
_logger.LogCallInfo();
int uploadCount = DbContext.CharaData.Count(c => c.UploaderUID == UserUID);
User user = DbContext.Users.Single(u => u.UID == UserUID);
int maximumUploads = string.IsNullOrEmpty(user.Alias) ? _maxCharaDataByUser : _maxCharaDataByUserVanity;
if (uploadCount >= maximumUploads)
{
return null;
}
string charaDataId = null;
while (charaDataId == null)
{
charaDataId = StringUtils.GenerateRandomString(10, "abcdefghijklmnopqrstuvwxyzABCDEFHIJKLMNOPQRSTUVWXYZ");
bool idExists = await DbContext.CharaData.AnyAsync(c => c.UploaderUID == UserUID && c.Id == charaDataId).ConfigureAwait(false);
if (idExists)
{
charaDataId = null;
}
}
DateTime createdDate = DateTime.UtcNow;
CharaData charaData = new()
{
Id = charaDataId,
UploaderUID = UserUID,
CreatedDate = createdDate,
UpdatedDate = createdDate,
AccessType = CharaDataAccess.Individuals,
ShareType = CharaDataShare.Private,
CustomizeData = string.Empty,
GlamourerData = string.Empty,
ExpiryDate = DateTime.MaxValue,
Description = string.Empty,
};
await DbContext.CharaData.AddAsync(charaData).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("SUCCESS", charaDataId));
return GetCharaDataFullDto(charaData);
}
[Authorize(Policy = "Identified")]
public async Task<bool> CharaDataDelete(string id)
{
var existingData = await DbContext.CharaData.SingleOrDefaultAsync(u => u.Id == id && u.UploaderUID == UserUID).ConfigureAwait(false);
if (existingData == null)
return false;
try
{
_logger.LogCallInfo(LightlessHubLogger.Args("SUCCESS", id));
DbContext.Remove(existingData);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
_logger.LogCallWarning(LightlessHubLogger.Args("FAILURE", id, ex.Message));
return false;
}
}
[Authorize(Policy = "Identified")]
public async Task<CharaDataDownloadDto?> CharaDataDownload(string id)
{
CharaData charaData = await GetCharaDataById(id, nameof(CharaDataDownload)).ConfigureAwait(false);
if (!string.Equals(charaData.UploaderUID, UserUID, StringComparison.Ordinal))
{
charaData.DownloadCount++;
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
_logger.LogCallInfo(LightlessHubLogger.Args("SUCCESS", id));
return GetCharaDataDownloadDto(charaData);
}
[Authorize(Policy = "Identified")]
public async Task<CharaDataMetaInfoDto?> CharaDataGetMetainfo(string id)
{
var charaData = await GetCharaDataById(id, nameof(CharaDataGetMetainfo)).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("SUCCESS", id));
return GetCharaDataMetaInfoDto(charaData);
}
[Authorize(Policy = "Identified")]
public async Task<List<CharaDataFullDto>> CharaDataGetOwn()
{
var ownCharaData = await DbContext.CharaData
.Include(u => u.Files)
.Include(u => u.FileSwaps)
.Include(u => u.OriginalFiles)
.Include(u => u.AllowedIndividiuals)
.ThenInclude(u => u.AllowedUser)
.Include(u => u.AllowedIndividiuals)
.ThenInclude(u => u.AllowedGroup)
.Include(u => u.Poses)
.AsSplitQuery()
.Where(c => c.UploaderUID == UserUID).ToListAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("SUCCESS"));
return [.. ownCharaData.Select(GetCharaDataFullDto)];
}
[Authorize(Policy = "Identified")]
public async Task<CharaDataFullDto?> CharaDataAttemptRestore(string id)
{
_logger.LogCallInfo(LightlessHubLogger.Args(id));
var charaData = await DbContext.CharaData
.Include(u => u.Files)
.Include(u => u.FileSwaps)
.Include(u => u.OriginalFiles)
.Include(u => u.AllowedIndividiuals)
.ThenInclude(u => u.AllowedUser)
.Include(u => u.AllowedIndividiuals)
.ThenInclude(u => u.AllowedGroup)
.Include(u => u.Poses)
.AsSplitQuery()
.SingleOrDefaultAsync(s => s.Id == id && s.UploaderUID == UserUID)
.ConfigureAwait(false);
if (charaData == null)
return null;
var currentHashes = charaData.Files.Select(f => f.FileCacheHash).ToList();
var missingFiles = charaData.OriginalFiles.Where(c => !currentHashes.Contains(c.Hash, StringComparer.Ordinal)).ToList();
// now let's see what's on the db still
var existingDbFiles = await DbContext.Files
.Where(f => missingFiles.Select(k => k.Hash).Distinct().Contains(f.Hash))
.ToListAsync()
.ConfigureAwait(false);
// now shove it all back into the db
foreach (var dbFile in existingDbFiles)
{
var missingFileEntry = missingFiles.First(f => string.Equals(f.Hash, dbFile.Hash, StringComparison.Ordinal));
charaData.Files.Add(new CharaDataFile()
{
FileCache = dbFile,
GamePath = missingFileEntry.GamePath,
Parent = charaData
});
missingFiles.Remove(missingFileEntry);
}
if (existingDbFiles.Any())
{
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
return GetCharaDataFullDto(charaData);
}
[Authorize(Policy = "Identified")]
public async Task<List<CharaDataMetaInfoDto>> CharaDataGetShared()
{
_logger.LogCallInfo();
List<CharaData> sharedCharaData = [];
var groups = await DbContext.GroupPairs
.Where(u => u.GroupUserUID == UserUID)
.Select(k => k.GroupGID)
.AsNoTracking()
.ToListAsync()
.ConfigureAwait(false);
var pairs = (await GetAllPairInfo(UserUID).ConfigureAwait(false));
var individualPairs = pairs.Where(p => p.Value.IndividuallyPaired && (!p.Value.OwnPermissions?.IsPaused ?? false) && (!p.Value.OtherPermissions?.IsPaused ?? false)).Select(k => k.Key).ToList();
var allPairs = pairs.Where(p => (!p.Value.OwnPermissions?.IsPaused ?? false) && (!p.Value.OtherPermissions?.IsPaused ?? false)).Select(k => k.Key).ToList();
var allSharedDataByPair = await DbContext.CharaData
.Include(u => u.Files)
.Include(u => u.OriginalFiles)
.Include(u => u.AllowedIndividiuals)
.Include(u => u.Poses)
.Include(u => u.Uploader)
.Where(p => p.UploaderUID != UserUID && p.ShareType == CharaDataShare.Shared)
.Where(p =>
(individualPairs.Contains(p.UploaderUID) && p.AccessType == CharaDataAccess.ClosePairs)
|| (allPairs.Contains(p.UploaderUID) && (p.AccessType == CharaDataAccess.AllPairs || p.AccessType == CharaDataAccess.Public))
|| (p.AllowedIndividiuals.Any(u => u.AllowedUserUID == UserUID || (u.AllowedGroupGID != null && groups.Contains(u.AllowedGroupGID)))))
.AsSplitQuery()
.AsNoTracking()
.ToListAsync()
.ConfigureAwait(false);
foreach (var charaData in allSharedDataByPair)
{
sharedCharaData.Add(charaData);
}
_logger.LogCallInfo(LightlessHubLogger.Args("SUCCESS", sharedCharaData.Count));
return [.. sharedCharaData.Select(GetCharaDataMetaInfoDto)];
}
[Authorize(Policy = "Identified")]
public async Task<CharaDataFullDto?> CharaDataUpdate(CharaDataUpdateDto updateDto)
{
var charaData = await DbContext.CharaData
.Include(u => u.Files)
.Include(u => u.OriginalFiles)
.Include(u => u.AllowedIndividiuals)
.ThenInclude(u => u.AllowedUser)
.Include(u => u.AllowedIndividiuals)
.ThenInclude(u => u.AllowedGroup)
.Include(u => u.FileSwaps)
.Include(u => u.Poses)
.AsSplitQuery()
.SingleOrDefaultAsync(u => u.Id == updateDto.Id && u.UploaderUID == UserUID).ConfigureAwait(false);
if (charaData == null)
return null;
bool anyChanges = false;
if (updateDto.Description != null)
{
charaData.Description = updateDto.Description;
anyChanges = true;
}
if (updateDto.ExpiryDate != null)
{
charaData.ExpiryDate = updateDto.ExpiryDate;
anyChanges = true;
}
if (updateDto.GlamourerData != null)
{
charaData.GlamourerData = updateDto.GlamourerData;
anyChanges = true;
}
if (updateDto.CustomizeData != null)
{
charaData.CustomizeData = updateDto.CustomizeData;
anyChanges = true;
}
if (updateDto.ManipulationData != null)
{
charaData.ManipulationData = updateDto.ManipulationData;
anyChanges = true;
}
if (updateDto.AccessType != null)
{
charaData.AccessType = GetAccessType(updateDto.AccessType.Value);
anyChanges = true;
}
if (updateDto.ShareType != null)
{
charaData.ShareType = GetShareType(updateDto.ShareType.Value);
anyChanges = true;
}
if (updateDto.AllowedUsers != null)
{
var individuals = charaData.AllowedIndividiuals.Where(k => k.AllowedGroup == null).ToList();
var allowedUserList = updateDto.AllowedUsers.ToList();
foreach (var user in updateDto.AllowedUsers)
{
if (charaData.AllowedIndividiuals.Any(k => k.AllowedUser != null && (string.Equals(k.AllowedUser.UID, user, StringComparison.Ordinal) || string.Equals(k.AllowedUser.Alias, user, StringComparison.Ordinal))))
{
continue;
}
else
{
var dbUser = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == user || u.Alias == user).ConfigureAwait(false);
if (dbUser != null)
{
charaData.AllowedIndividiuals.Add(new CharaDataAllowance()
{
AllowedUser = dbUser,
Parent = charaData
});
}
}
}
foreach (var dataUser in individuals.Where(k => !updateDto.AllowedUsers.Contains(k.AllowedUser.UID, StringComparer.Ordinal) && !updateDto.AllowedUsers.Contains(k.AllowedUser.Alias, StringComparer.Ordinal)))
{
DbContext.Remove(dataUser);
charaData.AllowedIndividiuals.Remove(dataUser);
}
anyChanges = true;
}
if (updateDto.AllowedGroups != null)
{
var individualGroups = charaData.AllowedIndividiuals.Where(k => k.AllowedUser == null).ToList();
var allowedGroups = updateDto.AllowedGroups.ToList();
foreach (var group in updateDto.AllowedGroups)
{
if (charaData.AllowedIndividiuals.Any(k => k.AllowedGroup != null && (string.Equals(k.AllowedGroup.GID, group, StringComparison.Ordinal) || string.Equals(k.AllowedGroup.Alias, group, StringComparison.Ordinal))))
{
continue;
}
else
{
var groupUser = await DbContext.GroupPairs.Include(u => u.Group).SingleOrDefaultAsync(u => (u.Group.GID == group || u.Group.Alias == group) && u.GroupUserUID == UserUID).ConfigureAwait(false);
if (groupUser != null)
{
charaData.AllowedIndividiuals.Add(new CharaDataAllowance()
{
AllowedGroup = groupUser.Group,
Parent = charaData
});
}
}
}
foreach (var dataGroup in individualGroups.Where(k => !updateDto.AllowedGroups.Contains(k.AllowedGroup.GID, StringComparer.Ordinal) && !updateDto.AllowedGroups.Contains(k.AllowedGroup.Alias, StringComparer.Ordinal)))
{
DbContext.Remove(dataGroup);
charaData.AllowedIndividiuals.Remove(dataGroup);
}
anyChanges = true;
}
if (updateDto.FileGamePaths != null)
{
var originalFiles = charaData.OriginalFiles.ToList();
charaData.OriginalFiles.Clear();
DbContext.RemoveRange(originalFiles);
var files = charaData.Files.ToList();
charaData.Files.Clear();
DbContext.RemoveRange(files);
foreach (var file in updateDto.FileGamePaths)
{
charaData.Files.Add(new CharaDataFile()
{
FileCacheHash = file.HashOrFileSwap,
GamePath = file.GamePath,
Parent = charaData
});
charaData.OriginalFiles.Add(new CharaDataOriginalFile()
{
Hash = file.HashOrFileSwap,
Parent = charaData,
GamePath = file.GamePath
});
}
anyChanges = true;
}
if (updateDto.FileSwaps != null)
{
var fileSwaps = charaData.FileSwaps.ToList();
charaData.FileSwaps.Clear();
DbContext.RemoveRange(fileSwaps);
foreach (var file in updateDto.FileSwaps)
{
charaData.FileSwaps.Add(new CharaDataFileSwap()
{
FilePath = file.HashOrFileSwap,
GamePath = file.GamePath,
Parent = charaData
});
}
anyChanges = true;
}
if (updateDto.Poses != null)
{
foreach (var pose in updateDto.Poses)
{
if (pose.Id == null)
{
charaData.Poses.Add(new CharaDataPose()
{
Description = pose.Description,
Parent = charaData,
ParentUploaderUID = UserUID,
PoseData = pose.PoseData,
WorldData = pose.WorldData == null ? string.Empty : JsonSerializer.Serialize(pose.WorldData),
});
anyChanges = true;
}
else
{
var associatedPose = charaData.Poses.FirstOrDefault(p => p.Id == pose.Id);
if (associatedPose == null)
continue;
if (pose.Description == null && pose.PoseData == null && pose.WorldData == null)
{
charaData.Poses.Remove(associatedPose);
DbContext.Remove(associatedPose);
}
else
{
if (pose.Description != null)
associatedPose.Description = pose.Description;
if (pose.WorldData != null)
{
if (pose.WorldData.Value == default) associatedPose.WorldData = string.Empty;
else associatedPose.WorldData = JsonSerializer.Serialize(pose.WorldData.Value);
}
if (pose.PoseData != null)
associatedPose.PoseData = pose.PoseData;
}
anyChanges = true;
}
var overflowingPoses = charaData.Poses.Skip(10).ToList();
foreach (var overflowing in overflowingPoses)
{
charaData.Poses.Remove(overflowing);
DbContext.Remove(overflowing);
}
}
}
if (anyChanges)
{
charaData.UpdatedDate = DateTime.UtcNow;
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args("SUCCESS", anyChanges));
}
return GetCharaDataFullDto(charaData);
}
private static CharaDataAccess GetAccessType(AccessTypeDto dataAccess) => dataAccess switch
{
AccessTypeDto.Public => CharaDataAccess.Public,
AccessTypeDto.AllPairs => CharaDataAccess.AllPairs,
AccessTypeDto.ClosePairs => CharaDataAccess.ClosePairs,
AccessTypeDto.Individuals => CharaDataAccess.Individuals,
_ => throw new NotSupportedException(),
};
private static AccessTypeDto GetAccessTypeDto(CharaDataAccess dataAccess) => dataAccess switch
{
CharaDataAccess.Public => AccessTypeDto.Public,
CharaDataAccess.AllPairs => AccessTypeDto.AllPairs,
CharaDataAccess.ClosePairs => AccessTypeDto.ClosePairs,
CharaDataAccess.Individuals => AccessTypeDto.Individuals,
_ => throw new NotSupportedException(),
};
private static CharaDataDownloadDto GetCharaDataDownloadDto(CharaData charaData)
{
return new CharaDataDownloadDto(charaData.Id, charaData.Uploader.ToUserData())
{
CustomizeData = charaData.CustomizeData,
Description = charaData.Description,
FileGamePaths = charaData.Files.Select(k => new GamePathEntry(k.FileCacheHash, k.GamePath)).ToList(),
GlamourerData = charaData.GlamourerData,
FileSwaps = charaData.FileSwaps.Select(k => new GamePathEntry(k.FilePath, k.GamePath)).ToList(),
ManipulationData = charaData.ManipulationData,
};
}
private CharaDataFullDto GetCharaDataFullDto(CharaData charaData)
{
return new CharaDataFullDto(charaData.Id, new(UserUID))
{
AccessType = GetAccessTypeDto(charaData.AccessType),
ShareType = GetShareTypeDto(charaData.ShareType),
AllowedUsers = [.. charaData.AllowedIndividiuals.Where(k => !string.IsNullOrEmpty(k.AllowedUserUID)).Select(u => new UserData(u.AllowedUser.UID, u.AllowedUser.Alias))],
AllowedGroups = [.. charaData.AllowedIndividiuals.Where(k => !string.IsNullOrEmpty(k.AllowedGroupGID)).Select(k => new GroupData(k.AllowedGroup.GID, k.AllowedGroup.Alias))],
CustomizeData = charaData.CustomizeData,
Description = charaData.Description,
ExpiryDate = charaData.ExpiryDate ?? DateTime.MaxValue,
OriginalFiles = charaData.OriginalFiles.Select(k => new GamePathEntry(k.Hash, k.GamePath)).ToList(),
FileGamePaths = charaData.Files.Select(k => new GamePathEntry(k.FileCacheHash, k.GamePath)).ToList(),
FileSwaps = charaData.FileSwaps.Select(k => new GamePathEntry(k.FilePath, k.GamePath)).ToList(),
GlamourerData = charaData.GlamourerData,
CreatedDate = charaData.CreatedDate,
UpdatedDate = charaData.UpdatedDate,
ManipulationData = charaData.ManipulationData,
DownloadCount = charaData.DownloadCount,
PoseData = [.. charaData.Poses.OrderBy(p => p.Id).Select(k =>
{
WorldData data = default;
if(!string.IsNullOrEmpty(k.WorldData)) data = JsonSerializer.Deserialize<WorldData>(k.WorldData);
return new PoseEntry(k.Id)
{
Description = k.Description,
PoseData = k.PoseData,
WorldData = data
};
})],
};
}
private static CharaDataMetaInfoDto GetCharaDataMetaInfoDto(CharaData charaData)
{
var allOrigHashes = charaData.OriginalFiles.Select(k => k.Hash).ToList();
var allFileHashes = charaData.Files.Select(f => f.FileCacheHash).ToList();
var allHashesPresent = allOrigHashes.TrueForAll(h => allFileHashes.Contains(h, StringComparer.Ordinal));
var canBeDownloaded = allHashesPresent &= !string.IsNullOrEmpty(charaData.GlamourerData);
return new CharaDataMetaInfoDto(charaData.Id, charaData.Uploader.ToUserData())
{
CanBeDownloaded = canBeDownloaded,
Description = charaData.Description,
UpdatedDate = charaData.UpdatedDate,
PoseData = [.. charaData.Poses.OrderBy(p => p.Id).Select(k =>
{
WorldData data = default;
if(!string.IsNullOrEmpty(k.WorldData)) data = JsonSerializer.Deserialize<WorldData>(k.WorldData);
return new PoseEntry(k.Id)
{
Description = k.Description,
PoseData = k.PoseData,
WorldData = data
};
})],
};
}
private static CharaDataShare GetShareType(ShareTypeDto dataShare) => dataShare switch
{
ShareTypeDto.Shared => CharaDataShare.Shared,
ShareTypeDto.Private => CharaDataShare.Private,
_ => throw new NotSupportedException(),
};
private static ShareTypeDto GetShareTypeDto(CharaDataShare dataShare) => dataShare switch
{
CharaDataShare.Shared => ShareTypeDto.Shared,
CharaDataShare.Private => ShareTypeDto.Private,
_ => throw new NotSupportedException(),
};
private async Task<bool> CheckCharaDataAllowance(CharaData charaData, List<string> joinedGroups)
{
// check for self
if (string.Equals(charaData.UploaderUID, UserUID, StringComparison.Ordinal))
return true;
// check for public access
if (charaData.AccessType == CharaDataAccess.Public)
return true;
// check for individuals
if (charaData.AllowedIndividiuals.Any(u => string.Equals(u.AllowedUserUID, UserUID, StringComparison.Ordinal)))
return true;
if (charaData.AllowedIndividiuals.Any(u => joinedGroups.Contains(u.AllowedGroupGID, StringComparer.Ordinal)))
return true;
var pairInfoUploader = await GetAllPairInfo(charaData.UploaderUID).ConfigureAwait(false);
// check for all pairs
if (charaData.AccessType == CharaDataAccess.AllPairs)
{
if (pairInfoUploader.TryGetValue(UserUID, out var userInfo) && userInfo.IsSynced && !userInfo.OwnPermissions.IsPaused && !userInfo.OtherPermissions.IsPaused)
{
return true;
}
return false;
}
// check for individual pairs
if (charaData.AccessType == CharaDataAccess.ClosePairs)
{
if (pairInfoUploader.TryGetValue(UserUID, out var userInfo) && userInfo.IsSynced && !userInfo.OwnPermissions.IsPaused && !userInfo.OtherPermissions.IsPaused
&& userInfo.IndividuallyPaired)
{
return true;
}
return false;
}
return false;
}
private async Task<CharaData> GetCharaDataById(string id, string methodName)
{
var splitid = id.Split(":", StringSplitOptions.None);
if (splitid.Length != 2)
{
_logger.LogCallWarning(LightlessHubLogger.Args("INVALID", id));
throw new InvalidOperationException($"Id {id} not in expected format");
}
var charaData = await DbContext.CharaData
.Include(u => u.Files)
.Include(u => u.FileSwaps)
.Include(u => u.AllowedIndividiuals)
.Include(u => u.Poses)
.Include(u => u.Uploader)
.AsSplitQuery()
.SingleOrDefaultAsync(c => c.Id == splitid[1] && c.UploaderUID == splitid[0]).ConfigureAwait(false);
if (charaData == null)
{
_logger.LogCallWarning(LightlessHubLogger.Args("NOT FOUND", id));
throw new InvalidDataException($"No chara data with {id} found");
}
var groups = await DbContext.GroupPairs.Where(u => u.GroupUserUID == UserUID).Select(k => k.GroupGID).ToListAsync()
.ConfigureAwait(false);
if (!await CheckCharaDataAllowance(charaData, groups).ConfigureAwait(false))
{
_logger.LogCallWarning(LightlessHubLogger.Args("UNAUTHORIZED", id));
throw new UnauthorizedAccessException($"User is not allowed to download {id}");
}
return charaData;
}
}

View File

@@ -0,0 +1,58 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
namespace LightlessSyncServer.Hubs
{
public partial class LightlessHub
{
public Task Client_DownloadReady(Guid requestId) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupChangePermissions(GroupPermissionDto groupPermission) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupDelete(GroupDto groupDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupPairChangeUserInfo(GroupPairUserInfoDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupPairJoined(GroupPairFullInfoDto groupPairInfoDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupPairLeft(GroupPairDto groupPairDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupSendFullInfo(GroupFullInfoDto groupInfo) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupSendInfo(GroupInfoDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_ReceiveServerMessage(MessageSeverity messageSeverity, string message) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UpdateSystemInfo(SystemInfoDto systemInfo) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserAddClientPair(UserPairDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserReceiveCharacterData(OnlineUserCharaDataDto dataDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserReceiveUploadStatus(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserRemoveClientPair(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserSendOffline(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserSendOnline(OnlineUserIdentDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserUpdateOtherPairPermissions(UserPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserUpdateProfile(UserDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserUpdateSelfPairPermissions(UserPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UserUpdateDefaultPermissions(DefaultPermissionsDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_UpdateUserIndividualPairStatusDto(UserIndividualPairStatusDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GroupChangeUserPairPermissions(GroupPairUserPermissionDto dto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GposeLobbyJoin(UserData userData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GposeLobbyLeave(UserData userData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GposeLobbyPushPoseData(UserData userData, PoseData poseData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
public Task Client_GposeLobbyPushWorldData(UserData userData, WorldData worldData) => throw new PlatformNotSupportedException("Calling clientside method on server not supported");
}
}

View File

@@ -0,0 +1,488 @@
using LightlessSyncShared.Models;
using Microsoft.EntityFrameworkCore;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Utils;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Group;
using LightlessSyncShared.Metrics;
using Microsoft.AspNetCore.SignalR;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
public string UserCharaIdent => Context.User?.Claims?.SingleOrDefault(c => string.Equals(c.Type, LightlessClaimTypes.CharaIdent, StringComparison.Ordinal))?.Value ?? throw new Exception("No Chara Ident in Claims");
public string UserUID => Context.User?.Claims?.SingleOrDefault(c => string.Equals(c.Type, LightlessClaimTypes.Uid, StringComparison.Ordinal))?.Value ?? throw new Exception("No UID in Claims");
public string Continent => Context.User?.Claims?.SingleOrDefault(c => string.Equals(c.Type, LightlessClaimTypes.Continent, StringComparison.Ordinal))?.Value ?? "UNK";
private async Task DeleteUser(User user)
{
var ownPairData = await DbContext.ClientPairs.Where(u => u.User.UID == user.UID).ToListAsync().ConfigureAwait(false);
var auth = await DbContext.Auth.SingleAsync(u => u.UserUID == user.UID).ConfigureAwait(false);
var lodestone = await DbContext.LodeStoneAuth.SingleOrDefaultAsync(a => a.User.UID == user.UID).ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.Where(g => g.GroupUserUID == user.UID).ToListAsync().ConfigureAwait(false);
var userProfileData = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == user.UID).ConfigureAwait(false);
var defaultpermissions = await DbContext.UserDefaultPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == user.UID).ConfigureAwait(false);
var groupPermissions = await DbContext.GroupPairPreferredPermissions.Where(u => u.UserUID == user.UID).ToListAsync().ConfigureAwait(false);
var individualPermissions = await DbContext.Permissions.Where(u => u.UserUID == user.UID || u.OtherUserUID == user.UID).ToListAsync().ConfigureAwait(false);
var bannedEntries = await DbContext.GroupBans.Where(u => u.BannedUserUID == user.UID).ToListAsync().ConfigureAwait(false);
if (lodestone != null)
{
DbContext.Remove(lodestone);
}
if (userProfileData != null)
{
DbContext.Remove(userProfileData);
}
while (DbContext.Files.Any(f => f.Uploader == user))
{
await Task.Delay(1000).ConfigureAwait(false);
}
DbContext.ClientPairs.RemoveRange(ownPairData);
var otherPairData = await DbContext.ClientPairs.Include(u => u.User)
.Where(u => u.OtherUser.UID == user.UID).AsNoTracking().ToListAsync().ConfigureAwait(false);
foreach (var pair in otherPairData)
{
await Clients.User(pair.UserUID).Client_UserRemoveClientPair(new(user.ToUserData())).ConfigureAwait(false);
}
foreach (var pair in groupPairs)
{
await UserLeaveGroup(new GroupDto(new GroupData(pair.GroupGID)), user.UID).ConfigureAwait(false);
}
if (defaultpermissions != null)
{
DbContext.UserDefaultPreferredPermissions.Remove(defaultpermissions);
}
DbContext.GroupPairPreferredPermissions.RemoveRange(groupPermissions);
DbContext.Permissions.RemoveRange(individualPermissions);
DbContext.GroupBans.RemoveRange(bannedEntries);
_lightlessMetrics.IncCounter(MetricsAPI.CounterUsersRegisteredDeleted, 1);
DbContext.ClientPairs.RemoveRange(otherPairData);
DbContext.Users.Remove(user);
DbContext.Auth.Remove(auth);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
private async Task<List<string>> GetAllPairedUnpausedUsers(string? uid = null)
{
uid ??= UserUID;
return (await GetSyncedUnpausedOnlinePairs(UserUID).ConfigureAwait(false));
}
private async Task<Dictionary<string, string>> GetOnlineUsers(List<string> uids)
{
var result = await _redis.GetAllAsync<string>(uids.Select(u => "UID:" + u).ToHashSet(StringComparer.Ordinal)).ConfigureAwait(false);
return uids.Where(u => result.TryGetValue("UID:" + u, out var ident) && !string.IsNullOrEmpty(ident)).ToDictionary(u => u, u => result["UID:" + u], StringComparer.Ordinal);
}
private async Task<string> GetUserIdent(string uid)
{
if (string.IsNullOrEmpty(uid)) return string.Empty;
return await _redis.GetAsync<string>("UID:" + uid).ConfigureAwait(false);
}
private async Task RemoveUserFromRedis()
{
await _redis.RemoveAsync("UID:" + UserUID, StackExchange.Redis.CommandFlags.FireAndForget).ConfigureAwait(false);
}
private async Task SendGroupDeletedToAll(List<GroupPair> groupUsers)
{
foreach (var pair in groupUsers)
{
var pairIdent = await GetUserIdent(pair.GroupUserUID).ConfigureAwait(false);
if (string.IsNullOrEmpty(pairIdent)) continue;
var pairInfo = await GetAllPairInfo(pair.GroupUserUID).ConfigureAwait(false);
foreach (var groupUserPair in groupUsers.Where(g => !string.Equals(g.GroupUserUID, pair.GroupUserUID, StringComparison.Ordinal)))
{
await UserGroupLeave(groupUserPair, pairIdent, pairInfo, pair.GroupUserUID).ConfigureAwait(false);
}
}
}
private async Task<List<string>> SendOfflineToAllPairedUsers()
{
var usersToSendDataTo = await GetAllPairedUnpausedUsers().ConfigureAwait(false);
var self = await DbContext.Users.AsNoTracking().SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
await Clients.Users(usersToSendDataTo).Client_UserSendOffline(new(self.ToUserData())).ConfigureAwait(false);
return usersToSendDataTo;
}
private async Task<List<string>> SendOnlineToAllPairedUsers()
{
var usersToSendDataTo = await GetAllPairedUnpausedUsers().ConfigureAwait(false);
var self = await DbContext.Users.AsNoTracking().SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
await Clients.Users(usersToSendDataTo).Client_UserSendOnline(new(self.ToUserData(), UserCharaIdent)).ConfigureAwait(false);
return usersToSendDataTo;
}
private async Task<(bool IsValid, Group ReferredGroup)> TryValidateGroupModeratorOrOwner(string gid)
{
var isOwnerResult = await TryValidateOwner(gid).ConfigureAwait(false);
if (isOwnerResult.isValid) return (true, isOwnerResult.ReferredGroup);
if (isOwnerResult.ReferredGroup == null) return (false, null);
var groupPairSelf = await DbContext.GroupPairs.SingleOrDefaultAsync(g => g.GroupGID == gid && g.GroupUserUID == UserUID).ConfigureAwait(false);
if (groupPairSelf == null || !groupPairSelf.IsModerator) return (false, null);
return (true, isOwnerResult.ReferredGroup);
}
private async Task<(bool isValid, Group ReferredGroup)> TryValidateOwner(string gid)
{
var group = await DbContext.Groups.SingleOrDefaultAsync(g => g.GID == gid).ConfigureAwait(false);
if (group == null) return (false, null);
return (string.Equals(group.OwnerUID, UserUID, StringComparison.Ordinal), group);
}
private async Task<(bool IsValid, GroupPair ReferredPair)> TryValidateUserInGroup(string gid, string? uid = null)
{
uid ??= UserUID;
var groupPair = await DbContext.GroupPairs.Include(c => c.GroupUser)
.SingleOrDefaultAsync(g => g.GroupGID == gid && (g.GroupUserUID == uid || g.GroupUser.Alias == uid)).ConfigureAwait(false);
if (groupPair == null) return (false, null);
return (true, groupPair);
}
private async Task UpdateUserOnRedis()
{
await _redis.AddAsync("UID:" + UserUID, UserCharaIdent, 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)
{
uid ??= UserUID;
if (!allUserPairs.TryGetValue(groupUserPair.GroupUserUID, out var info) || !info.IsSynced)
{
var groupUserIdent = await GetUserIdent(groupUserPair.GroupUserUID).ConfigureAwait(false);
if (!string.IsNullOrEmpty(groupUserIdent))
{
await Clients.User(uid).Client_UserSendOffline(new(new(groupUserPair.GroupUserUID))).ConfigureAwait(false);
await Clients.User(groupUserPair.GroupUserUID).Client_UserSendOffline(new(new(uid))).ConfigureAwait(false);
}
}
}
private async Task UserLeaveGroup(GroupDto dto, string userUid)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (exists, groupPair) = await TryValidateUserInGroup(dto.Group.GID, userUid).ConfigureAwait(false);
if (!exists) return;
var group = await DbContext.Groups.SingleOrDefaultAsync(g => g.GID == dto.Group.GID).ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == group.GID).ToListAsync().ConfigureAwait(false);
var groupPairsWithoutSelf = groupPairs.Where(p => !string.Equals(p.GroupUserUID, userUid, StringComparison.Ordinal)).ToList();
DbContext.GroupPairs.Remove(groupPair);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await Clients.User(userUid).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
bool ownerHasLeft = string.Equals(group.OwnerUID, userUid, StringComparison.Ordinal);
if (ownerHasLeft)
{
if (!groupPairsWithoutSelf.Any())
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Deleted"));
DbContext.Groups.Remove(group);
}
else
{
var groupHasMigrated = await SharedDbFunctions.MigrateOrDeleteGroup(DbContext, group, groupPairsWithoutSelf, _maxExistingGroupsByUser).ConfigureAwait(false);
if (groupHasMigrated.Item1)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Migrated", groupHasMigrated.Item2));
var user = await DbContext.Users.SingleAsync(u => u.UID == groupHasMigrated.Item2).ConfigureAwait(false);
await Clients.Users(groupPairsWithoutSelf.Select(p => p.GroupUserUID)).Client_GroupSendInfo(new GroupInfoDto(group.ToGroupData(),
user.ToUserData(), group.ToEnum())).ConfigureAwait(false);
}
else
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Deleted"));
await Clients.Users(groupPairsWithoutSelf.Select(p => p.GroupUserUID)).Client_GroupDelete(dto).ConfigureAwait(false);
await SendGroupDeletedToAll(groupPairs).ConfigureAwait(false);
return;
}
}
}
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == userUid).ToListAsync().ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
await Clients.Users(groupPairsWithoutSelf.Select(p => p.GroupUserUID)).Client_GroupPairLeft(new GroupPairDto(dto.Group, groupPair.GroupUser.ToUserData())).ConfigureAwait(false);
var ident = await GetUserIdent(userUid).ConfigureAwait(false);
var pairs = await GetAllPairInfo(userUid).ConfigureAwait(false);
foreach (var groupUserPair in groupPairsWithoutSelf)
{
await UserGroupLeave(groupUserPair, ident, pairs, userUid).ConfigureAwait(false);
}
}
private async Task<UserInfo?> GetPairInfo(string uid, string otheruid)
{
var clientPairs = from cp in DbContext.ClientPairs.AsNoTracking().Where(u => u.UserUID == uid && u.OtherUserUID == otheruid)
join cp2 in DbContext.ClientPairs.AsNoTracking().Where(u => u.OtherUserUID == uid && u.UserUID == otheruid)
on new
{
UserUID = cp.UserUID,
OtherUserUID = cp.OtherUserUID
}
equals new
{
UserUID = cp2.OtherUserUID,
OtherUserUID = cp2.UserUID
} into joined
from c in joined.DefaultIfEmpty()
where cp.UserUID == uid
select new
{
UserUID = cp.UserUID,
OtherUserUID = cp.OtherUserUID,
Gid = string.Empty,
Synced = c != null
};
var groupPairs = from gp in DbContext.GroupPairs.AsNoTracking().Where(u => u.GroupUserUID == uid)
join gp2 in DbContext.GroupPairs.AsNoTracking().Where(u => u.GroupUserUID == otheruid)
on new
{
GID = gp.GroupGID
}
equals new
{
GID = gp2.GroupGID
}
where gp.GroupUserUID == uid
select new
{
UserUID = gp.GroupUserUID,
OtherUserUID = gp2.GroupUserUID,
Gid = Convert.ToString(gp2.GroupGID),
Synced = true
};
var allPairs = clientPairs.Concat(groupPairs);
var result = from user in allPairs
join u in DbContext.Users.AsNoTracking() on user.OtherUserUID equals u.UID
join o in DbContext.Permissions.AsNoTracking().Where(u => u.UserUID == uid)
on new { UserUID = user.UserUID, OtherUserUID = user.OtherUserUID }
equals new { UserUID = o.UserUID, OtherUserUID = o.OtherUserUID }
into ownperms
from ownperm in ownperms.DefaultIfEmpty()
join p in DbContext.Permissions.AsNoTracking().Where(u => u.OtherUserUID == uid)
on new { UserUID = user.OtherUserUID, OtherUserUID = user.UserUID }
equals new { UserUID = p.UserUID, OtherUserUID = p.OtherUserUID }
into otherperms
from otherperm in otherperms.DefaultIfEmpty()
where user.UserUID == uid
&& u.UID == user.OtherUserUID
&& ownperm.UserUID == user.UserUID && ownperm.OtherUserUID == user.OtherUserUID
&& (otherperm == null || (otherperm.OtherUserUID == user.UserUID && otherperm.UserUID == user.OtherUserUID))
select new
{
UserUID = user.UserUID,
OtherUserUID = user.OtherUserUID,
OtherUserAlias = u.Alias,
GID = user.Gid,
Synced = user.Synced,
OwnPermissions = ownperm,
OtherPermissions = otherperm
};
var resultList = await result.AsNoTracking().ToListAsync().ConfigureAwait(false);
if (!resultList.Any()) return null;
var groups = resultList.Select(g => g.GID).ToList();
return new UserInfo(resultList[0].OtherUserAlias,
resultList.SingleOrDefault(p => string.IsNullOrEmpty(p.GID))?.Synced ?? false,
resultList.Max(p => p.Synced),
resultList.Select(p => string.IsNullOrEmpty(p.GID) ? Constants.IndividualKeyword : p.GID).ToList(),
resultList[0].OwnPermissions,
resultList[0].OtherPermissions);
}
private async Task<Dictionary<string, UserInfo>> GetAllPairInfo(string uid)
{
var clientPairs = from cp in DbContext.ClientPairs.AsNoTracking().Where(u => u.UserUID == uid)
join cp2 in DbContext.ClientPairs.AsNoTracking().Where(u => u.OtherUserUID == uid)
on new
{
UserUID = cp.UserUID,
OtherUserUID = cp.OtherUserUID
}
equals new
{
UserUID = cp2.OtherUserUID,
OtherUserUID = cp2.UserUID
} into joined
from c in joined.DefaultIfEmpty()
where cp.UserUID == uid
select new
{
UserUID = cp.UserUID,
OtherUserUID = cp.OtherUserUID,
Gid = string.Empty,
Synced = c != null
};
var groupPairs = from gp in DbContext.GroupPairs.AsNoTracking().Where(u => u.GroupUserUID == uid)
join gp2 in DbContext.GroupPairs.AsNoTracking().Where(u => u.GroupUserUID != uid)
on new
{
GID = gp.GroupGID
}
equals new
{
GID = gp2.GroupGID
}
select new
{
UserUID = gp.GroupUserUID,
OtherUserUID = gp2.GroupUserUID,
Gid = Convert.ToString(gp2.GroupGID),
Synced = true
};
var allPairs = clientPairs.Concat(groupPairs);
var result = from user in allPairs
join u in DbContext.Users.AsNoTracking() on user.OtherUserUID equals u.UID
join o in DbContext.Permissions.AsNoTracking().Where(u => u.UserUID == uid)
on new { UserUID = user.UserUID, OtherUserUID = user.OtherUserUID }
equals new { UserUID = o.UserUID, OtherUserUID = o.OtherUserUID }
into ownperms
from ownperm in ownperms.DefaultIfEmpty()
join p in DbContext.Permissions.AsNoTracking().Where(u => u.OtherUserUID == uid)
on new { UserUID = user.OtherUserUID, OtherUserUID = user.UserUID }
equals new { UserUID = p.UserUID, OtherUserUID = p.OtherUserUID }
into otherperms
from otherperm in otherperms.DefaultIfEmpty()
where user.UserUID == uid
&& u.UID == user.OtherUserUID
&& ownperm.UserUID == user.UserUID && ownperm.OtherUserUID == user.OtherUserUID
&& (otherperm == null || (otherperm.OtherUserUID == user.UserUID && otherperm.UserUID == user.OtherUserUID))
select new
{
UserUID = user.UserUID,
OtherUserUID = user.OtherUserUID,
OtherUserAlias = u.Alias,
GID = user.Gid,
Synced = user.Synced,
OwnPermissions = ownperm,
OtherPermissions = otherperm
};
var resultList = await result.AsNoTracking().ToListAsync().ConfigureAwait(false);
return resultList.GroupBy(g => g.OtherUserUID, StringComparer.Ordinal).ToDictionary(g => g.Key, g =>
{
return new UserInfo(g.First().OtherUserAlias,
g.SingleOrDefault(p => string.IsNullOrEmpty(p.GID))?.Synced ?? false,
g.Max(p => p.Synced),
g.Select(p => string.IsNullOrEmpty(p.GID) ? Constants.IndividualKeyword : p.GID).ToList(),
g.First().OwnPermissions,
g.First().OtherPermissions);
}, StringComparer.Ordinal);
}
private async Task<List<string>> GetSyncedUnpausedOnlinePairs(string uid)
{
var clientPairs = from cp in DbContext.ClientPairs.AsNoTracking().Where(u => u.UserUID == uid)
join cp2 in DbContext.ClientPairs.AsNoTracking().Where(u => u.OtherUserUID == uid)
on new
{
UserUID = cp.UserUID,
OtherUserUID = cp.OtherUserUID
}
equals new
{
UserUID = cp2.OtherUserUID,
OtherUserUID = cp2.UserUID
} into joined
from c in joined.DefaultIfEmpty()
where cp.UserUID == uid && c.UserUID != null
select new
{
UserUID = cp.UserUID,
OtherUserUID = cp.OtherUserUID,
};
var groupPairs = from gp in DbContext.GroupPairs.AsNoTracking().Where(u => u.GroupUserUID == uid)
join gp2 in DbContext.GroupPairs.AsNoTracking().Where(u => u.GroupUserUID != uid)
on new
{
GID = gp.GroupGID
}
equals new
{
GID = gp2.GroupGID
}
select new
{
UserUID = gp.GroupUserUID,
OtherUserUID = gp2.GroupUserUID,
};
var allPairs = clientPairs.Concat(groupPairs);
var result = from user in allPairs
join o in DbContext.Permissions.AsNoTracking().Where(u => u.UserUID == uid)
on new { UserUID = user.UserUID, OtherUserUID = user.OtherUserUID }
equals new { UserUID = o.UserUID, OtherUserUID = o.OtherUserUID }
into ownperms
from ownperm in ownperms.DefaultIfEmpty()
join p in DbContext.Permissions.AsNoTracking().Where(u => u.OtherUserUID == uid)
on new { UserUID = user.OtherUserUID, OtherUserUID = user.UserUID }
equals new { UserUID = p.UserUID, OtherUserUID = p.OtherUserUID }
into otherperms
from otherperm in otherperms.DefaultIfEmpty()
where user.UserUID == uid
&& ownperm.UserUID == user.UserUID && ownperm.OtherUserUID == user.OtherUserUID
&& otherperm.OtherUserUID == user.UserUID && otherperm.UserUID == user.OtherUserUID
&& !ownperm.IsPaused && (otherperm == null ? false : !otherperm.IsPaused)
select user.OtherUserUID;
return await result.Distinct().AsNoTracking().ToListAsync().ConfigureAwait(false);
}
public record UserInfo(string Alias, bool IndividuallyPaired, bool IsSynced, List<string> GIDs, UserPermissionSet? OwnPermissions, UserPermissionSet? OtherPermissions);
}

View File

@@ -0,0 +1,155 @@
using LightlessSync.API.Data;
using LightlessSync.API.Dto.CharaData;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
private async Task<string?> GetUserGposeLobby()
{
return await _redis.GetAsync<string>(GposeLobbyUser).ConfigureAwait(false);
}
private async Task<List<string>> GetUsersInLobby(string lobbyId, bool includeSelf = false)
{
var users = await _redis.GetAsync<List<string>>($"GposeLobby:{lobbyId}").ConfigureAwait(false);
return users?.Where(u => includeSelf || !string.Equals(u, UserUID, StringComparison.Ordinal)).ToList() ?? [];
}
private async Task AddUserToLobby(string lobbyId, List<string> priorUsers)
{
_lightlessMetrics.IncGauge(MetricsAPI.GaugeGposeLobbyUsers);
if (priorUsers.Count == 0)
_lightlessMetrics.IncGauge(MetricsAPI.GaugeGposeLobbies);
await _redis.AddAsync(GposeLobbyUser, lobbyId).ConfigureAwait(false);
await _redis.AddAsync($"GposeLobby:{lobbyId}", priorUsers.Concat([UserUID])).ConfigureAwait(false);
}
private async Task RemoveUserFromLobby(string lobbyId, List<string> priorUsers)
{
await _redis.RemoveAsync(GposeLobbyUser).ConfigureAwait(false);
_lightlessMetrics.DecGauge(MetricsAPI.GaugeGposeLobbyUsers);
if (priorUsers.Count == 1)
{
await _redis.RemoveAsync($"GposeLobby:{lobbyId}").ConfigureAwait(false);
_lightlessMetrics.DecGauge(MetricsAPI.GaugeGposeLobbies);
}
else
{
priorUsers.Remove(UserUID);
await _redis.AddAsync($"GposeLobby:{lobbyId}", priorUsers).ConfigureAwait(false);
await Clients.Users(priorUsers).Client_GposeLobbyLeave(new(UserUID)).ConfigureAwait(false);
}
}
private string GposeLobbyUser => $"GposeLobbyUser:{UserUID}";
[Authorize(Policy = "Identified")]
public async Task<string> GposeLobbyCreate()
{
_logger.LogCallInfo();
var alreadyInLobby = await GetUserGposeLobby().ConfigureAwait(false);
if (!string.IsNullOrEmpty(alreadyInLobby))
{
throw new HubException("Already in GPose Lobby, cannot join another");
}
string lobbyId = string.Empty;
while (string.IsNullOrEmpty(lobbyId))
{
lobbyId = StringUtils.GenerateRandomString(30, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789");
var result = await _redis.GetAsync<List<string>>($"GposeLobby:{lobbyId}").ConfigureAwait(false);
if (result != null)
lobbyId = string.Empty;
}
await AddUserToLobby(lobbyId, []).ConfigureAwait(false);
return lobbyId;
}
[Authorize(Policy = "Identified")]
public async Task<List<UserData>> GposeLobbyJoin(string lobbyId)
{
_logger.LogCallInfo();
var existingLobbyId = await GetUserGposeLobby().ConfigureAwait(false);
if (!string.IsNullOrEmpty(existingLobbyId))
await GposeLobbyLeave().ConfigureAwait(false);
var lobbyUsers = await GetUsersInLobby(lobbyId).ConfigureAwait(false);
if (!lobbyUsers.Any())
return [];
await AddUserToLobby(lobbyId, lobbyUsers).ConfigureAwait(false);
var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
await Clients.Users(lobbyUsers.Where(u => !string.Equals(u, UserUID, StringComparison.Ordinal)))
.Client_GposeLobbyJoin(user.ToUserData()).ConfigureAwait(false);
var users = await DbContext.Users.Where(u => lobbyUsers.Contains(u.UID))
.Select(u => u.ToUserData())
.ToListAsync()
.ConfigureAwait(false);
return users;
}
[Authorize(Policy = "Identified")]
public async Task<bool> GposeLobbyLeave()
{
var lobbyId = await GetUserGposeLobby().ConfigureAwait(false);
if (string.IsNullOrEmpty(lobbyId))
return true;
_logger.LogCallInfo();
var lobbyUsers = await GetUsersInLobby(lobbyId, true).ConfigureAwait(false);
await RemoveUserFromLobby(lobbyId, lobbyUsers).ConfigureAwait(false);
return true;
}
[Authorize(Policy = "Identified")]
public async Task GposeLobbyPushCharacterData(CharaDataDownloadDto charaDataDownloadDto)
{
_logger.LogCallInfo();
var lobbyId = await GetUserGposeLobby().ConfigureAwait(false);
if (string.IsNullOrEmpty(lobbyId))
return;
var lobbyUsers = await GetUsersInLobby(lobbyId).ConfigureAwait(false);
await Clients.Users(lobbyUsers).Client_GposeLobbyPushCharacterData(charaDataDownloadDto).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task GposeLobbyPushPoseData(PoseData poseData)
{
_logger.LogCallInfo();
var lobbyId = await GetUserGposeLobby().ConfigureAwait(false);
if (string.IsNullOrEmpty(lobbyId))
return;
await _gPoseLobbyDistributionService.PushPoseData(lobbyId, UserUID, poseData).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task GposeLobbyPushWorldData(WorldData worldData)
{
_logger.LogCallInfo();
var lobbyId = await GetUserGposeLobby().ConfigureAwait(false);
if (string.IsNullOrEmpty(lobbyId))
return;
await _gPoseLobbyDistributionService.PushWorldData(lobbyId, UserUID, worldData).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,672 @@
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Models;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
[Authorize(Policy = "Identified")]
public async Task GroupBanUser(GroupPairDto dto, string reason)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, reason));
var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!userHasRights) return;
var (userExists, groupPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false);
if (!userExists) return;
if (groupPair.IsModerator || string.Equals(group.OwnerUID, dto.User.UID, StringComparison.Ordinal)) return;
var alias = string.IsNullOrEmpty(groupPair.GroupUser.Alias) ? "-" : groupPair.GroupUser.Alias;
var ban = new GroupBan()
{
BannedByUID = UserUID,
BannedReason = $"{reason} (Alias at time of ban: {alias})",
BannedOn = DateTime.UtcNow,
BannedUserUID = dto.User.UID,
GroupGID = dto.Group.GID,
};
DbContext.Add(ban);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await GroupRemoveUser(dto).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
}
[Authorize(Policy = "Identified")]
public async Task GroupChangeGroupPermissionState(GroupPermissionDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
group.InvitesEnabled = !dto.Permissions.HasFlag(GroupPermissions.DisableInvites);
group.PreferDisableSounds = dto.Permissions.HasFlag(GroupPermissions.PreferDisableSounds);
group.PreferDisableAnimations = dto.Permissions.HasFlag(GroupPermissions.PreferDisableAnimations);
group.PreferDisableVFX = dto.Permissions.HasFlag(GroupPermissions.PreferDisableVFX);
await DbContext.SaveChangesAsync().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);
}
[Authorize(Policy = "Identified")]
public async Task GroupChangeOwnership(GroupPairDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (isOwner, group) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false);
if (!isOwner) return;
var (isInGroup, newOwnerPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false);
if (!isInGroup) return;
var ownedShells = await DbContext.Groups.CountAsync(g => g.OwnerUID == dto.User.UID).ConfigureAwait(false);
if (ownedShells >= _maxExistingGroupsByUser) return;
var prevOwner = await DbContext.GroupPairs.SingleOrDefaultAsync(g => g.GroupGID == dto.Group.GID && g.GroupUserUID == UserUID).ConfigureAwait(false);
prevOwner.IsPinned = false;
group.Owner = newOwnerPair.GroupUser;
group.Alias = null;
newOwnerPair.IsPinned = true;
newOwnerPair.IsModerator = false;
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).AsNoTracking().ToListAsync().ConfigureAwait(false);
await Clients.Users(groupPairs).Client_GroupSendInfo(new GroupInfoDto(group.ToGroupData(), newOwnerPair.GroupUser.ToUserData(), group.ToEnum())).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<bool> GroupChangePassword(GroupPasswordDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (isOwner, group) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false);
if (!isOwner || dto.Password.Length < 10) return false;
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
group.HashedPassword = StringUtils.Sha256String(dto.Password);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return true;
}
[Authorize(Policy = "Identified")]
public async Task GroupClear(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
var groupPairs = await DbContext.GroupPairs.Include(p => p.GroupUser).Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false);
var notPinned = groupPairs.Where(g => !g.IsPinned && !g.IsModerator).ToList();
await Clients.Users(notPinned.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
DbContext.GroupPairs.RemoveRange(notPinned);
foreach (var pair in notPinned)
{
await Clients.Users(groupPairs.Where(p => p.IsPinned || p.IsModerator).Select(g => g.GroupUserUID))
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false);
var pairIdent = await GetUserIdent(pair.GroupUserUID).ConfigureAwait(false);
if (string.IsNullOrEmpty(pairIdent)) continue;
var allUserPairs = await GetAllPairInfo(pair.GroupUserUID).ConfigureAwait(false);
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == pair.GroupUserUID).ToListAsync().ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
foreach (var groupUserPair in groupPairs.Where(p => !string.Equals(p.GroupUserUID, pair.GroupUserUID, StringComparison.Ordinal)))
{
await UserGroupLeave(pair, pairIdent, allUserPairs, pair.GroupUserUID).ConfigureAwait(false);
}
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<GroupJoinDto> GroupCreate()
{
_logger.LogCallInfo();
var existingGroupsByUser = await DbContext.Groups.CountAsync(u => u.OwnerUID == UserUID).ConfigureAwait(false);
var existingJoinedGroups = await DbContext.GroupPairs.CountAsync(u => u.GroupUserUID == UserUID).ConfigureAwait(false);
if (existingGroupsByUser >= _maxExistingGroupsByUser || existingJoinedGroups >= _maxJoinedGroupsByUser)
{
throw new System.Exception($"Max groups for user is {_maxExistingGroupsByUser}, max joined groups is {_maxJoinedGroupsByUser}.");
}
var gid = StringUtils.GenerateRandomString(12);
while (await DbContext.Groups.AnyAsync(g => g.GID == "MSS-" + gid).ConfigureAwait(false))
{
gid = StringUtils.GenerateRandomString(12);
}
gid = "MSS-" + gid;
var passwd = StringUtils.GenerateRandomString(16);
using var sha = SHA256.Create();
var hashedPw = StringUtils.Sha256String(passwd);
UserDefaultPreferredPermission defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID).ConfigureAwait(false);
Group newGroup = new()
{
GID = gid,
HashedPassword = hashedPw,
InvitesEnabled = true,
OwnerUID = UserUID,
PreferDisableAnimations = defaultPermissions.DisableGroupAnimations,
PreferDisableSounds = defaultPermissions.DisableGroupSounds,
PreferDisableVFX = defaultPermissions.DisableGroupVFX
};
GroupPair initialPair = new()
{
GroupGID = newGroup.GID,
GroupUserUID = UserUID,
IsPinned = true,
};
GroupPairPreferredPermission initialPrefPermissions = new()
{
UserUID = UserUID,
GroupGID = newGroup.GID,
DisableSounds = defaultPermissions.DisableGroupSounds,
DisableAnimations = defaultPermissions.DisableGroupAnimations,
DisableVFX = defaultPermissions.DisableGroupAnimations
};
await DbContext.Groups.AddAsync(newGroup).ConfigureAwait(false);
await DbContext.GroupPairs.AddAsync(initialPair).ConfigureAwait(false);
await DbContext.GroupPairPreferredPermissions.AddAsync(initialPrefPermissions).ConfigureAwait(false);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var self = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
await Clients.User(UserUID).Client_GroupSendFullInfo(new GroupFullInfoDto(newGroup.ToGroupData(), self.ToUserData(),
newGroup.ToEnum(), initialPrefPermissions.ToEnum(), initialPair.ToEnum(), new(StringComparer.Ordinal)))
.ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(gid));
return new GroupJoinDto(newGroup.ToGroupData(), passwd, initialPrefPermissions.ToEnum());
}
[Authorize(Policy = "Identified")]
public async Task<List<string>> GroupCreateTempInvite(GroupDto dto, int amount)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, amount));
List<string> inviteCodes = new();
List<GroupTempInvite> tempInvites = new();
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return new();
var existingInvites = await DbContext.GroupTempInvites.Where(g => g.GroupGID == group.GID).ToListAsync().ConfigureAwait(false);
for (int i = 0; i < amount; i++)
{
bool hasValidInvite = false;
string invite = string.Empty;
string hashedInvite = string.Empty;
while (!hasValidInvite)
{
invite = StringUtils.GenerateRandomString(10, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
hashedInvite = StringUtils.Sha256String(invite);
if (existingInvites.Any(i => string.Equals(i.Invite, hashedInvite, StringComparison.Ordinal))) continue;
hasValidInvite = true;
inviteCodes.Add(invite);
}
tempInvites.Add(new GroupTempInvite()
{
ExpirationDate = DateTime.UtcNow.AddDays(1),
GroupGID = group.GID,
Invite = hashedInvite,
});
}
DbContext.GroupTempInvites.AddRange(tempInvites);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return inviteCodes;
}
[Authorize(Policy = "Identified")]
public async Task GroupDelete(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
var groupPairs = await DbContext.GroupPairs.Where(p => p.GroupGID == dto.Group.GID).ToListAsync().ConfigureAwait(false);
DbContext.RemoveRange(groupPairs);
DbContext.Remove(group);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await Clients.Users(groupPairs.Select(g => g.GroupUserUID)).Client_GroupDelete(new GroupDto(group.ToGroupData())).ConfigureAwait(false);
await SendGroupDeletedToAll(groupPairs).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<List<BannedGroupUserDto>> GroupGetBannedUsers(GroupDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (userHasRights, group) = await TryValidateGroupModeratorOrOwner(dto.GID).ConfigureAwait(false);
if (!userHasRights) return new List<BannedGroupUserDto>();
var banEntries = await DbContext.GroupBans.Include(b => b.BannedUser).Where(g => g.GroupGID == dto.Group.GID).AsNoTracking().ToListAsync().ConfigureAwait(false);
List<BannedGroupUserDto> bannedGroupUsers = banEntries.Select(b =>
new BannedGroupUserDto(group.ToGroupData(), b.BannedUser.ToUserData(), b.BannedReason, b.BannedOn,
b.BannedByUID)).ToList();
_logger.LogCallInfo(LightlessHubLogger.Args(dto, bannedGroupUsers.Count));
return bannedGroupUsers;
}
[Authorize(Policy = "Identified")]
public async Task<GroupJoinInfoDto> GroupJoin(GroupPasswordDto dto)
{
var aliasOrGid = dto.Group.GID.Trim();
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid).ConfigureAwait(false);
var groupGid = group?.GID ?? string.Empty;
var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID).ConfigureAwait(false);
var hashedPw = StringUtils.Sha256String(dto.Password);
var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid).ConfigureAwait(false);
var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID).ConfigureAwait(false);
var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID).ConfigureAwait(false);
var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw).ConfigureAwait(false);
if (group == null
|| (!string.Equals(group.HashedPassword, hashedPw, StringComparison.Ordinal) && oneTimeInvite == null)
|| existingPair != null
|| existingUserCount >= _maxGroupUserCount
|| !group.InvitesEnabled
|| joinedGroups >= _maxJoinedGroupsByUser
|| isBanned)
return new GroupJoinInfoDto(null, null, GroupPermissions.NoneSet, false);
return new GroupJoinInfoDto(group.ToGroupData(), group.Owner.ToUserData(), group.ToEnum(), true);
}
[Authorize(Policy = "Identified")]
public async Task<bool> GroupJoinFinalize(GroupJoinDto dto)
{
var aliasOrGid = dto.Group.GID.Trim();
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var group = await DbContext.Groups.Include(g => g.Owner).AsNoTracking().SingleOrDefaultAsync(g => g.GID == aliasOrGid || g.Alias == aliasOrGid).ConfigureAwait(false);
var groupGid = group?.GID ?? string.Empty;
var existingPair = await DbContext.GroupPairs.AsNoTracking().SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.GroupUserUID == UserUID).ConfigureAwait(false);
var hashedPw = StringUtils.Sha256String(dto.Password);
var existingUserCount = await DbContext.GroupPairs.AsNoTracking().CountAsync(g => g.GroupGID == groupGid).ConfigureAwait(false);
var joinedGroups = await DbContext.GroupPairs.CountAsync(g => g.GroupUserUID == UserUID).ConfigureAwait(false);
var isBanned = await DbContext.GroupBans.AnyAsync(g => g.GroupGID == groupGid && g.BannedUserUID == UserUID).ConfigureAwait(false);
var oneTimeInvite = await DbContext.GroupTempInvites.SingleOrDefaultAsync(g => g.GroupGID == groupGid && g.Invite == hashedPw).ConfigureAwait(false);
if (group == null
|| (!string.Equals(group.HashedPassword, hashedPw, StringComparison.Ordinal) && oneTimeInvite == null)
|| existingPair != null
|| existingUserCount >= _maxGroupUserCount
|| !group.InvitesEnabled
|| joinedGroups >= _maxJoinedGroupsByUser
|| isBanned)
return false;
// get all pairs before we join
var allUserPairs = (await GetAllPairInfo(UserUID).ConfigureAwait(false));
if (oneTimeInvite != null)
{
_logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "TempInvite", oneTimeInvite.Invite));
DbContext.Remove(oneTimeInvite);
}
GroupPair newPair = new()
{
GroupGID = group.GID,
GroupUserUID = UserUID,
};
var preferredPermissions = await DbContext.GroupPairPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.GroupGID == group.GID).ConfigureAwait(false);
if (preferredPermissions == null)
{
GroupPairPreferredPermission newPerms = new()
{
GroupGID = group.GID,
UserUID = UserUID,
DisableSounds = dto.GroupUserPreferredPermissions.IsDisableSounds(),
DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX(),
DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations(),
IsPaused = false
};
DbContext.Add(newPerms);
preferredPermissions = newPerms;
}
else
{
preferredPermissions.DisableSounds = dto.GroupUserPreferredPermissions.IsDisableSounds();
preferredPermissions.DisableVFX = dto.GroupUserPreferredPermissions.IsDisableVFX();
preferredPermissions.DisableAnimations = dto.GroupUserPreferredPermissions.IsDisableAnimations();
preferredPermissions.IsPaused = false;
DbContext.Update(preferredPermissions);
}
await DbContext.GroupPairs.AddAsync(newPair).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(aliasOrGid, "Success"));
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var groupInfos = await DbContext.GroupPairs.Where(u => u.GroupGID == group.GID && (u.IsPinned || u.IsModerator)).ToListAsync().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);
var self = DbContext.Users.Single(u => u.UID == UserUID);
var groupPairs = await DbContext.GroupPairs.Include(p => p.GroupUser)
.Where(p => p.GroupGID == group.GID && p.GroupUserUID != UserUID).ToListAsync().ConfigureAwait(false);
var userPairsAfterJoin = await GetAllPairInfo(UserUID).ConfigureAwait(false);
foreach (var pair in groupPairs)
{
var perms = userPairsAfterJoin.TryGetValue(pair.GroupUserUID, out var userinfo);
// check if we have had prior permissions to that pair, if not add them
var ownPermissionsToOther = userinfo?.OwnPermissions ?? null;
if (ownPermissionsToOther == null)
{
var existingPermissionsOnDb = await DbContext.Permissions.SingleOrDefaultAsync(p => p.UserUID == UserUID && p.OtherUserUID == pair.GroupUserUID).ConfigureAwait(false);
if (existingPermissionsOnDb == null)
{
ownPermissionsToOther = new()
{
UserUID = UserUID,
OtherUserUID = pair.GroupUserUID,
DisableAnimations = preferredPermissions.DisableAnimations,
DisableSounds = preferredPermissions.DisableSounds,
DisableVFX = preferredPermissions.DisableVFX,
IsPaused = preferredPermissions.IsPaused,
Sticky = false
};
await DbContext.Permissions.AddAsync(ownPermissionsToOther).ConfigureAwait(false);
}
else
{
existingPermissionsOnDb.DisableAnimations = preferredPermissions.DisableAnimations;
existingPermissionsOnDb.DisableSounds = preferredPermissions.DisableSounds;
existingPermissionsOnDb.DisableVFX = preferredPermissions.DisableVFX;
existingPermissionsOnDb.IsPaused = false;
existingPermissionsOnDb.Sticky = false;
DbContext.Update(existingPermissionsOnDb);
ownPermissionsToOther = existingPermissionsOnDb;
}
}
else if (!ownPermissionsToOther.Sticky)
{
ownPermissionsToOther = await DbContext.Permissions.SingleAsync(u => u.UserUID == UserUID && u.OtherUserUID == pair.GroupUserUID).ConfigureAwait(false);
// update the existing permission only if it was not set to sticky
ownPermissionsToOther.DisableAnimations = preferredPermissions.DisableAnimations;
ownPermissionsToOther.DisableVFX = preferredPermissions.DisableVFX;
ownPermissionsToOther.DisableSounds = preferredPermissions.DisableSounds;
ownPermissionsToOther.IsPaused = false;
DbContext.Update(ownPermissionsToOther);
}
// get others permissionset to self and eventually update it
var otherPermissionToSelf = userinfo?.OtherPermissions ?? null;
if (otherPermissionToSelf == null)
{
var otherExistingPermsOnDb = await DbContext.Permissions.SingleOrDefaultAsync(p => p.UserUID == pair.GroupUserUID && p.OtherUserUID == UserUID).ConfigureAwait(false);
if (otherExistingPermsOnDb == null)
{
var otherPreferred = await DbContext.GroupPairPreferredPermissions.SingleAsync(u => u.GroupGID == group.GID && u.UserUID == pair.GroupUserUID).ConfigureAwait(false);
otherExistingPermsOnDb = new()
{
UserUID = pair.GroupUserUID,
OtherUserUID = UserUID,
DisableAnimations = otherPreferred.DisableAnimations,
DisableSounds = otherPreferred.DisableSounds,
DisableVFX = otherPreferred.DisableVFX,
IsPaused = otherPreferred.IsPaused,
Sticky = false
};
await DbContext.AddAsync(otherExistingPermsOnDb).ConfigureAwait(false);
}
else if (!otherExistingPermsOnDb.Sticky)
{
var otherPreferred = await DbContext.GroupPairPreferredPermissions.SingleAsync(u => u.GroupGID == group.GID && u.UserUID == pair.GroupUserUID).ConfigureAwait(false);
otherExistingPermsOnDb.DisableAnimations = otherPreferred.DisableAnimations;
otherExistingPermsOnDb.DisableSounds = otherPreferred.DisableSounds;
otherExistingPermsOnDb.DisableVFX = otherPreferred.DisableVFX;
otherExistingPermsOnDb.IsPaused = otherPreferred.IsPaused;
DbContext.Update(otherExistingPermsOnDb);
}
otherPermissionToSelf = otherExistingPermsOnDb;
}
else if (!otherPermissionToSelf.Sticky)
{
var otherPreferred = await DbContext.GroupPairPreferredPermissions.SingleAsync(u => u.GroupGID == group.GID && u.UserUID == pair.GroupUserUID).ConfigureAwait(false);
otherPermissionToSelf.DisableAnimations = otherPreferred.DisableAnimations;
otherPermissionToSelf.DisableSounds = otherPreferred.DisableSounds;
otherPermissionToSelf.DisableVFX = otherPreferred.DisableVFX;
otherPermissionToSelf.IsPaused = otherPreferred.IsPaused;
DbContext.Update(otherPermissionToSelf);
}
await Clients.User(UserUID).Client_GroupPairJoined(new GroupPairFullInfoDto(group.ToGroupData(),
pair.ToUserData(), ownPermissionsToOther.ToUserPermissions(setSticky: ownPermissionsToOther.Sticky),
otherPermissionToSelf.ToUserPermissions(setSticky: false))).ConfigureAwait(false);
await Clients.User(pair.GroupUserUID).Client_GroupPairJoined(new GroupPairFullInfoDto(group.ToGroupData(),
self.ToUserData(), otherPermissionToSelf.ToUserPermissions(setSticky: otherPermissionToSelf.Sticky),
ownPermissionsToOther.ToUserPermissions(setSticky: false))).ConfigureAwait(false);
// if not paired prior and neither has the permissions set to paused, send online
if ((!allUserPairs.ContainsKey(pair.GroupUserUID) || (allUserPairs.TryGetValue(pair.GroupUserUID, out var info) && !info.IsSynced))
&& !otherPermissionToSelf.IsPaused && !ownPermissionsToOther.IsPaused)
{
var groupUserIdent = await GetUserIdent(pair.GroupUserUID).ConfigureAwait(false);
if (!string.IsNullOrEmpty(groupUserIdent))
{
await Clients.User(UserUID).Client_UserSendOnline(new(pair.ToUserData(), groupUserIdent)).ConfigureAwait(false);
await Clients.User(pair.GroupUserUID).Client_UserSendOnline(new(self.ToUserData(), UserCharaIdent)).ConfigureAwait(false);
}
}
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return true;
}
[Authorize(Policy = "Identified")]
public async Task GroupLeave(GroupDto dto)
{
await UserLeaveGroup(dto, UserUID).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<int> GroupPrune(GroupDto dto, int days, bool execute)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto, days, execute));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return -1;
var allGroupUsers = await DbContext.GroupPairs.Include(p => p.GroupUser).Include(p => p.Group)
.Where(g => g.GroupGID == dto.Group.GID)
.ToListAsync().ConfigureAwait(false);
var usersToPrune = allGroupUsers.Where(p => !p.IsPinned && !p.IsModerator
&& p.GroupUserUID != UserUID
&& p.Group.OwnerUID != p.GroupUserUID
&& p.GroupUser.LastLoggedIn.AddDays(days) < DateTime.UtcNow);
if (!execute) return usersToPrune.Count();
DbContext.GroupPairs.RemoveRange(usersToPrune);
foreach (var pair in usersToPrune)
{
await Clients.Users(allGroupUsers.Where(p => !usersToPrune.Contains(p)).Select(g => g.GroupUserUID))
.Client_GroupPairLeft(new GroupPairDto(dto.Group, pair.GroupUser.ToUserData())).ConfigureAwait(false);
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return usersToPrune.Count();
}
[Authorize(Policy = "Identified")]
public async Task GroupRemoveUser(GroupPairDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (hasRights, group) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!hasRights) return;
var (userExists, groupPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false);
if (!userExists) return;
if (groupPair.IsModerator || string.Equals(group.OwnerUID, dto.User.UID, StringComparison.Ordinal)) return;
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
DbContext.GroupPairs.Remove(groupPair);
var groupPairs = DbContext.GroupPairs.Where(p => p.GroupGID == group.GID).AsNoTracking().ToList();
await Clients.Users(groupPairs.Select(p => p.GroupUserUID)).Client_GroupPairLeft(dto).ConfigureAwait(false);
var sharedData = await DbContext.CharaDataAllowances.Where(u => u.AllowedGroup != null && u.AllowedGroupGID == dto.GID && u.ParentUploaderUID == dto.UID).ToListAsync().ConfigureAwait(false);
DbContext.CharaDataAllowances.RemoveRange(sharedData);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var userIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false);
if (userIdent == null)
{
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return;
}
await Clients.User(dto.User.UID).Client_GroupDelete(new GroupDto(dto.Group)).ConfigureAwait(false);
var userPairs = await GetAllPairInfo(dto.User.UID).ConfigureAwait(false);
foreach (var groupUserPair in groupPairs)
{
await UserGroupLeave(groupUserPair, userIdent, userPairs, dto.User.UID).ConfigureAwait(false);
}
}
[Authorize(Policy = "Identified")]
public async Task GroupSetUserInfo(GroupPairUserInfoDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (userExists, userPair) = await TryValidateUserInGroup(dto.Group.GID, dto.User.UID).ConfigureAwait(false);
if (!userExists) return;
var (userIsOwner, _) = await TryValidateOwner(dto.Group.GID).ConfigureAwait(false);
var (userIsModerator, _) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (dto.GroupUserInfo.HasFlag(GroupPairUserInfo.IsPinned) && userIsModerator && !userPair.IsPinned)
{
userPair.IsPinned = true;
}
else if (userIsModerator && userPair.IsPinned)
{
userPair.IsPinned = false;
}
if (dto.GroupUserInfo.HasFlag(GroupPairUserInfo.IsModerator) && userIsOwner && !userPair.IsModerator)
{
userPair.IsModerator = true;
}
else if (userIsOwner && userPair.IsModerator)
{
userPair.IsModerator = false;
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
var groupPairs = await DbContext.GroupPairs.AsNoTracking().Where(p => p.GroupGID == dto.Group.GID).Select(p => p.GroupUserUID).ToListAsync().ConfigureAwait(false);
await Clients.Users(groupPairs).Client_GroupPairChangeUserInfo(new GroupPairUserInfoDto(dto.Group, dto.User, userPair.ToEnum())).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<List<GroupFullInfoDto>> GroupsGetAll()
{
_logger.LogCallInfo();
var groups = await DbContext.GroupPairs.Include(g => g.Group).Include(g => g.Group.Owner).Where(g => g.GroupUserUID == UserUID).AsNoTracking().ToListAsync().ConfigureAwait(false);
var preferredPermissions = (await DbContext.GroupPairPreferredPermissions.Where(u => u.UserUID == UserUID).ToListAsync().ConfigureAwait(false))
.Where(u => groups.Exists(k => string.Equals(k.GroupGID, u.GroupGID, StringComparison.Ordinal)))
.ToDictionary(u => groups.First(f => string.Equals(f.GroupGID, u.GroupGID, StringComparison.Ordinal)), u => u);
var groupInfos = await DbContext.GroupPairs.Where(u => groups.Select(g => g.GroupGID).Contains(u.GroupGID) && (u.IsPinned || u.IsModerator))
.ToListAsync().ConfigureAwait(false);
return preferredPermissions.Select(g => new GroupFullInfoDto(g.Key.Group.ToGroupData(), g.Key.Group.Owner.ToUserData(),
g.Key.Group.ToEnum(), g.Value.ToEnum(), g.Key.ToEnum(),
groupInfos.Where(i => string.Equals(i.GroupGID, g.Key.GroupGID, StringComparison.Ordinal))
.ToDictionary(i => i.GroupUserUID, i => i.ToEnum(), StringComparer.Ordinal))).ToList();
}
[Authorize(Policy = "Identified")]
public async Task GroupUnbanUser(GroupPairDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
var (userHasRights, _) = await TryValidateGroupModeratorOrOwner(dto.Group.GID).ConfigureAwait(false);
if (!userHasRights) return;
var banEntry = await DbContext.GroupBans.SingleOrDefaultAsync(g => g.GroupGID == dto.Group.GID && g.BannedUserUID == dto.User.UID).ConfigureAwait(false);
if (banEntry == null) return;
DbContext.Remove(banEntry);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
}
}

View File

@@ -0,0 +1,172 @@
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.API.Data.Extensions;
using LightlessSyncServer.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
[Authorize(Policy = "Authenticated")]
public async Task UserUpdateDefaultPermissions(DefaultPermissionsDto defaultPermissions)
{
_logger.LogCallInfo(LightlessHubLogger.Args(defaultPermissions));
var permissions = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID).ConfigureAwait(false);
permissions.DisableGroupAnimations = defaultPermissions.DisableGroupAnimations;
permissions.DisableGroupSounds = defaultPermissions.DisableGroupSounds;
permissions.DisableGroupVFX = defaultPermissions.DisableGroupVFX;
permissions.DisableIndividualAnimations = defaultPermissions.DisableIndividualAnimations;
permissions.DisableIndividualSounds = defaultPermissions.DisableIndividualSounds;
permissions.DisableIndividualVFX = defaultPermissions.DisableIndividualVFX;
permissions.IndividualIsSticky = defaultPermissions.IndividualIsSticky;
DbContext.Update(permissions);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
await Clients.Caller.Client_UserUpdateDefaultPermissions(defaultPermissions).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task SetBulkPermissions(BulkPermissionsDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(
"Individual", string.Join(';', dto.AffectedUsers.Select(g => g.Key + ":" + g.Value)),
"Group", string.Join(';', dto.AffectedGroups.Select(g => g.Key + ":" + g.Value))));
// remove self
dto.AffectedUsers.Remove(UserUID, out _);
if (!dto.AffectedUsers.Any() && !dto.AffectedGroups.Any()) return;
// get all current pairs in any form
var allUsers = await GetAllPairInfo(UserUID).ConfigureAwait(false);
var ownDefaultPerms = await DbContext.UserDefaultPreferredPermissions.SingleAsync(u => u.UserUID == UserUID).ConfigureAwait(false);
foreach (var user in dto.AffectedUsers)
{
bool setSticky = false;
var newPerm = user.Value;
if (!allUsers.TryGetValue(user.Key, out var pairData)) continue;
if (!pairData.OwnPermissions.Sticky && !newPerm.IsSticky())
{
setSticky = ownDefaultPerms.IndividualIsSticky;
}
var pauseChange = pairData.OwnPermissions.IsPaused != newPerm.IsPaused();
var prevPermissions = await DbContext.Permissions.SingleAsync(u => u.UserUID == UserUID && u.OtherUserUID == user.Key).ConfigureAwait(false);
prevPermissions.IsPaused = newPerm.IsPaused();
prevPermissions.DisableAnimations = newPerm.IsDisableAnimations();
prevPermissions.DisableSounds = newPerm.IsDisableSounds();
prevPermissions.DisableVFX = newPerm.IsDisableVFX();
prevPermissions.Sticky = newPerm.IsSticky() || setSticky;
DbContext.Update(prevPermissions);
// send updated data to pair
var permCopy = newPerm;
permCopy.SetSticky(newPerm.IsSticky() || setSticky);
var permToOther = permCopy;
permToOther.SetSticky(false);
await Clients.User(UserUID).Client_UserUpdateSelfPairPermissions(new(new(user.Key), permCopy)).ConfigureAwait(false);
if (pairData.OtherPermissions == null) continue;
await Clients.User(user.Key).Client_UserUpdateOtherPairPermissions(new(new(UserUID), permToOther)).ConfigureAwait(false);
// check if pause change and send online or offline respectively
if (pauseChange && !pairData.OtherPermissions.IsPaused)
{
var otherCharaIdent = await GetUserIdent(user.Key).ConfigureAwait(false);
if (UserCharaIdent == null || otherCharaIdent == null) continue;
if (newPerm.IsPaused())
{
await Clients.User(UserUID).Client_UserSendOffline(new(new(user.Key))).ConfigureAwait(false);
await Clients.User(user.Key).Client_UserSendOffline(new(new(UserUID))).ConfigureAwait(false);
}
else
{
await Clients.User(UserUID).Client_UserSendOnline(new(new(user.Key), otherCharaIdent)).ConfigureAwait(false);
await Clients.User(user.Key).Client_UserSendOnline(new(new(UserUID), UserCharaIdent)).ConfigureAwait(false);
}
}
}
foreach (var group in dto.AffectedGroups)
{
var (inGroup, groupPair) = await TryValidateUserInGroup(group.Key).ConfigureAwait(false);
if (!inGroup) continue;
var groupPreferredPermissions = await DbContext.GroupPairPreferredPermissions
.SingleAsync(u => u.UserUID == UserUID && u.GroupGID == group.Key).ConfigureAwait(false);
var wasPaused = groupPreferredPermissions.IsPaused;
groupPreferredPermissions.DisableSounds = group.Value.IsDisableSounds();
groupPreferredPermissions.DisableAnimations = group.Value.IsDisableAnimations();
groupPreferredPermissions.IsPaused = group.Value.IsPaused();
groupPreferredPermissions.DisableVFX = group.Value.IsDisableVFX();
var nonStickyPairs = allUsers.Where(u => !u.Value.OwnPermissions.Sticky).ToList();
var affectedGroupPairs = nonStickyPairs.Where(u => u.Value.GIDs.Contains(group.Key, StringComparer.Ordinal)).ToList();
var groupUserUids = affectedGroupPairs.Select(g => g.Key).ToList();
var affectedPerms = await DbContext.Permissions.Where(u => u.UserUID == UserUID
&& groupUserUids.Any(c => c == u.OtherUserUID))
.ToListAsync().ConfigureAwait(false);
foreach (var perm in affectedPerms)
{
perm.DisableSounds = groupPreferredPermissions.DisableSounds;
perm.DisableAnimations = groupPreferredPermissions.DisableAnimations;
perm.IsPaused = groupPreferredPermissions.IsPaused;
perm.DisableVFX = groupPreferredPermissions.DisableVFX;
}
UserPermissions permissions = UserPermissions.NoneSet;
permissions.SetPaused(groupPreferredPermissions.IsPaused);
permissions.SetDisableAnimations(groupPreferredPermissions.DisableAnimations);
permissions.SetDisableSounds(groupPreferredPermissions.DisableSounds);
permissions.SetDisableVFX(groupPreferredPermissions.DisableVFX);
await Clients.Users(affectedGroupPairs
.Select(k => k.Key))
.Client_UserUpdateOtherPairPermissions(new(new(UserUID), permissions)).ConfigureAwait(false);
await Clients.User(UserUID).Client_GroupChangeUserPairPermissions(new GroupPairUserPermissionDto(new(group.Key), new(UserUID), group.Value)).ConfigureAwait(false);
foreach (var item in affectedGroupPairs.Select(k => k.Key))
{
await Clients.User(UserUID).Client_UserUpdateSelfPairPermissions(new(new(item), permissions)).ConfigureAwait(false);
}
if (wasPaused == groupPreferredPermissions.IsPaused) continue;
foreach (var groupUserPair in affectedGroupPairs)
{
var groupUserIdent = await GetUserIdent(groupUserPair.Key).ConfigureAwait(false);
if (!string.IsNullOrEmpty(groupUserIdent) && !groupUserPair.Value.OtherPermissions.IsPaused)
{
// if we changed to paused and other was not paused before, we send offline
if (groupPreferredPermissions.IsPaused)
{
await Clients.User(UserUID).Client_UserSendOffline(new(new(groupUserPair.Key, groupUserPair.Value.Alias))).ConfigureAwait(false);
await Clients.User(groupUserPair.Key).Client_UserSendOffline(new(new(UserUID))).ConfigureAwait(false);
}
// if we changed to unpaused and other was not paused either we send online
else
{
await Clients.User(UserUID).Client_UserSendOnline(new(new(groupUserPair.Key, groupUserPair.Value.Alias), groupUserIdent)).ConfigureAwait(false);
await Clients.User(groupUserPair.Key).Client_UserSendOnline(new(new(UserUID), UserCharaIdent)).ConfigureAwait(false);
}
}
}
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,437 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.User;
using LightlessSyncServer.Utils;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
private static readonly string[] AllowedExtensionsForGamePaths = { ".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk" };
[Authorize(Policy = "Identified")]
public async Task UserAddPair(UserDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
// don't allow adding nothing
var uid = dto.User.UID.Trim();
if (string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(dto.User.UID)) return;
// grab other user, check if it exists and if a pair already exists
var otherUser = await DbContext.Users.SingleOrDefaultAsync(u => u.UID == uid || u.Alias == uid).ConfigureAwait(false);
if (otherUser == null)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"Cannot pair with {dto.User.UID}, UID does not exist").ConfigureAwait(false);
return;
}
if (string.Equals(otherUser.UID, UserUID, StringComparison.Ordinal))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"My god you can't pair with yourself why would you do that please stop").ConfigureAwait(false);
return;
}
var existingEntry =
await DbContext.ClientPairs.AsNoTracking()
.FirstOrDefaultAsync(p =>
p.User.UID == UserUID && p.OtherUserUID == otherUser.UID).ConfigureAwait(false);
if (existingEntry != null)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Warning, $"Cannot pair with {dto.User.UID}, already paired").ConfigureAwait(false);
return;
}
// grab self create new client pair and save
var user = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
ClientPair wl = new ClientPair()
{
OtherUser = otherUser,
User = user,
};
await DbContext.ClientPairs.AddAsync(wl).ConfigureAwait(false);
var existingData = await GetPairInfo(UserUID, otherUser.UID).ConfigureAwait(false);
var permissions = existingData?.OwnPermissions;
if (permissions == null || !permissions.Sticky)
{
var ownDefaultPermissions = await DbContext.UserDefaultPreferredPermissions.AsNoTracking().SingleOrDefaultAsync(f => f.UserUID == UserUID).ConfigureAwait(false);
permissions = new UserPermissionSet()
{
User = user,
OtherUser = otherUser,
DisableAnimations = ownDefaultPermissions.DisableIndividualAnimations,
DisableSounds = ownDefaultPermissions.DisableIndividualSounds,
DisableVFX = ownDefaultPermissions.DisableIndividualVFX,
IsPaused = false,
Sticky = true
};
var existingDbPerms = await DbContext.Permissions.SingleOrDefaultAsync(u => u.UserUID == UserUID && u.OtherUserUID == otherUser.UID).ConfigureAwait(false);
if (existingDbPerms == null)
{
await DbContext.Permissions.AddAsync(permissions).ConfigureAwait(false);
}
else
{
existingDbPerms.DisableAnimations = permissions.DisableAnimations;
existingDbPerms.DisableSounds = permissions.DisableSounds;
existingDbPerms.DisableVFX = permissions.DisableVFX;
existingDbPerms.IsPaused = false;
existingDbPerms.Sticky = true;
DbContext.Permissions.Update(existingDbPerms);
}
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
// get the opposite entry of the client pair
var otherEntry = OppositeEntry(otherUser.UID);
var otherIdent = await GetUserIdent(otherUser.UID).ConfigureAwait(false);
var otherPermissions = existingData?.OtherPermissions ?? null;
var ownPerm = permissions.ToUserPermissions(setSticky: true);
var otherPerm = otherPermissions.ToUserPermissions();
var userPairResponse = new UserPairDto(otherUser.ToUserData(),
otherEntry == null ? IndividualPairStatus.OneSided : IndividualPairStatus.Bidirectional,
ownPerm, otherPerm);
await Clients.User(user.UID).Client_UserAddClientPair(userPairResponse).ConfigureAwait(false);
// check if other user is online
if (otherIdent == null || otherEntry == null) return;
// send push with update to other user if other user is online
await Clients.User(otherUser.UID)
.Client_UserUpdateOtherPairPermissions(new UserPermissionsDto(user.ToUserData(),
permissions.ToUserPermissions())).ConfigureAwait(false);
await Clients.User(otherUser.UID)
.Client_UpdateUserIndividualPairStatusDto(new(user.ToUserData(), IndividualPairStatus.Bidirectional))
.ConfigureAwait(false);
if (!ownPerm.IsPaused() && !otherPerm.IsPaused())
{
await Clients.User(UserUID).Client_UserSendOnline(new(otherUser.ToUserData(), otherIdent)).ConfigureAwait(false);
await Clients.User(otherUser.UID).Client_UserSendOnline(new(user.ToUserData(), UserCharaIdent)).ConfigureAwait(false);
}
}
[Authorize(Policy = "Identified")]
public async Task UserDelete()
{
_logger.LogCallInfo();
var userEntry = await DbContext.Users.SingleAsync(u => u.UID == UserUID).ConfigureAwait(false);
var secondaryUsers = await DbContext.Auth.Include(u => u.User).Where(u => u.PrimaryUserUID == UserUID).Select(c => c.User).ToListAsync().ConfigureAwait(false);
foreach (var user in secondaryUsers)
{
await DeleteUser(user).ConfigureAwait(false);
}
await DeleteUser(userEntry).ConfigureAwait(false);
}
[Authorize(Policy = "Identified")]
public async Task<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.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).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).ConfigureAwait(false);
if (callerPair == null) return;
var pairData = await GetPairInfo(UserUID, dto.User.UID).ConfigureAwait(false);
// delete from database, send update info to users pair list
DbContext.ClientPairs.Remove(callerPair);
await DbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(dto, "Success"));
await Clients.User(UserUID).Client_UserRemoveClientPair(dto).ConfigureAwait(false);
// check if opposite entry exists
if (!pairData.IndividuallyPaired) return;
// check if other user is online, if no then there is no need to do anything further
var otherIdent = await GetUserIdent(dto.User.UID).ConfigureAwait(false);
if (otherIdent == null) return;
// if the other user had paused the user the state will be offline for either, do nothing
bool callerHadPaused = pairData.OwnPermissions?.IsPaused ?? false;
// send updated individual pair status
await Clients.User(dto.User.UID)
.Client_UpdateUserIndividualPairStatusDto(new(new(UserUID), IndividualPairStatus.OneSided))
.ConfigureAwait(false);
UserPermissionSet? otherPermissions = pairData.OtherPermissions;
bool otherHadPaused = otherPermissions?.IsPaused ?? true;
// if the either had paused, do nothing
if (callerHadPaused && otherHadPaused) return;
var currentPairData = await GetPairInfo(UserUID, dto.User.UID).ConfigureAwait(false);
// if neither user had paused each other and either is not in an unpaused group with each other, change state to offline
if (!currentPairData?.IsSynced ?? true)
{
await Clients.User(UserUID).Client_UserSendOffline(dto).ConfigureAwait(false);
await Clients.User(dto.User.UID).Client_UserSendOffline(new(new(UserUID))).ConfigureAwait(false);
}
}
[Authorize(Policy = "Identified")]
public async Task UserSetProfile(UserProfileDto dto)
{
_logger.LogCallInfo(LightlessHubLogger.Args(dto));
if (!string.Equals(dto.User.UID, UserUID, StringComparison.Ordinal)) throw new HubException("Cannot modify profile data for anyone but yourself");
var existingData = await DbContext.UserProfileData.SingleOrDefaultAsync(u => u.UserUID == dto.User.UID).ConfigureAwait(false);
if (existingData?.FlaggedForReport ?? false)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile is currently flagged for report and cannot be edited").ConfigureAwait(false);
return;
}
if (existingData?.ProfileDisabled ?? false)
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your profile was permanently disabled and cannot be edited").ConfigureAwait(false);
return;
}
if (!string.IsNullOrEmpty(dto.ProfilePictureBase64))
{
byte[] imageData = Convert.FromBase64String(dto.ProfilePictureBase64);
using MemoryStream ms = new(imageData);
var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false);
if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase))
{
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Error, "Your provided image file is not in PNG format").ConfigureAwait(false);
return;
}
using var image = Image.Load<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);
}

View File

@@ -0,0 +1,212 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto;
using LightlessSync.API.SignalR;
using LightlessSyncServer.Services;
using LightlessSyncServer.Utils;
using LightlessSyncShared;
using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis.Extensions.Core.Abstractions;
using System.Collections.Concurrent;
namespace LightlessSyncServer.Hubs;
[Authorize(Policy = "Authenticated")]
public partial class LightlessHub : Hub<ILightlessHub>, ILightlessHub
{
private static readonly ConcurrentDictionary<string, string> _userConnections = new(StringComparer.Ordinal);
private readonly LightlessMetrics _lightlessMetrics;
private readonly SystemInfoService _systemInfoService;
private readonly IHttpContextAccessor _contextAccessor;
private readonly LightlessHubLogger _logger;
private readonly string _shardName;
private readonly int _maxExistingGroupsByUser;
private readonly int _maxJoinedGroupsByUser;
private readonly int _maxGroupUserCount;
private readonly IRedisDatabase _redis;
private readonly OnlineSyncedPairCacheService _onlineSyncedPairCacheService;
private readonly LightlessCensus _lightlessCensus;
private readonly GPoseLobbyDistributionService _gPoseLobbyDistributionService;
private readonly Uri _fileServerAddress;
private readonly Version _expectedClientVersion;
private readonly Lazy<LightlessDbContext> _dbContextLazy;
private LightlessDbContext DbContext => _dbContextLazy.Value;
private readonly int _maxCharaDataByUser;
private readonly int _maxCharaDataByUserVanity;
public LightlessHub(LightlessMetrics lightlessMetrics,
IDbContextFactory<LightlessDbContext> lightlessDbContextFactory, ILogger<LightlessHub> logger, SystemInfoService systemInfoService,
IConfigurationService<ServerConfiguration> configuration, IHttpContextAccessor contextAccessor,
IRedisDatabase redisDb, OnlineSyncedPairCacheService onlineSyncedPairCacheService, LightlessCensus lightlessCensus,
GPoseLobbyDistributionService gPoseLobbyDistributionService)
{
_lightlessMetrics = lightlessMetrics;
_systemInfoService = systemInfoService;
_shardName = configuration.GetValue<string>(nameof(ServerConfiguration.ShardName));
_maxExistingGroupsByUser = configuration.GetValueOrDefault(nameof(ServerConfiguration.MaxExistingGroupsByUser), 3);
_maxJoinedGroupsByUser = configuration.GetValueOrDefault(nameof(ServerConfiguration.MaxJoinedGroupsByUser), 6);
_maxGroupUserCount = configuration.GetValueOrDefault(nameof(ServerConfiguration.MaxGroupUserCount), 100);
_fileServerAddress = configuration.GetValue<Uri>(nameof(ServerConfiguration.CdnFullUrl));
_expectedClientVersion = configuration.GetValueOrDefault(nameof(ServerConfiguration.ExpectedClientVersion), new Version(0, 0, 0));
_maxCharaDataByUser = configuration.GetValueOrDefault(nameof(ServerConfiguration.MaxCharaDataByUser), 10);
_maxCharaDataByUserVanity = configuration.GetValueOrDefault(nameof(ServerConfiguration.MaxCharaDataByUserVanity), 50);
_contextAccessor = contextAccessor;
_redis = redisDb;
_onlineSyncedPairCacheService = onlineSyncedPairCacheService;
_lightlessCensus = lightlessCensus;
_gPoseLobbyDistributionService = gPoseLobbyDistributionService;
_logger = new LightlessHubLogger(this, logger);
_dbContextLazy = new Lazy<LightlessDbContext>(() => lightlessDbContextFactory.CreateDbContext());
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
if (_dbContextLazy.IsValueCreated) DbContext.Dispose();
}
base.Dispose(disposing);
}
[Authorize(Policy = "Identified")]
public async Task<ConnectionDto> GetConnectionDto()
{
_logger.LogCallInfo();
_lightlessMetrics.IncCounter(MetricsAPI.CounterInitializedConnections);
await Clients.Caller.Client_UpdateSystemInfo(_systemInfoService.SystemInfoDto).ConfigureAwait(false);
var dbUser = await DbContext.Users.SingleAsync(f => f.UID == UserUID).ConfigureAwait(false);
dbUser.LastLoggedIn = DateTime.UtcNow;
await Clients.Caller.Client_ReceiveServerMessage(MessageSeverity.Information, "Welcome to Lightless Sync \"" + _shardName + "\", Current Online Users: " + _systemInfoService.SystemInfoDto.OnlineUsers).ConfigureAwait(false);
var defaultPermissions = await DbContext.UserDefaultPreferredPermissions.SingleOrDefaultAsync(u => u.UserUID == UserUID).ConfigureAwait(false);
if (defaultPermissions == null)
{
defaultPermissions = new UserDefaultPreferredPermission()
{
UserUID = UserUID,
};
DbContext.UserDefaultPreferredPermissions.Add(defaultPermissions);
}
await DbContext.SaveChangesAsync().ConfigureAwait(false);
return new ConnectionDto(new UserData(dbUser.UID, string.IsNullOrWhiteSpace(dbUser.Alias) ? null : dbUser.Alias))
{
CurrentClientVersion = _expectedClientVersion,
ServerVersion = ILightlessHub.ApiVersion,
IsAdmin = dbUser.IsAdmin,
IsModerator = dbUser.IsModerator,
ServerInfo = new ServerInfo()
{
MaxGroupsCreatedByUser = _maxExistingGroupsByUser,
ShardName = _shardName,
MaxGroupsJoinedByUser = _maxJoinedGroupsByUser,
MaxGroupUserCount = _maxGroupUserCount,
FileServerAddress = _fileServerAddress,
MaxCharaData = _maxCharaDataByUser,
MaxCharaDataVanity = _maxCharaDataByUserVanity,
},
DefaultPreferredPermissions = new DefaultPermissionsDto()
{
DisableGroupAnimations = defaultPermissions.DisableGroupAnimations,
DisableGroupSounds = defaultPermissions.DisableGroupSounds,
DisableGroupVFX = defaultPermissions.DisableGroupVFX,
DisableIndividualAnimations = defaultPermissions.DisableIndividualAnimations,
DisableIndividualSounds = defaultPermissions.DisableIndividualSounds,
DisableIndividualVFX = defaultPermissions.DisableIndividualVFX,
IndividualIsSticky = defaultPermissions.IndividualIsSticky,
},
};
}
[Authorize(Policy = "Authenticated")]
public async Task<bool> CheckClientHealth()
{
await UpdateUserOnRedis().ConfigureAwait(false);
return false;
}
[Authorize(Policy = "Authenticated")]
public override async Task OnConnectedAsync()
{
if (_userConnections.TryGetValue(UserUID, out var oldId))
{
_logger.LogCallWarning(LightlessHubLogger.Args(_contextAccessor.GetIpAddress(), "UpdatingId", oldId, Context.ConnectionId));
_userConnections[UserUID] = Context.ConnectionId;
}
else
{
_lightlessMetrics.IncGaugeWithLabels(MetricsAPI.GaugeConnections, labels: Continent);
try
{
_logger.LogCallInfo(LightlessHubLogger.Args(_contextAccessor.GetIpAddress(), Context.ConnectionId, UserCharaIdent));
await _onlineSyncedPairCacheService.InitPlayer(UserUID).ConfigureAwait(false);
await UpdateUserOnRedis().ConfigureAwait(false);
_userConnections[UserUID] = Context.ConnectionId;
}
catch
{
_userConnections.Remove(UserUID, out _);
}
}
await base.OnConnectedAsync().ConfigureAwait(false);
}
[Authorize(Policy = "Authenticated")]
public override async Task OnDisconnectedAsync(Exception exception)
{
if (_userConnections.TryGetValue(UserUID, out var connectionId)
&& string.Equals(connectionId, Context.ConnectionId, StringComparison.Ordinal))
{
_lightlessMetrics.DecGaugeWithLabels(MetricsAPI.GaugeConnections, labels: Continent);
try
{
await GposeLobbyLeave().ConfigureAwait(false);
await _onlineSyncedPairCacheService.DisposePlayer(UserUID).ConfigureAwait(false);
_logger.LogCallInfo(LightlessHubLogger.Args(_contextAccessor.GetIpAddress(), Context.ConnectionId, UserCharaIdent));
if (exception != null)
_logger.LogCallWarning(LightlessHubLogger.Args(_contextAccessor.GetIpAddress(), Context.ConnectionId, exception.Message, exception.StackTrace));
await RemoveUserFromRedis().ConfigureAwait(false);
_lightlessCensus.ClearStatistics(UserUID);
await SendOfflineToAllPairedUsers().ConfigureAwait(false);
DbContext.RemoveRange(DbContext.Files.Where(f => !f.Uploaded && f.UploaderUID == UserUID));
await DbContext.SaveChangesAsync().ConfigureAwait(false);
}
catch { }
finally
{
_userConnections.Remove(UserUID, out _);
}
}
else
{
_logger.LogCallWarning(LightlessHubLogger.Args(_contextAccessor.GetIpAddress(), "ObsoleteId", UserUID, Context.ConnectionId));
}
await base.OnDisconnectedAsync(exception).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,112 @@
using AspNetCoreRateLimit;
using LightlessSyncShared;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
namespace LightlessSyncServer.Hubs;
public class SignalRLimitFilter : IHubFilter
{
private readonly IRateLimitProcessor _processor;
private readonly IHttpContextAccessor accessor;
private readonly ILogger<SignalRLimitFilter> logger;
private static readonly SemaphoreSlim ConnectionLimiterSemaphore = new(20, 20);
private static readonly SemaphoreSlim DisconnectLimiterSemaphore = new(20, 20);
public SignalRLimitFilter(
IOptions<IpRateLimitOptions> options, IProcessingStrategy processing, IIpPolicyStore policyStore, IHttpContextAccessor accessor, ILogger<SignalRLimitFilter> logger)
{
_processor = new IpRateLimitProcessor(options?.Value, policyStore, processing);
this.accessor = accessor;
this.logger = logger;
}
public async ValueTask<object> InvokeMethodAsync(
HubInvocationContext invocationContext, Func<HubInvocationContext, ValueTask<object>> next)
{
var ip = accessor.GetIpAddress();
var client = new ClientRequestIdentity
{
ClientIp = ip,
Path = invocationContext.HubMethodName,
HttpVerb = "ws",
ClientId = invocationContext.Context.UserIdentifier,
};
foreach (var rule in await _processor.GetMatchingRulesAsync(client).ConfigureAwait(false))
{
var counter = await _processor.ProcessRequestAsync(client, rule).ConfigureAwait(false);
if (counter.Count > rule.Limit)
{
var authUserId = invocationContext.Context.User.Claims?.SingleOrDefault(c => string.Equals(c.Type, LightlessClaimTypes.Uid, StringComparison.Ordinal))?.Value ?? "Unknown";
var retry = counter.Timestamp.RetryAfterFrom(rule);
logger.LogWarning("Method rate limit triggered from {ip}/{authUserId}: {method}", ip, authUserId, invocationContext.HubMethodName);
throw new HubException($"call limit {retry}");
}
}
return await next(invocationContext).ConfigureAwait(false);
}
// Optional method
/* public async Task OnConnectedAsync(HubLifetimeContext context, Func<HubLifetimeContext, Task> next)
{
await ConnectionLimiterSemaphore.WaitAsync().ConfigureAwait(false);
try
{
var ip = accessor.GetIpAddress();
var client = new ClientRequestIdentity
{
ClientIp = ip,
Path = "Connect",
HttpVerb = "ws",
};
foreach (var rule in await _processor.GetMatchingRulesAsync(client).ConfigureAwait(false))
{
var counter = await _processor.ProcessRequestAsync(client, rule).ConfigureAwait(false);
if (counter.Count > rule.Limit)
{
var retry = counter.Timestamp.RetryAfterFrom(rule);
logger.LogWarning("Connection rate limit triggered from {ip}", ip);
ConnectionLimiterSemaphore.Release();
throw new HubException($"Connection rate limit {retry}");
}
}
await Task.Delay(25).ConfigureAwait(false);
await next(context).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error on OnConnectedAsync");
}
finally
{
ConnectionLimiterSemaphore.Release();
}
}
public async Task OnDisconnectedAsync(
HubLifetimeContext context, Exception exception, Func<HubLifetimeContext, Exception, Task> next)
{
await DisconnectLimiterSemaphore.WaitAsync().ConfigureAwait(false);
if (exception != null)
{
logger.LogWarning(exception, "InitialException on OnDisconnectedAsync");
}
try
{
await next(context, exception).ConfigureAwait(false);
await Task.Delay(25).ConfigureAwait(false);
}
catch (Exception e)
{
logger.LogWarning(e, "ThrownException on OnDisconnectedAsync");
}
finally
{
DisconnectLimiterSemaphore.Release();
}
} */
}

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<UserSecretsId>aspnet-LightlessSyncServer-BA82A12A-0B30-463C-801D-B7E81318CD50</UserSecretsId>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Content Remove="appsettings.Development.json" />
<Content Remove="appsettings.json" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.Development.json" />
<None Include="appsettings.json">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.212">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.8" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" />
<ProjectReference Include="..\LightlessSyncShared\LightlessSyncShared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,96 @@
using Microsoft.EntityFrameworkCore;
using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
namespace LightlessSyncServer;
public class Program
{
public static void Main(string[] args)
{
var hostBuilder = CreateHostBuilder(args);
using var host = hostBuilder.Build();
using (var scope = host.Services.CreateScope())
{
var services = scope.ServiceProvider;
var factory = services.GetRequiredService<IDbContextFactory<LightlessDbContext>>();
using var context = factory.CreateDbContext();
var options = services.GetRequiredService<IConfigurationService<ServerConfiguration>>();
var logger = host.Services.GetRequiredService<ILogger<Program>>();
if (options.IsMain)
{
context.Database.SetCommandTimeout(TimeSpan.FromMinutes(10));
context.Database.Migrate();
context.Database.SetCommandTimeout(TimeSpan.FromSeconds(30));
context.SaveChanges();
// clean up residuals
var looseFiles = context.Files.Where(f => f.Uploaded == false);
var unfinishedRegistrations = context.LodeStoneAuth.Where(c => c.StartedAt != null);
context.RemoveRange(unfinishedRegistrations);
context.RemoveRange(looseFiles);
context.SaveChanges();
logger.LogInformation(options.ToString());
}
var metrics = services.GetRequiredService<LightlessMetrics>();
metrics.SetGaugeTo(MetricsAPI.GaugeUsersRegistered, context.Users.AsNoTracking().Count());
metrics.SetGaugeTo(MetricsAPI.GaugePairs, context.ClientPairs.AsNoTracking().Count());
metrics.SetGaugeTo(MetricsAPI.GaugePairsPaused, context.Permissions.AsNoTracking().Where(p=>p.IsPaused).Count());
}
if (args.Length == 0 || !string.Equals(args[0], "dry", StringComparison.Ordinal))
{
try
{
host.Run();
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
}
public static IHostBuilder CreateHostBuilder(string[] args)
{
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder.ClearProviders();
builder.AddConsole();
});
var logger = loggerFactory.CreateLogger<Startup>();
return Host.CreateDefaultBuilder(args)
.UseSystemd()
.UseConsoleLifetime()
.ConfigureAppConfiguration((ctx, config) =>
{
var appSettingsPath = Environment.GetEnvironmentVariable("APPSETTINGS_PATH");
if (!string.IsNullOrEmpty(appSettingsPath))
{
config.AddJsonFile(appSettingsPath, optional: true, reloadOnChange: true);
}
else
{
config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
}
config.AddEnvironmentVariables();
})
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseContentRoot(AppContext.BaseDirectory);
webBuilder.ConfigureLogging((ctx, builder) =>
{
builder.AddConfiguration(ctx.Configuration.GetSection("Logging"));
builder.AddFile(o => o.RootPath = AppContext.BaseDirectory);
});
webBuilder.UseStartup(ctx => new Startup(ctx.Configuration, logger));
});
}
}

View File

@@ -0,0 +1,14 @@
{
"profiles": {
"LightlessSyncServer": {
"commandName": "Project",
"dotnetRunMessages": "true",
"launchBrowser": false,
//"applicationUrl": "https://localhost:5001;http://localhost:5000;https://192.168.1.124:5001;http://192.168.1.124:5000",
"applicationUrl": "http://localhost:5000;https://localhost:5001;https://darkarchon.internet-box.ch:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,3 @@
{
"dependencies": {}
}

View File

@@ -0,0 +1,3 @@
{
"dependencies": {}
}

View File

@@ -0,0 +1,42 @@
using LightlessSyncShared.Data;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Services;
public class CharaDataCleanupService : BackgroundService
{
private readonly ILogger<CharaDataCleanupService> _logger;
private readonly IDbContextFactory<LightlessDbContext> _dbContextFactory;
public CharaDataCleanupService(ILogger<CharaDataCleanupService> logger, IDbContextFactory<LightlessDbContext> dbContextFactory)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
await base.StartAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Chara Data Cleanup Service started");
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("CharaData Cleanup Service started");
while (!ct.IsCancellationRequested)
{
using (var db = await _dbContextFactory.CreateDbContextAsync(ct).ConfigureAwait(false))
{
var dateTime = DateTime.UtcNow;
var expiredData = await db.CharaData.Where(c => c.ExpiryDate <= DateTime.UtcNow).ToListAsync(cancellationToken: ct).ConfigureAwait(false);
_logger.LogInformation("Removing {count} expired Chara Data entries", expiredData.Count);
db.RemoveRange(expiredData);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
await Task.Delay(TimeSpan.FromHours(12), ct).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,190 @@

using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
using System.Collections.Concurrent;
using System.Data;
using System.Diagnostics;
namespace LightlessSyncServer.Services;
public class ClientPairPermissionsCleanupService(ILogger<ClientPairPermissionsCleanupService> _logger, IDbContextFactory<LightlessDbContext> _dbContextFactory,
IConfigurationService<ServerConfiguration> _configurationService)
: BackgroundService
{
public override async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Client Pair Permissions Cleanup Service started");
await base.StartAsync(cancellationToken).ConfigureAwait(false);
}
private async Task AllUsersPermissionsCleanup(CancellationToken ct)
{
const int MaxParallelism = 8;
const int MaxProcessingPerChunk = 1000000;
long removedEntries = 0;
long priorRemovedEntries = 0;
ConcurrentDictionary<int, List<UserPermissionSet>> toRemovePermsParallel = [];
ConcurrentDictionary<int, bool> completionDebugPrint = [];
int parallelProcessed = 0;
int userNo = 0;
int lastUserNo = 0;
using var db = await _dbContextFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Building All Pairs");
_logger.LogInformation("Collecting Users");
var users = (await db.Users.Select(k => k.UID).AsNoTracking().ToListAsync(ct).ConfigureAwait(false)).Order(StringComparer.Ordinal).ToList();
Stopwatch st = Stopwatch.StartNew();
while (userNo < users.Count)
{
using CancellationTokenSource loopCts = new();
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(loopCts.Token, ct);
try
{
await Parallel.ForAsync(userNo, users.Count, new ParallelOptions()
{
MaxDegreeOfParallelism = MaxParallelism,
CancellationToken = linkedCts.Token
},
async (i, token) =>
{
var userNoInc = Interlocked.Increment(ref userNo);
using var db2 = await _dbContextFactory.CreateDbContextAsync(token).ConfigureAwait(false);
var user = users[i];
var personalPairs = await GetAllPairsForUser(user, db2, ct).ConfigureAwait(false);
toRemovePermsParallel[i] = await UserPermissionCleanup(i, users.Count, user, db2, personalPairs).ConfigureAwait(false);
var processedAdd = Interlocked.Add(ref parallelProcessed, toRemovePermsParallel[i].Count);
var completionPcnt = userNoInc / (double)users.Count;
var completionInt = (int)(completionPcnt * 100);
if (completionInt > 0 && (!completionDebugPrint.TryGetValue(completionInt, out bool posted) || !posted))
{
completionDebugPrint[completionInt] = true;
var elapsed = st.Elapsed;
var estimatedTimeLeft = (elapsed / completionPcnt) - elapsed;
_logger.LogInformation("Progress: {no}/{total} ({pct:P2}), removed so far: {removed}, planned next chunk: {planned}, estimated time left: {time}",
userNoInc, users.Count, completionPcnt, removedEntries, processedAdd, estimatedTimeLeft);
if (userNoInc / (double)users.Count - lastUserNo / (double)users.Count > 0.05)
{
// 5% processed without writing, might as well save at this point
await loopCts.CancelAsync().ConfigureAwait(false);
}
}
if (processedAdd > MaxProcessingPerChunk)
await loopCts.CancelAsync().ConfigureAwait(false);
}).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// expected
}
removedEntries += parallelProcessed;
try
{
parallelProcessed = 0;
_logger.LogInformation("Removing {newDeleted} entities and writing to database", removedEntries - priorRemovedEntries);
db.Permissions.RemoveRange(toRemovePermsParallel.Values.SelectMany(v => v).ToList());
await db.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Removed {newDeleted} entities, settling...", removedEntries - priorRemovedEntries);
priorRemovedEntries = removedEntries;
lastUserNo = userNo;
}
catch (DBConcurrencyException ex)
{
_logger.LogWarning(ex, "Concurrency Exception during User Permissions Cleanup, restarting at {last}", lastUserNo);
userNo = lastUserNo;
removedEntries = priorRemovedEntries;
continue;
}
finally
{
toRemovePermsParallel.Clear();
}
}
st.Stop();
_logger.LogInformation("User Permissions Cleanup Finished, removed {total} stale permissions in {time}", removedEntries, st.Elapsed);
}
private async Task<List<UserPermissionSet>> UserPermissionCleanup(int userNr, int totalUsers, string uid, LightlessDbContext dbContext, List<string> pairs)
{
var perms = dbContext.Permissions.Where(p => p.UserUID == uid && !p.Sticky && !pairs.Contains(p.OtherUserUID));
var permsToRemoveCount = await perms.CountAsync().ConfigureAwait(false);
if (permsToRemoveCount == 0)
return [];
_logger.LogInformation("[{current}/{totalCount}] User {user}: Planning to remove {removed} permissions", userNr, totalUsers, uid, permsToRemoveCount);
return await perms.ToListAsync().ConfigureAwait(false);
}
private async Task<List<string>> GetAllPairsForUser(string uid, LightlessDbContext dbContext, CancellationToken ct)
{
var entries = await dbContext.ClientPairs.AsNoTracking().Where(k => k.UserUID == uid).Select(k => k.OtherUserUID)
.Concat(
dbContext.GroupPairs.Where(k => k.GroupUserUID == uid).AsNoTracking()
.Join(dbContext.GroupPairs.AsNoTracking(),
a => a.GroupGID,
b => b.GroupGID,
(a, b) => b.GroupUserUID)
.Where(a => a != uid))
.ToListAsync(ct).ConfigureAwait(false);
return entries.Distinct(StringComparer.Ordinal).ToList();
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
if (!_configurationService.GetValueOrDefault(nameof(ServerConfiguration.RunPermissionCleanupOnStartup), defaultValue: true))
{
await WaitUntilNextCleanup(ct).ConfigureAwait(false);
}
while (!ct.IsCancellationRequested)
{
try
{
_logger.LogInformation("Starting Permissions Cleanup");
await AllUsersPermissionsCleanup(ct).ConfigureAwait(false);
}
catch (DbUpdateConcurrencyException ex)
{
_logger.LogWarning(ex, "Concurrency Exception during User Permissions Cleanup");
continue;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled Exception during User Permissions Cleanup");
}
await WaitUntilNextCleanup(ct).ConfigureAwait(false);
}
}
private async Task WaitUntilNextCleanup(CancellationToken token)
{
var now = DateTime.UtcNow;
var nextRun = new DateTime(now.Year, now.Month, now.Day, 12, 0, 0, DateTimeKind.Utc);
if (now > nextRun) nextRun = nextRun.AddDays(1);
var nextRunSpan = nextRun - now;
_logger.LogInformation("Permissions Cleanup next run in {span}", nextRunSpan);
await Task.Delay(nextRunSpan, token).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,226 @@
using LightlessSync.API.Dto.CharaData;
using LightlessSync.API.SignalR;
using LightlessSyncServer.Hubs;
using Microsoft.AspNetCore.SignalR;
using StackExchange.Redis.Extensions.Core.Abstractions;
namespace LightlessSyncServer.Services;
public sealed class GPoseLobbyDistributionService : IHostedService, IDisposable
{
private CancellationTokenSource _runtimeCts = new();
private readonly Dictionary<string, Dictionary<string, WorldData>> _lobbyWorldData = [];
private readonly Dictionary<string, Dictionary<string, PoseData>> _lobbyPoseData = [];
private readonly SemaphoreSlim _lobbyPoseDataModificationSemaphore = new(1, 1);
private readonly SemaphoreSlim _lobbyWorldDataModificationSemaphore = new(1, 1);
public GPoseLobbyDistributionService(ILogger<GPoseLobbyDistributionService> logger, IRedisDatabase redisDb,
IHubContext<LightlessHub, ILightlessHub> hubContext)
{
_logger = logger;
_redisDb = redisDb;
_hubContext = hubContext;
}
private bool _disposed;
private readonly ILogger<GPoseLobbyDistributionService> _logger;
private readonly IRedisDatabase _redisDb;
private readonly IHubContext<LightlessHub, ILightlessHub> _hubContext;
public void Dispose()
{
if (_disposed)
{
return;
}
_runtimeCts.Cancel();
_runtimeCts.Dispose();
_lobbyPoseDataModificationSemaphore.Dispose();
_lobbyWorldDataModificationSemaphore.Dispose();
_disposed = true;
}
public async Task PushWorldData(string lobby, string user, WorldData worldData)
{
await _lobbyWorldDataModificationSemaphore.WaitAsync().ConfigureAwait(false);
try
{
if (!_lobbyWorldData.TryGetValue(lobby, out var worldDataDict))
{
_lobbyWorldData[lobby] = worldDataDict = new(StringComparer.Ordinal);
}
worldDataDict[user] = worldData;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during Pushing World Data for Lobby {lobby} by User {user}", lobby, user);
}
finally
{
_lobbyWorldDataModificationSemaphore.Release();
}
}
public async Task PushPoseData(string lobby, string user, PoseData poseData)
{
await _lobbyPoseDataModificationSemaphore.WaitAsync().ConfigureAwait(false);
try
{
if (!_lobbyPoseData.TryGetValue(lobby, out var poseDataDict))
{
_lobbyPoseData[lobby] = poseDataDict = new(StringComparer.Ordinal);
}
poseDataDict[user] = poseData;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during Pushing World Data for Lobby {lobby} by User {user}", lobby, user);
}
finally
{
_lobbyPoseDataModificationSemaphore.Release();
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
_ = WorldDataDistribution(_runtimeCts.Token);
_ = PoseDataDistribution(_runtimeCts.Token);
return Task.CompletedTask;
}
private async Task WorldDataDistribution(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await DistributeWorldData(token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during World Data Distribution");
}
await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false);
}
}
private async Task PoseDataDistribution(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await DistributePoseData(token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during Pose Data Distribution");
}
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
}
}
private async Task DistributeWorldData(CancellationToken token)
{
await _lobbyWorldDataModificationSemaphore.WaitAsync(token).ConfigureAwait(false);
Dictionary<string, Dictionary<string, WorldData>> clone = [];
try
{
clone = _lobbyWorldData.ToDictionary(k => k.Key, k => k.Value, StringComparer.Ordinal);
_lobbyWorldData.Clear();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during Distributing World Data Clone generation");
_lobbyWorldData.Clear();
return;
}
finally
{
_lobbyWorldDataModificationSemaphore.Release();
}
foreach (var lobbyId in clone)
{
token.ThrowIfCancellationRequested();
try
{
if (!lobbyId.Value.Values.Any())
continue;
var gposeLobbyUsers = await _redisDb.GetAsync<List<string>>($"GposeLobby:{lobbyId.Key}").ConfigureAwait(false);
if (gposeLobbyUsers == null)
continue;
foreach (var data in lobbyId.Value)
{
await _hubContext.Clients.Users(gposeLobbyUsers.Where(k => !string.Equals(k, data.Key, StringComparison.Ordinal)))
.Client_GposeLobbyPushWorldData(new(data.Key), data.Value).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during World Data Distribution for Lobby {lobby}", lobbyId.Key);
continue;
}
}
}
private async Task DistributePoseData(CancellationToken token)
{
await _lobbyPoseDataModificationSemaphore.WaitAsync(token).ConfigureAwait(false);
Dictionary<string, Dictionary<string, PoseData>> clone = [];
try
{
clone = _lobbyPoseData.ToDictionary(k => k.Key, k => k.Value, StringComparer.Ordinal);
_lobbyPoseData.Clear();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during Distributing Pose Data Clone generation");
_lobbyPoseData.Clear();
return;
}
finally
{
_lobbyPoseDataModificationSemaphore.Release();
}
foreach (var lobbyId in clone)
{
token.ThrowIfCancellationRequested();
try
{
if (!lobbyId.Value.Values.Any())
continue;
var gposeLobbyUsers = await _redisDb.GetAsync<List<string>>($"GposeLobby:{lobbyId.Key}").ConfigureAwait(false);
if (gposeLobbyUsers == null)
continue;
foreach (var data in lobbyId.Value)
{
await _hubContext.Clients.Users(gposeLobbyUsers.Where(k => !string.Equals(k, data.Key, StringComparison.Ordinal)))
.Client_GposeLobbyPushPoseData(new(data.Key), data.Value).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during Pose Data Distribution for Lobby {lobby}", lobbyId.Key);
continue;
}
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_runtimeCts.Cancel();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,182 @@
using LightlessSync.API.Dto.User;
using Microsoft.VisualBasic.FileIO;
using Prometheus;
using System.Collections.Concurrent;
using System.Globalization;
namespace LightlessSyncServer.Services;
public class LightlessCensus : IHostedService
{
private record CensusEntry(ushort WorldId, short Race, short Subrace, short Gender)
{
public static CensusEntry FromDto(CensusDataDto dto)
{
return new CensusEntry(dto.WorldId, dto.RaceId, dto.TribeId, dto.Gender);
}
}
private readonly ConcurrentDictionary<string, CensusEntry> _censusEntries = new(StringComparer.Ordinal);
private readonly Dictionary<short, string> _dcs = new();
private readonly Dictionary<short, string> _gender = new();
private readonly ILogger<LightlessCensus> _logger;
private readonly Dictionary<short, string> _races = new();
private readonly Dictionary<short, string> _tribes = new();
private readonly Dictionary<ushort, (string, short)> _worlds = new();
private Gauge? _gauge;
public LightlessCensus(ILogger<LightlessCensus> logger)
{
_logger = logger;
}
private bool Initialized => _gauge != null;
public void ClearStatistics(string uid)
{
if (!Initialized) return;
if (_censusEntries.Remove(uid, out var censusEntry))
{
ModifyGauge(censusEntry, increase: false);
}
}
public void PublishStatistics(string uid, CensusDataDto? censusDataDto)
{
if (!Initialized || censusDataDto == null) return;
var newEntry = CensusEntry.FromDto(censusDataDto);
if (_censusEntries.TryGetValue(uid, out var entry))
{
if (entry != newEntry)
{
ModifyGauge(entry, increase: false);
ModifyGauge(newEntry, increase: true);
_censusEntries[uid] = newEntry;
}
}
else
{
_censusEntries[uid] = newEntry;
ModifyGauge(newEntry, increase: true);
}
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Loading XIVAPI data");
using HttpClient client = new HttpClient();
Dictionary<ushort, short> worldDcs = new();
var dcs = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/WorldDCGroupType.csv", cancellationToken).ConfigureAwait(false);
// dc: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/WorldDCGroupType.csv
// id, name, region
using var dcsReader = new StringReader(dcs);
using var dcsParser = new TextFieldParser(dcsReader);
dcsParser.Delimiters = [","];
// read 3 lines and discard
dcsParser.ReadLine(); dcsParser.ReadLine(); dcsParser.ReadLine();
while (!dcsParser.EndOfData)
{
var fields = dcsParser.ReadFields();
var id = short.Parse(fields[0], CultureInfo.InvariantCulture);
var name = fields[1];
if (string.IsNullOrEmpty(name) || id == 0) continue;
_logger.LogInformation("DC: ID: {id}, Name: {name}", id, name);
_dcs[id] = name;
}
var worlds = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/World.csv", cancellationToken).ConfigureAwait(false);
// world: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/World.csv
// id, internalname, name, region, usertype, datacenter, ispublic
using var worldsReader = new StringReader(worlds);
using var worldsParser = new TextFieldParser(worldsReader);
worldsParser.Delimiters = [","];
// read 3 lines and discard
worldsParser.ReadLine(); worldsParser.ReadLine(); worldsParser.ReadLine();
while (!worldsParser.EndOfData)
{
var fields = worldsParser.ReadFields();
var id = ushort.Parse(fields[0], CultureInfo.InvariantCulture);
var name = fields[1];
var dc = short.Parse(fields[5], CultureInfo.InvariantCulture);
var isPublic = bool.Parse(fields[6]);
if (!_dcs.ContainsKey(dc) || !isPublic) continue;
_worlds[id] = (name, dc);
_logger.LogInformation("World: ID: {id}, Name: {name}, DC: {dc}", id, name, dc);
}
var races = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Race.csv", cancellationToken).ConfigureAwait(false);
// race: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Race.csv
// id, masc name, fem name, other crap I don't care about
using var raceReader = new StringReader(races);
using var raceParser = new TextFieldParser(raceReader);
raceParser.Delimiters = [","];
// read 3 lines and discard
raceParser.ReadLine(); raceParser.ReadLine(); raceParser.ReadLine();
while (!raceParser.EndOfData)
{
var fields = raceParser.ReadFields();
var id = short.Parse(fields[0], CultureInfo.InvariantCulture);
var name = fields[1];
if (string.IsNullOrEmpty(name) || id == 0) continue;
_races[id] = name;
_logger.LogInformation("Race: ID: {id}, Name: {name}", id, name);
}
var tribe = await client.GetStringAsync("https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Tribe.csv", cancellationToken).ConfigureAwait(false);
// tribe: https://raw.githubusercontent.com/xivapi/ffxiv-datamining/master/csv/Tribe.csv
// id masc name, fem name, other crap I don't care about
using var tribeReader = new StringReader(tribe);
using var tribeParser = new TextFieldParser(tribeReader);
tribeParser.Delimiters = [","];
// read 3 lines and discard
tribeParser.ReadLine(); tribeParser.ReadLine(); tribeParser.ReadLine();
while (!tribeParser.EndOfData)
{
var fields = tribeParser.ReadFields();
var id = short.Parse(fields[0], CultureInfo.InvariantCulture);
var name = fields[1];
if (string.IsNullOrEmpty(name) || id == 0) continue;
_tribes[id] = name;
_logger.LogInformation("Tribe: ID: {id}, Name: {name}", id, name);
}
_gender[0] = "Male";
_gender[1] = "Female";
_gauge = Metrics.CreateGauge("lightless_census", "lightless informational census data", new[] { "dc", "world", "gender", "race", "subrace" });
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private void ModifyGauge(CensusEntry censusEntry, bool increase)
{
var subraceSuccess = _tribes.TryGetValue(censusEntry.Subrace, out var subrace);
var raceSuccess = _races.TryGetValue(censusEntry.Race, out var race);
var worldSuccess = _worlds.TryGetValue(censusEntry.WorldId, out var world);
var genderSuccess = _gender.TryGetValue(censusEntry.Gender, out var gender);
if (subraceSuccess && raceSuccess && worldSuccess && genderSuccess && _dcs.TryGetValue(world.Item2, out var dc))
{
if (increase)
_gauge.WithLabels(dc, world.Item1, gender, race, subrace).Inc();
else
_gauge.WithLabels(dc, world.Item1, gender, race, subrace).Dec();
}
}
}

View File

@@ -0,0 +1,145 @@
using LightlessSyncShared.Metrics;
namespace LightlessSyncServer.Services;
public class OnlineSyncedPairCacheService
{
private readonly Dictionary<string, PairCache> _lastSeenCache = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _cacheModificationSemaphore = new(1);
private readonly ILogger<OnlineSyncedPairCacheService> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMetrics _lightlessMetrics;
public OnlineSyncedPairCacheService(ILogger<OnlineSyncedPairCacheService> logger, ILoggerFactory loggerFactory, LightlessMetrics lightlessMetrics)
{
_logger = logger;
_loggerFactory = loggerFactory;
_lightlessMetrics = lightlessMetrics;
}
public async Task InitPlayer(string user)
{
if (_lastSeenCache.ContainsKey(user)) return;
await _cacheModificationSemaphore.WaitAsync().ConfigureAwait(false);
try
{
_logger.LogDebug("Initializing {user}", user);
_lastSeenCache[user] = new(_loggerFactory.CreateLogger<PairCache>(), user, _lightlessMetrics);
}
finally
{
_cacheModificationSemaphore.Release();
}
}
public async Task DisposePlayer(string user)
{
if (!_lastSeenCache.ContainsKey(user)) return;
await _cacheModificationSemaphore.WaitAsync().ConfigureAwait(false);
try
{
_logger.LogDebug("Disposing {user}", user);
_lastSeenCache.Remove(user, out var pairCache);
pairCache?.Dispose();
}
finally
{
_cacheModificationSemaphore.Release();
}
}
public async Task<bool> AreAllPlayersCached(string sender, List<string> uids, CancellationToken ct)
{
if (!_lastSeenCache.ContainsKey(sender)) await InitPlayer(sender).ConfigureAwait(false);
_lastSeenCache.TryGetValue(sender, out var pairCache);
return await pairCache.AreAllPlayersCached(uids, ct).ConfigureAwait(false);
}
public async Task CachePlayers(string sender, List<string> uids, CancellationToken ct)
{
if (!_lastSeenCache.ContainsKey(sender)) await InitPlayer(sender).ConfigureAwait(false);
_lastSeenCache.TryGetValue(sender, out var pairCache);
await pairCache.CachePlayers(uids, ct).ConfigureAwait(false);
}
private sealed class PairCache : IDisposable
{
private readonly ILogger<PairCache> _logger;
private readonly string _owner;
private readonly LightlessMetrics _metrics;
private readonly Dictionary<string, DateTime> _lastSeenCache = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _lock = new(1);
public PairCache(ILogger<PairCache> logger, string owner, LightlessMetrics metrics)
{
metrics.IncGauge(MetricsAPI.GaugeUserPairCacheUsers);
_logger = logger;
_owner = owner;
_metrics = metrics;
}
public async Task<bool> AreAllPlayersCached(List<string> uids, CancellationToken ct)
{
await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
var allCached = uids.TrueForAll(u => _lastSeenCache.TryGetValue(u, out var expiry) && expiry > DateTime.UtcNow);
_logger.LogDebug("AreAllPlayersCached:{uid}:{count}:{result}", _owner, uids.Count, allCached);
if (allCached) _metrics.IncCounter(MetricsAPI.CounterUserPairCacheHit);
else _metrics.IncCounter(MetricsAPI.CounterUserPairCacheMiss);
return allCached;
}
finally
{
_lock.Release();
}
}
public async Task CachePlayers(List<string> uids, CancellationToken ct)
{
await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
var lastSeen = DateTime.UtcNow.AddMinutes(60);
_logger.LogDebug("CacheOnlinePlayers:{uid}:{count}", _owner, uids.Count);
var newEntries = uids.Count(u => !_lastSeenCache.ContainsKey(u));
_metrics.IncCounter(MetricsAPI.CounterUserPairCacheNewEntries, newEntries);
_metrics.IncCounter(MetricsAPI.CounterUserPairCacheUpdatedEntries, uids.Count - newEntries);
_metrics.IncGauge(MetricsAPI.GaugeUserPairCacheEntries, newEntries);
uids.ForEach(u => _lastSeenCache[u] = lastSeen);
// clean up old entries
var outdatedEntries = _lastSeenCache.Where(u => u.Value < DateTime.UtcNow).Select(k => k.Key).ToList();
if (outdatedEntries.Any())
{
_metrics.DecGauge(MetricsAPI.GaugeUserPairCacheEntries, outdatedEntries.Count);
foreach (var entry in outdatedEntries)
{
_lastSeenCache.Remove(entry);
}
}
}
finally
{
_lock.Release();
}
}
public void Dispose()
{
_metrics.DecGauge(MetricsAPI.GaugeUserPairCacheUsers);
_metrics.DecGauge(MetricsAPI.GaugeUserPairCacheEntries, _lastSeenCache.Count);
_lock.Dispose();
}
}
}

View File

@@ -0,0 +1,84 @@
using LightlessSync.API.Dto;
using LightlessSync.API.SignalR;
using LightlessSyncServer.Hubs;
using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis.Extensions.Core.Abstractions;
namespace LightlessSyncServer.Services;
public sealed class SystemInfoService : BackgroundService
{
private readonly LightlessMetrics _lightlessMetrics;
private readonly IConfigurationService<ServerConfiguration> _config;
private readonly IDbContextFactory<LightlessDbContext> _dbContextFactory;
private readonly ILogger<SystemInfoService> _logger;
private readonly IHubContext<LightlessHub, ILightlessHub> _hubContext;
private readonly IRedisDatabase _redis;
public SystemInfoDto SystemInfoDto { get; private set; } = new();
public SystemInfoService(LightlessMetrics lightlessMetrics, IConfigurationService<ServerConfiguration> configurationService, IDbContextFactory<LightlessDbContext> dbContextFactory,
ILogger<SystemInfoService> logger, IHubContext<LightlessHub, ILightlessHub> hubContext, IRedisDatabase redisDb)
{
_lightlessMetrics = lightlessMetrics;
_config = configurationService;
_dbContextFactory = dbContextFactory;
_logger = logger;
_hubContext = hubContext;
_redis = redisDb;
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
await base.StartAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("System Info Service started");
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var timeOut = _config.IsMain ? 15 : 30;
while (!ct.IsCancellationRequested)
{
try
{
ThreadPool.GetAvailableThreads(out int workerThreads, out int ioThreads);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeAvailableWorkerThreads, workerThreads);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeAvailableIOWorkerThreads, ioThreads);
var onlineUsers = (_redis.SearchKeysAsync("UID:*").GetAwaiter().GetResult()).Count();
SystemInfoDto = new SystemInfoDto()
{
OnlineUsers = onlineUsers,
};
if (_config.IsMain)
{
_logger.LogInformation("Sending System Info, Online Users: {onlineUsers}", onlineUsers);
await _hubContext.Clients.All.Client_UpdateSystemInfo(SystemInfoDto).ConfigureAwait(false);
using var db = await _dbContextFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeAuthorizedConnections, onlineUsers);
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugePairs, db.ClientPairs.AsNoTracking().Count());
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugePairsPaused, db.Permissions.AsNoTracking().Where(p => p.IsPaused).Count());
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroups, db.Groups.AsNoTracking().Count());
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeGroupPairs, db.GroupPairs.AsNoTracking().Count());
_lightlessMetrics.SetGaugeTo(MetricsAPI.GaugeUsersRegistered, db.Users.AsNoTracking().Count());
}
await Task.Delay(TimeSpan.FromSeconds(timeOut), ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to push system info");
}
}
}
}

View File

@@ -0,0 +1,194 @@
using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncServer.Services;
public class UserCleanupService : IHostedService
{
private readonly LightlessMetrics metrics;
private readonly ILogger<UserCleanupService> _logger;
private readonly IDbContextFactory<LightlessDbContext> _lightlessDbContextFactory;
private readonly IConfigurationService<ServerConfiguration> _configuration;
private CancellationTokenSource _cleanupCts;
public UserCleanupService(LightlessMetrics metrics, ILogger<UserCleanupService> logger, IDbContextFactory<LightlessDbContext> lightlessDbContextFactory, IConfigurationService<ServerConfiguration> configuration)
{
this.metrics = metrics;
_logger = logger;
_lightlessDbContextFactory = lightlessDbContextFactory;
_configuration = configuration;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Cleanup Service started");
_cleanupCts = new();
_ = CleanUp(_cleanupCts.Token);
return Task.CompletedTask;
}
private async Task CleanUp(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using (var dbContext = await _lightlessDbContextFactory.CreateDbContextAsync(ct).ConfigureAwait(false))
{
CleanUpOutdatedLodestoneAuths(dbContext);
await PurgeUnusedAccounts(dbContext).ConfigureAwait(false);
await PurgeTempInvites(dbContext).ConfigureAwait(false);
dbContext.SaveChanges();
}
var now = DateTime.Now;
TimeOnly currentTime = new(now.Hour, now.Minute, now.Second);
TimeOnly futureTime = new(now.Hour, now.Minute - now.Minute % 10, 0);
var span = futureTime.AddMinutes(10) - currentTime;
_logger.LogInformation("User Cleanup Complete, next run at {date}", now.Add(span));
await Task.Delay(span, ct).ConfigureAwait(false);
}
}
private async Task PurgeTempInvites(LightlessDbContext dbContext)
{
try
{
var tempInvites = await dbContext.GroupTempInvites.ToListAsync().ConfigureAwait(false);
dbContext.RemoveRange(tempInvites.Where(i => i.ExpirationDate < DateTime.UtcNow));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during Temp Invite purge");
}
}
private async Task PurgeUnusedAccounts(LightlessDbContext dbContext)
{
try
{
if (_configuration.GetValueOrDefault(nameof(ServerConfiguration.PurgeUnusedAccounts), false))
{
var usersOlderThanDays = _configuration.GetValueOrDefault(nameof(ServerConfiguration.PurgeUnusedAccountsPeriodInDays), 14);
var maxGroupsByUser = _configuration.GetValueOrDefault(nameof(ServerConfiguration.MaxGroupUserCount), 3);
_logger.LogInformation("Cleaning up users older than {usersOlderThanDays} days", usersOlderThanDays);
var allUsers = dbContext.Users.Where(u => string.IsNullOrEmpty(u.Alias)).ToList();
List<User> usersToRemove = new();
foreach (var user in allUsers)
{
if (user.LastLoggedIn < DateTime.UtcNow - TimeSpan.FromDays(usersOlderThanDays))
{
_logger.LogInformation("User outdated: {userUID}", user.UID);
usersToRemove.Add(user);
}
}
foreach (var user in usersToRemove)
{
await SharedDbFunctions.PurgeUser(_logger, user, dbContext, maxGroupsByUser).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during user purge");
}
}
private void CleanUpOutdatedLodestoneAuths(LightlessDbContext dbContext)
{
try
{
_logger.LogInformation($"Cleaning up expired lodestone authentications");
var lodestoneAuths = dbContext.LodeStoneAuth.Include(u => u.User).Where(a => a.StartedAt != null).ToList();
List<LodeStoneAuth> expiredAuths = new List<LodeStoneAuth>();
foreach (var auth in lodestoneAuths)
{
if (auth.StartedAt < DateTime.UtcNow - TimeSpan.FromMinutes(15))
{
expiredAuths.Add(auth);
}
}
dbContext.Users.RemoveRange(expiredAuths.Where(u => u.User != null).Select(a => a.User));
dbContext.RemoveRange(expiredAuths);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during expired auths cleanup");
}
}
public async Task PurgeUser(User user, LightlessDbContext dbContext)
{
_logger.LogInformation("Purging user: {uid}", user.UID);
var lodestone = dbContext.LodeStoneAuth.SingleOrDefault(a => a.User.UID == user.UID);
if (lodestone != null)
{
dbContext.Remove(lodestone);
}
var auth = dbContext.Auth.Single(a => a.UserUID == user.UID);
var userFiles = dbContext.Files.Where(f => f.Uploaded && f.Uploader.UID == user.UID).ToList();
dbContext.Files.RemoveRange(userFiles);
var ownPairData = dbContext.ClientPairs.Where(u => u.User.UID == user.UID).ToList();
dbContext.ClientPairs.RemoveRange(ownPairData);
var otherPairData = dbContext.ClientPairs.Include(u => u.User)
.Where(u => u.OtherUser.UID == user.UID).ToList();
dbContext.ClientPairs.RemoveRange(otherPairData);
var userJoinedGroups = await dbContext.GroupPairs.Include(g => g.Group).Where(u => u.GroupUserUID == user.UID).ToListAsync().ConfigureAwait(false);
foreach (var userGroupPair in userJoinedGroups)
{
bool ownerHasLeft = string.Equals(userGroupPair.Group.OwnerUID, user.UID, StringComparison.Ordinal);
if (ownerHasLeft)
{
var groupPairs = await dbContext.GroupPairs.Where(g => g.GroupGID == userGroupPair.GroupGID && g.GroupUserUID != user.UID).ToListAsync().ConfigureAwait(false);
if (!groupPairs.Any())
{
_logger.LogInformation("Group {gid} has no new owner, deleting", userGroupPair.GroupGID);
dbContext.Groups.Remove(userGroupPair.Group);
}
else
{
_ = await SharedDbFunctions.MigrateOrDeleteGroup(dbContext, userGroupPair.Group, groupPairs, _configuration.GetValueOrDefault(nameof(ServerConfiguration.MaxExistingGroupsByUser), 3)).ConfigureAwait(false);
}
}
dbContext.GroupPairs.Remove(userGroupPair);
}
_logger.LogInformation("User purged: {uid}", user.UID);
dbContext.Auth.Remove(auth);
dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken)
{
_cleanupCts.Cancel();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,367 @@
using Microsoft.EntityFrameworkCore;
using LightlessSyncServer.Hubs;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Authorization;
using AspNetCoreRateLimit;
using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics;
using LightlessSyncServer.Services;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Services;
using Prometheus;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using StackExchange.Redis;
using StackExchange.Redis.Extensions.Core.Configuration;
using System.Net;
using StackExchange.Redis.Extensions.System.Text.Json;
using LightlessSync.API.SignalR;
using MessagePack;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.Mvc.Controllers;
using LightlessSyncServer.Controllers;
using LightlessSyncShared.RequirementHandlers;
using LightlessSyncShared.Utils.Configuration;
namespace LightlessSyncServer;
public class Startup
{
private readonly ILogger<Startup> _logger;
public Startup(IConfiguration configuration, ILogger<Startup> logger)
{
Configuration = configuration;
_logger = logger;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddTransient(_ => Configuration);
var lightlessConfig = Configuration.GetRequiredSection("LightlessSync");
// configure metrics
ConfigureMetrics(services);
// configure database
ConfigureDatabase(services, lightlessConfig);
// configure authentication and authorization
ConfigureAuthorization(services);
// configure rate limiting
ConfigureIpRateLimiting(services);
// configure SignalR
ConfigureSignalR(services, lightlessConfig);
// configure lightless specific services
ConfigureLightlessServices(services, lightlessConfig);
services.AddHealthChecks();
services.AddControllers().ConfigureApplicationPartManager(a =>
{
a.FeatureProviders.Remove(a.FeatureProviders.OfType<ControllerFeatureProvider>().First());
if (lightlessConfig.GetValue<Uri>(nameof(ServerConfiguration.MainServerAddress), defaultValue: null) == null)
{
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(LightlessServerConfigurationController), typeof(LightlessBaseConfigurationController), typeof(ClientMessageController)));
}
else
{
a.FeatureProviders.Add(new AllowedControllersFeatureProvider());
}
});
}
private void ConfigureLightlessServices(IServiceCollection services, IConfigurationSection lightlessConfig)
{
bool isMainServer = lightlessConfig.GetValue<Uri>(nameof(ServerConfiguration.MainServerAddress), defaultValue: null) == null;
services.Configure<ServerConfiguration>(Configuration.GetRequiredSection("LightlessSync"));
services.Configure<LightlessConfigurationBase>(Configuration.GetRequiredSection("LightlessSync"));
services.AddSingleton<ServerTokenGenerator>();
services.AddSingleton<SystemInfoService>();
services.AddSingleton<OnlineSyncedPairCacheService>();
services.AddHostedService(provider => provider.GetService<SystemInfoService>());
// configure services based on main server status
ConfigureServicesBasedOnShardType(services, lightlessConfig, isMainServer);
services.AddSingleton(s => new LightlessCensus(s.GetRequiredService<ILogger<LightlessCensus>>()));
services.AddHostedService(p => p.GetRequiredService<LightlessCensus>());
if (isMainServer)
{
services.AddSingleton<UserCleanupService>();
services.AddHostedService(provider => provider.GetService<UserCleanupService>());
services.AddSingleton<CharaDataCleanupService>();
services.AddHostedService(provider => provider.GetService<CharaDataCleanupService>());
services.AddHostedService<ClientPairPermissionsCleanupService>();
}
services.AddSingleton<GPoseLobbyDistributionService>();
services.AddHostedService(provider => provider.GetService<GPoseLobbyDistributionService>());
}
private static void ConfigureSignalR(IServiceCollection services, IConfigurationSection lightlessConfig)
{
services.AddSingleton<IUserIdProvider, IdBasedUserIdProvider>();
services.AddSingleton<ConcurrencyFilter>();
var signalRServiceBuilder = services.AddSignalR(hubOptions =>
{
hubOptions.MaximumReceiveMessageSize = long.MaxValue;
hubOptions.EnableDetailedErrors = true;
hubOptions.MaximumParallelInvocationsPerClient = 10;
hubOptions.StreamBufferCapacity = 200;
hubOptions.AddFilter<SignalRLimitFilter>();
hubOptions.AddFilter<ConcurrencyFilter>();
}).AddMessagePackProtocol(opt =>
{
var resolver = CompositeResolver.Create(StandardResolverAllowPrivate.Instance,
BuiltinResolver.Instance,
AttributeFormatterResolver.Instance,
// replace enum resolver
DynamicEnumAsStringResolver.Instance,
DynamicGenericResolver.Instance,
DynamicUnionResolver.Instance,
DynamicObjectResolver.Instance,
PrimitiveObjectResolver.Instance,
// final fallback(last priority)
StandardResolver.Instance);
opt.SerializerOptions = MessagePackSerializerOptions.Standard
.WithCompression(MessagePackCompression.Lz4Block)
.WithResolver(resolver);
});
// configure redis for SignalR
var redisConnection = lightlessConfig.GetValue(nameof(ServerConfiguration.RedisConnectionString), string.Empty);
signalRServiceBuilder.AddStackExchangeRedis(redisConnection, options => { });
var options = ConfigurationOptions.Parse(redisConnection);
var endpoint = options.EndPoints[0];
string address = "";
int port = 0;
if (endpoint is DnsEndPoint dnsEndPoint) { address = dnsEndPoint.Host; port = dnsEndPoint.Port; }
if (endpoint is IPEndPoint ipEndPoint) { address = ipEndPoint.Address.ToString(); port = ipEndPoint.Port; }
var redisConfiguration = new RedisConfiguration()
{
AbortOnConnectFail = true,
KeyPrefix = "",
Hosts = new RedisHost[]
{
new RedisHost(){ Host = address, Port = port },
},
AllowAdmin = true,
ConnectTimeout = options.ConnectTimeout,
Database = 0,
Ssl = false,
Password = options.Password,
ServerEnumerationStrategy = new ServerEnumerationStrategy()
{
Mode = ServerEnumerationStrategy.ModeOptions.All,
TargetRole = ServerEnumerationStrategy.TargetRoleOptions.Any,
UnreachableServerAction = ServerEnumerationStrategy.UnreachableServerActionOptions.Throw,
},
MaxValueLength = 1024,
PoolSize = lightlessConfig.GetValue(nameof(ServerConfiguration.RedisPool), 50),
SyncTimeout = options.SyncTimeout,
};
services.AddStackExchangeRedisExtensions<SystemTextJsonSerializer>(redisConfiguration);
}
private void ConfigureIpRateLimiting(IServiceCollection services)
{
services.Configure<IpRateLimitOptions>(Configuration.GetSection("IpRateLimiting"));
services.Configure<IpRateLimitPolicies>(Configuration.GetSection("IpRateLimitPolicies"));
services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
services.AddMemoryCache();
services.AddInMemoryRateLimiting();
}
private static void ConfigureAuthorization(IServiceCollection services)
{
services.AddTransient<IAuthorizationHandler, UserRequirementHandler>();
services.AddTransient<IAuthorizationHandler, ValidTokenRequirementHandler>();
services.AddTransient<IAuthorizationHandler, ValidTokenHubRequirementHandler>();
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<IConfigurationService<LightlessConfigurationBase>>((options, config) =>
{
options.TokenValidationParameters = new()
{
ValidateIssuer = false,
ValidateLifetime = true,
ValidateAudience = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.GetValue<string>(nameof(LightlessConfigurationBase.Jwt)))),
};
});
services.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer();
services.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser().Build();
options.AddPolicy("Authenticated", policy =>
{
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
policy.RequireAuthenticatedUser();
policy.AddRequirements(new ValidTokenRequirement());
});
options.AddPolicy("Identified", policy =>
{
policy.AddRequirements(new UserRequirement(UserRequirements.Identified));
policy.AddRequirements(new ValidTokenRequirement());
});
options.AddPolicy("Admin", policy =>
{
policy.AddRequirements(new UserRequirement(UserRequirements.Identified | UserRequirements.Administrator));
policy.AddRequirements(new ValidTokenRequirement());
});
options.AddPolicy("Moderator", policy =>
{
policy.AddRequirements(new UserRequirement(UserRequirements.Identified | UserRequirements.Moderator | UserRequirements.Administrator));
policy.AddRequirements(new ValidTokenRequirement());
});
options.AddPolicy("Internal", new AuthorizationPolicyBuilder().RequireClaim(LightlessClaimTypes.Internal, "true").Build());
});
}
private void ConfigureDatabase(IServiceCollection services, IConfigurationSection lightlessConfig)
{
services.AddDbContextPool<LightlessDbContext>(options =>
{
options.UseNpgsql(Configuration.GetConnectionString("DefaultConnection"), builder =>
{
builder.MigrationsHistoryTable("_efmigrationshistory", "public");
builder.MigrationsAssembly("LightlessSyncShared");
}).UseSnakeCaseNamingConvention();
options.EnableThreadSafetyChecks(false);
}, lightlessConfig.GetValue(nameof(LightlessConfigurationBase.DbContextPoolSize), 1024));
services.AddDbContextFactory<LightlessDbContext>(options =>
{
options.UseNpgsql(Configuration.GetConnectionString("DefaultConnection"), builder =>
{
builder.MigrationsHistoryTable("_efmigrationshistory", "public");
builder.MigrationsAssembly("LightlessSyncShared");
}).UseSnakeCaseNamingConvention();
options.EnableThreadSafetyChecks(false);
});
}
private static void ConfigureMetrics(IServiceCollection services)
{
services.AddSingleton<LightlessMetrics>(m => new LightlessMetrics(m.GetService<ILogger<LightlessMetrics>>(), new List<string>
{
MetricsAPI.CounterInitializedConnections,
MetricsAPI.CounterUserPushData,
MetricsAPI.CounterUserPushDataTo,
MetricsAPI.CounterUsersRegisteredDeleted,
MetricsAPI.CounterAuthenticationCacheHits,
MetricsAPI.CounterAuthenticationFailures,
MetricsAPI.CounterAuthenticationRequests,
MetricsAPI.CounterAuthenticationSuccesses,
MetricsAPI.CounterUserPairCacheHit,
MetricsAPI.CounterUserPairCacheMiss,
MetricsAPI.CounterUserPairCacheNewEntries,
MetricsAPI.CounterUserPairCacheUpdatedEntries,
}, new List<string>
{
MetricsAPI.GaugeAuthorizedConnections,
MetricsAPI.GaugeConnections,
MetricsAPI.GaugePairs,
MetricsAPI.GaugePairsPaused,
MetricsAPI.GaugeAvailableIOWorkerThreads,
MetricsAPI.GaugeAvailableWorkerThreads,
MetricsAPI.GaugeGroups,
MetricsAPI.GaugeGroupPairs,
MetricsAPI.GaugeUsersRegistered,
MetricsAPI.GaugeAuthenticationCacheEntries,
MetricsAPI.GaugeUserPairCacheEntries,
MetricsAPI.GaugeUserPairCacheUsers,
MetricsAPI.GaugeGposeLobbies,
MetricsAPI.GaugeGposeLobbyUsers,
MetricsAPI.GaugeHubConcurrency,
MetricsAPI.GaugeHubQueuedConcurrency,
}));
}
private static void ConfigureServicesBasedOnShardType(IServiceCollection services, IConfigurationSection lightlessConfig, bool isMainServer)
{
if (!isMainServer)
{
services.AddSingleton<IConfigurationService<ServerConfiguration>, LightlessConfigurationServiceClient<ServerConfiguration>>();
services.AddSingleton<IConfigurationService<LightlessConfigurationBase>, LightlessConfigurationServiceClient<LightlessConfigurationBase>>();
services.AddHostedService(p => (LightlessConfigurationServiceClient<ServerConfiguration>)p.GetService<IConfigurationService<ServerConfiguration>>());
services.AddHostedService(p => (LightlessConfigurationServiceClient<LightlessConfigurationBase>)p.GetService<IConfigurationService<LightlessConfigurationBase>>());
}
else
{
services.AddSingleton<IConfigurationService<ServerConfiguration>, LightlessConfigurationServiceServer<ServerConfiguration>>();
services.AddSingleton<IConfigurationService<LightlessConfigurationBase>, LightlessConfigurationServiceServer<LightlessConfigurationBase>>();
}
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILogger<Startup> logger)
{
logger.LogInformation("Running Configure");
var config = app.ApplicationServices.GetRequiredService<IConfigurationService<LightlessConfigurationBase>>();
app.UseIpRateLimiting();
app.UseRouting();
app.UseWebSockets();
app.UseHttpMetrics();
var metricServer = new KestrelMetricServer(config.GetValueOrDefault<int>(nameof(LightlessConfigurationBase.MetricsPort), 4980));
metricServer.Start();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapHub<LightlessHub>(ILightlessHub.Path, options =>
{
options.ApplicationMaxBufferSize = 5242880;
options.TransportMaxBufferSize = 5242880;
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling;
});
endpoints.MapHealthChecks("/health").AllowAnonymous();
endpoints.MapControllers();
foreach (var source in endpoints.DataSources.SelectMany(e => e.Endpoints).Cast<RouteEndpoint>())
{
if (source == null) continue;
_logger.LogInformation("Endpoint: {url} ", source.RoutePattern.RawText);
}
});
}
}

View File

@@ -0,0 +1,74 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSyncShared.Models;
using static LightlessSyncServer.Hubs.LightlessHub;
namespace LightlessSyncServer.Utils;
public static class Extensions
{
public static GroupData ToGroupData(this Group group)
{
return new GroupData(group.GID, group.Alias);
}
public static UserData ToUserData(this GroupPair pair)
{
return new UserData(pair.GroupUser.UID, pair.GroupUser.Alias);
}
public static UserData ToUserData(this User user)
{
return new UserData(user.UID, user.Alias);
}
public static IndividualPairStatus ToIndividualPairStatus(this UserInfo userInfo)
{
if (userInfo.IndividuallyPaired) return IndividualPairStatus.Bidirectional;
if (!userInfo.IndividuallyPaired && userInfo.GIDs.Contains(Constants.IndividualKeyword, StringComparer.Ordinal)) return IndividualPairStatus.OneSided;
return IndividualPairStatus.None;
}
public static GroupPermissions ToEnum(this Group group)
{
var permissions = GroupPermissions.NoneSet;
permissions.SetPreferDisableAnimations(group.PreferDisableAnimations);
permissions.SetPreferDisableSounds(group.PreferDisableSounds);
permissions.SetPreferDisableVFX(group.PreferDisableVFX);
permissions.SetDisableInvites(!group.InvitesEnabled);
return permissions;
}
public static GroupUserPreferredPermissions ToEnum(this GroupPairPreferredPermission groupPair)
{
var permissions = GroupUserPreferredPermissions.NoneSet;
permissions.SetDisableAnimations(groupPair.DisableAnimations);
permissions.SetDisableSounds(groupPair.DisableSounds);
permissions.SetPaused(groupPair.IsPaused);
permissions.SetDisableVFX(groupPair.DisableVFX);
return permissions;
}
public static GroupPairUserInfo ToEnum(this GroupPair groupPair)
{
var groupUserInfo = GroupPairUserInfo.None;
groupUserInfo.SetPinned(groupPair.IsPinned);
groupUserInfo.SetModerator(groupPair.IsModerator);
return groupUserInfo;
}
public static UserPermissions ToUserPermissions(this UserPermissionSet? permissions, bool setSticky = false)
{
if (permissions == null) return UserPermissions.NoneSet;
UserPermissions perm = UserPermissions.NoneSet;
perm.SetPaused(permissions.IsPaused);
perm.SetDisableAnimations(permissions.DisableAnimations);
perm.SetDisableSounds(permissions.DisableSounds);
perm.SetDisableVFX(permissions.DisableVFX);
if (setSticky)
perm.SetSticky(permissions.Sticky);
return perm;
}
}

View File

@@ -0,0 +1,34 @@
using LightlessSync.API.SignalR;
using LightlessSyncServer.Hubs;
using System.Runtime.CompilerServices;
namespace LightlessSyncServer.Utils;
public class LightlessHubLogger
{
private readonly LightlessHub _hub;
private readonly ILogger<LightlessHub> _logger;
public LightlessHubLogger(LightlessHub hub, ILogger<LightlessHub> logger)
{
_hub = hub;
_logger = logger;
}
public static object[] Args(params object[] args)
{
return args;
}
public void LogCallInfo(object[] args = null, [CallerMemberName] string methodName = "")
{
string formattedArgs = args != null && args.Length != 0 ? "|" + string.Join(":", args) : string.Empty;
_logger.LogInformation("{uid}:{method}{args}", _hub.UserUID, methodName, formattedArgs);
}
public void LogCallWarning(object[] args = null, [CallerMemberName] string methodName = "")
{
string formattedArgs = args != null && args.Length != 0 ? "|" + string.Join(":", args) : string.Empty;
_logger.LogWarning("{uid}:{method}{args}", _hub.UserUID, methodName, formattedArgs);
}
}

View File

@@ -0,0 +1,8 @@
namespace LightlessSyncServer.Utils;
public enum PauseInfo
{
NoConnection,
Paused,
Unpaused,
}

View File

@@ -0,0 +1,9 @@
namespace LightlessSyncServer.Utils;
public record PauseState
{
public string GID { get; set; }
public bool IsPaused => IsSelfPaused || IsOtherPaused;
public bool IsSelfPaused { get; set; }
public bool IsOtherPaused { get; set; }
}

View File

@@ -0,0 +1,58 @@
namespace LightlessSyncServer.Utils;
public record PausedEntry
{
public string UID { get; set; }
public List<PauseState> PauseStates { get; set; } = new();
public PauseInfo IsDirectlyPaused => PauseStateWithoutGroups == null ? PauseInfo.NoConnection
: PauseStates.First(g => g.GID == null).IsPaused ? PauseInfo.Paused : PauseInfo.Unpaused;
public PauseInfo IsPausedPerGroup => !PauseStatesWithoutDirect.Any() ? PauseInfo.NoConnection
: PauseStatesWithoutDirect.All(p => p.IsPaused) ? PauseInfo.Paused : PauseInfo.Unpaused;
private IEnumerable<PauseState> PauseStatesWithoutDirect => PauseStates.Where(f => f.GID != null);
private PauseState PauseStateWithoutGroups => PauseStates.SingleOrDefault(p => p.GID == null);
public bool IsPaused
{
get
{
var isDirectlyPaused = IsDirectlyPaused;
bool result;
if (isDirectlyPaused != PauseInfo.NoConnection)
{
result = isDirectlyPaused == PauseInfo.Paused;
}
else
{
result = IsPausedPerGroup == PauseInfo.Paused;
}
return result;
}
}
public PauseInfo IsOtherPausedForSpecificGroup(string gid)
{
var state = PauseStatesWithoutDirect.SingleOrDefault(g => string.Equals(g.GID, gid, StringComparison.Ordinal));
if (state == null) return PauseInfo.NoConnection;
return state.IsOtherPaused ? PauseInfo.Paused : PauseInfo.Unpaused;
}
public PauseInfo IsPausedForSpecificGroup(string gid)
{
var state = PauseStatesWithoutDirect.SingleOrDefault(g => string.Equals(g.GID, gid, StringComparison.Ordinal));
if (state == null) return PauseInfo.NoConnection;
return state.IsPaused ? PauseInfo.Paused : PauseInfo.NoConnection;
}
public PauseInfo IsPausedExcludingGroup(string gid)
{
var states = PauseStatesWithoutDirect.Where(f => !string.Equals(f.GID, gid, StringComparison.Ordinal)).ToList();
if (!states.Any()) return PauseInfo.NoConnection;
var result = states.All(p => p.IsPaused);
if (result) return PauseInfo.Paused;
return PauseInfo.Unpaused;
}
}

View File

@@ -0,0 +1,12 @@
namespace LightlessSyncServer.Hubs;
public partial class LightlessHub
{
private record UserPair
{
public string UserUID { get; set; }
public string OtherUserUID { get; set; }
public bool UserPausedOther { get; set; }
public bool OtherPausedUser { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,61 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=;Username=;Password="
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"LightlessSyncServer.Authentication": "Warning",
"System.IO.IOException": "Warning"
},
"File": {
"BasePath": "logs",
"FileAccessMode": "KeepOpenAndAutoFlush",
"FileEncodingName": "utf-8",
"DateFormat": "yyyMMdd",
"MaxFileSize": 10485760,
"Files": [
{
"Path": "lightless-<counter>.log"
}
]
}
},
"LightlessSync": {
"DbContextPoolSize": 2000,
"CdnFullUrl": "http://localhost/cache/",
"ServiceAddress": "http://localhost:5002",
"StaticFileServiceAddress": "http://localhost:5003"
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://+:6000",
"Certificate": {
"Subject": "sync.lightless-sync.org",
"Store": "My",
"Location": "LocalMachine"
//"AllowInvalid": false
// "Path": "", //use path, keypath and password to provide a valid certificate if not using windows key store
// "KeyPath": ""
// "Password": ""
}
}
}
},
"IpRateLimiting": {
"EnableEndpointRateLimiting": false,
"StackBlockedRequests": false,
"RealIpHeader": "X-Real-IP",
"ClientIdHeader": "X-ClientId",
"HttpStatusCode": 429,
"IpWhitelist": [ ],
"GeneralRules": [ ]
},
"IPRateLimitPolicies": {
"IpRules": []
}
}