rebuild project & update internal names

This commit is contained in:
Abelfreyja
2025-08-22 13:07:48 +09:00
parent 3d9bf49d7f
commit 7d3de5361a
190 changed files with 851 additions and 851 deletions

View File

@@ -0,0 +1,480 @@
using Dalamud.Utility;
using K4os.Compression.LZ4.Legacy;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Files;
using LightlessSync.API.Routes;
using LightlessSync.FileCache;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Net.Http.Json;
namespace LightlessSync.WebAPI.Files;
public partial class FileDownloadManager : DisposableMediatorSubscriberBase
{
private readonly Dictionary<string, FileDownloadStatus> _downloadStatus;
private readonly FileCompactor _fileCompactor;
private readonly FileCacheManager _fileDbManager;
private readonly FileTransferOrchestrator _orchestrator;
private readonly List<ThrottledStream> _activeDownloadStreams;
public FileDownloadManager(ILogger<FileDownloadManager> logger, MareMediator mediator,
FileTransferOrchestrator orchestrator,
FileCacheManager fileCacheManager, FileCompactor fileCompactor) : base(logger, mediator)
{
_downloadStatus = new Dictionary<string, FileDownloadStatus>(StringComparer.Ordinal);
_orchestrator = orchestrator;
_fileDbManager = fileCacheManager;
_fileCompactor = fileCompactor;
_activeDownloadStreams = [];
Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) =>
{
if (!_activeDownloadStreams.Any()) return;
var newLimit = _orchestrator.DownloadLimitPerSlot();
Logger.LogTrace("Setting new Download Speed Limit to {newLimit}", newLimit);
foreach (var stream in _activeDownloadStreams)
{
stream.BandwidthLimit = newLimit;
}
});
}
public List<DownloadFileTransfer> CurrentDownloads { get; private set; } = [];
public List<FileTransfer> ForbiddenTransfers => _orchestrator.ForbiddenTransfers;
public bool IsDownloading => !CurrentDownloads.Any();
public static void MungeBuffer(Span<byte> buffer)
{
for (int i = 0; i < buffer.Length; ++i)
{
buffer[i] ^= 42;
}
}
public void ClearDownload()
{
CurrentDownloads.Clear();
_downloadStatus.Clear();
}
public async Task DownloadFiles(GameObjectHandler gameObject, List<FileReplacementData> fileReplacementDto, CancellationToken ct)
{
Mediator.Publish(new HaltScanMessage(nameof(DownloadFiles)));
try
{
await DownloadFilesInternal(gameObject, fileReplacementDto, ct).ConfigureAwait(false);
}
catch
{
ClearDownload();
}
finally
{
Mediator.Publish(new DownloadFinishedMessage(gameObject));
Mediator.Publish(new ResumeScanMessage(nameof(DownloadFiles)));
}
}
protected override void Dispose(bool disposing)
{
ClearDownload();
foreach (var stream in _activeDownloadStreams.ToList())
{
try
{
stream.Dispose();
}
catch
{
// do nothing
//
}
}
base.Dispose(disposing);
}
private static byte MungeByte(int byteOrEof)
{
if (byteOrEof == -1)
{
throw new EndOfStreamException();
}
return (byte)(byteOrEof ^ 42);
}
private static (string fileHash, long fileLengthBytes) ReadBlockFileHeader(FileStream fileBlockStream)
{
List<char> hashName = [];
List<char> fileLength = [];
var separator = (char)MungeByte(fileBlockStream.ReadByte());
if (separator != '#') throw new InvalidDataException("Data is invalid, first char is not #");
bool readHash = false;
while (true)
{
int readByte = fileBlockStream.ReadByte();
if (readByte == -1)
throw new EndOfStreamException();
var readChar = (char)MungeByte(readByte);
if (readChar == ':')
{
readHash = true;
continue;
}
if (readChar == '#') break;
if (!readHash) hashName.Add(readChar);
else fileLength.Add(readChar);
}
return (string.Join("", hashName), long.Parse(string.Join("", fileLength)));
}
private async Task DownloadAndMungeFileHttpClient(string downloadGroup, Guid requestId, List<DownloadFileTransfer> fileTransfer, string tempPath, IProgress<long> progress, CancellationToken ct)
{
Logger.LogDebug("GUID {requestId} on server {uri} for files {files}", requestId, fileTransfer[0].DownloadUri, string.Join(", ", fileTransfer.Select(c => c.Hash).ToList()));
await WaitForDownloadReady(fileTransfer, requestId, ct).ConfigureAwait(false);
_downloadStatus[downloadGroup].DownloadStatus = DownloadStatus.Downloading;
HttpResponseMessage response = null!;
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
Logger.LogDebug("Downloading {requestUrl} for request {id}", requestUrl, requestId);
try
{
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex)
{
Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode);
if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized)
{
throw new InvalidDataException($"Http error {ex.StatusCode} (cancelled: {ct.IsCancellationRequested}): {requestUrl}", ex);
}
}
ThrottledStream? stream = null;
try
{
var fileStream = File.Create(tempPath);
await using (fileStream.ConfigureAwait(false))
{
var bufferSize = response.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196;
var buffer = new byte[bufferSize];
var bytesRead = 0;
var limit = _orchestrator.DownloadLimitPerSlot();
Logger.LogTrace("Starting Download of {id} with a speed limit of {limit} to {tempPath}", requestId, limit, tempPath);
stream = new ThrottledStream(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit);
_activeDownloadStreams.Add(stream);
while ((bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
{
ct.ThrowIfCancellationRequested();
MungeBuffer(buffer.AsSpan(0, bytesRead));
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false);
progress.Report(bytesRead);
}
Logger.LogDebug("{requestUrl} downloaded to {tempPath}", requestUrl, tempPath);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
try
{
if (!tempPath.IsNullOrEmpty())
File.Delete(tempPath);
}
catch
{
// ignore if file deletion fails
}
throw;
}
finally
{
if (stream != null)
{
_activeDownloadStreams.Remove(stream);
await stream.DisposeAsync().ConfigureAwait(false);
}
}
}
public async Task<List<DownloadFileTransfer>> InitiateDownloadList(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct)
{
Logger.LogDebug("Download start: {id}", gameObjectHandler.Name);
List<DownloadFileDto> downloadFileInfoFromService =
[
.. await FilesGetSizes(fileReplacement.Select(f => f.Hash).Distinct(StringComparer.Ordinal).ToList(), ct).ConfigureAwait(false),
];
Logger.LogDebug("Files with size 0 or less: {files}", string.Join(", ", downloadFileInfoFromService.Where(f => f.Size <= 0).Select(f => f.Hash)));
foreach (var dto in downloadFileInfoFromService.Where(c => c.IsForbidden))
{
if (!_orchestrator.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, dto.Hash, StringComparison.Ordinal)))
{
_orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto));
}
}
CurrentDownloads = downloadFileInfoFromService.Distinct().Select(d => new DownloadFileTransfer(d))
.Where(d => d.CanBeTransferred).ToList();
return CurrentDownloads;
}
private async Task DownloadFilesInternal(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct)
{
var downloadGroups = CurrentDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal);
foreach (var downloadGroup in downloadGroups)
{
_downloadStatus[downloadGroup.Key] = new FileDownloadStatus()
{
DownloadStatus = DownloadStatus.Initializing,
TotalBytes = downloadGroup.Sum(c => c.Total),
TotalFiles = 1,
TransferredBytes = 0,
TransferredFiles = 0
};
}
Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus));
await Parallel.ForEachAsync(downloadGroups, new ParallelOptions()
{
MaxDegreeOfParallelism = downloadGroups.Count(),
CancellationToken = ct,
},
async (fileGroup, token) =>
{
// let server predownload files
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri),
fileGroup.Select(c => c.Hash), token).ConfigureAwait(false);
Logger.LogDebug("Sent request for {n} files on server {uri} with result {result}", fileGroup.Count(), fileGroup.First().DownloadUri,
await requestIdResponse.Content.ReadAsStringAsync(token).ConfigureAwait(false));
Guid requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync().ConfigureAwait(false)).Trim('"'));
Logger.LogDebug("GUID {requestId} for {n} files on server {uri}", requestId, fileGroup.Count(), fileGroup.First().DownloadUri);
var blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk");
FileInfo fi = new(blockFile);
try
{
_downloadStatus[fileGroup.Key].DownloadStatus = DownloadStatus.WaitingForSlot;
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
_downloadStatus[fileGroup.Key].DownloadStatus = DownloadStatus.WaitingForQueue;
Progress<long> progress = new((bytesDownloaded) =>
{
try
{
if (!_downloadStatus.TryGetValue(fileGroup.Key, out FileDownloadStatus? value)) return;
value.TransferredBytes += bytesDownloaded;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not set download progress");
}
});
await DownloadAndMungeFileHttpClient(fileGroup.Key, requestId, [.. fileGroup], blockFile, progress, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, gameObjectHandler);
}
catch (Exception ex)
{
_orchestrator.ReleaseDownloadSlot();
File.Delete(blockFile);
Logger.LogError(ex, "{dlName}: Error during download of {id}", fi.Name, requestId);
ClearDownload();
return;
}
FileStream? fileBlockStream = null;
try
{
if (_downloadStatus.TryGetValue(fileGroup.Key, out var status))
{
status.TransferredFiles = 1;
status.DownloadStatus = DownloadStatus.Decompressing;
}
fileBlockStream = File.OpenRead(blockFile);
while (fileBlockStream.Position < fileBlockStream.Length)
{
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
try
{
var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1];
var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension);
Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", fi.Name, fileHash, fileLengthBytes, filePath);
byte[] compressedFileContent = new byte[fileLengthBytes];
var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false);
if (readBytes != fileLengthBytes)
{
throw new EndOfStreamException();
}
MungeBuffer(compressedFileContent);
var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent);
await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath);
}
catch (EndOfStreamException)
{
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", fi.Name, fileHash);
}
catch (Exception e)
{
Logger.LogWarning(e, "{dlName}: Error during decompression", fi.Name);
}
}
}
catch (EndOfStreamException)
{
Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", fi.Name);
}
catch (Exception ex)
{
Logger.LogError(ex, "{dlName}: Error during block file read", fi.Name);
}
finally
{
_orchestrator.ReleaseDownloadSlot();
if (fileBlockStream != null)
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
File.Delete(blockFile);
}
}).ConfigureAwait(false);
Logger.LogDebug("Download end: {id}", gameObjectHandler);
ClearDownload();
}
private async Task<List<DownloadFileDto>> FilesGetSizes(List<string> hashes, CancellationToken ct)
{
if (!_orchestrator.IsInitialized) throw new InvalidOperationException("FileTransferManager is not initialized");
var response = await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!), hashes, ct).ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<List<DownloadFileDto>>(cancellationToken: ct).ConfigureAwait(false) ?? [];
}
private void PersistFileToStorage(string fileHash, string filePath)
{
var fi = new FileInfo(filePath);
Func<DateTime> RandomDayInThePast()
{
DateTime start = new(1995, 1, 1, 1, 1, 1, DateTimeKind.Local);
Random gen = new();
int range = (DateTime.Today - start).Days;
return () => start.AddDays(gen.Next(range));
}
fi.CreationTime = RandomDayInThePast().Invoke();
fi.LastAccessTime = DateTime.Today;
fi.LastWriteTime = RandomDayInThePast().Invoke();
try
{
var entry = _fileDbManager.CreateCacheEntry(filePath);
if (entry != null && !string.Equals(entry.Hash, fileHash, StringComparison.OrdinalIgnoreCase))
{
Logger.LogError("Hash mismatch after extracting, got {hash}, expected {expectedHash}, deleting file", entry.Hash, fileHash);
File.Delete(filePath);
_fileDbManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error creating cache entry");
}
}
private async Task WaitForDownloadReady(List<DownloadFileTransfer> downloadFileTransfer, Guid requestId, CancellationToken downloadCt)
{
bool alreadyCancelled = false;
try
{
CancellationTokenSource localTimeoutCts = new();
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
CancellationTokenSource composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
while (!_orchestrator.IsDownloadReady(requestId))
{
try
{
await Task.Delay(250, composite.Token).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
if (downloadCt.IsCancellationRequested) throw;
var req = await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
downloadFileTransfer.Select(c => c.Hash).ToList(), downloadCt).ConfigureAwait(false);
req.EnsureSuccessStatusCode();
localTimeoutCts.Dispose();
composite.Dispose();
localTimeoutCts = new();
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
}
}
localTimeoutCts.Dispose();
composite.Dispose();
Logger.LogDebug("Download {requestId} ready", requestId);
}
catch (TaskCanceledException)
{
try
{
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId)).ConfigureAwait(false);
alreadyCancelled = true;
}
catch
{
// ignore whatever happens here
}
throw;
}
finally
{
if (downloadCt.IsCancellationRequested && !alreadyCancelled)
{
try
{
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId)).ConfigureAwait(false);
}
catch
{
// ignore whatever happens here
}
}
_orchestrator.ClearDownloadRequest(requestId);
}
}
}

View File

@@ -0,0 +1,178 @@
using LightlessSync.MareConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI.Files.Models;
using LightlessSync.WebAPI.SignalR;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Reflection;
namespace LightlessSync.WebAPI.Files;
public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
{
private readonly ConcurrentDictionary<Guid, bool> _downloadReady = new();
private readonly HttpClient _httpClient;
private readonly MareConfigService _mareConfig;
private readonly object _semaphoreModificationLock = new();
private readonly TokenProvider _tokenProvider;
private int _availableDownloadSlots;
private SemaphoreSlim _downloadSemaphore;
private int CurrentlyUsedDownloadSlots => _availableDownloadSlots - _downloadSemaphore.CurrentCount;
public FileTransferOrchestrator(ILogger<FileTransferOrchestrator> logger, MareConfigService mareConfig,
MareMediator mediator, TokenProvider tokenProvider, HttpClient httpClient) : base(logger, mediator)
{
_mareConfig = mareConfig;
_tokenProvider = tokenProvider;
_httpClient = httpClient;
var ver = Assembly.GetExecutingAssembly().GetName().Version;
_httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build));
_availableDownloadSlots = mareConfig.Current.ParallelDownloads;
_downloadSemaphore = new(_availableDownloadSlots, _availableDownloadSlots);
Mediator.Subscribe<ConnectedMessage>(this, (msg) =>
{
FilesCdnUri = msg.Connection.ServerInfo.FileServerAddress;
});
Mediator.Subscribe<DisconnectedMessage>(this, (msg) =>
{
FilesCdnUri = null;
});
Mediator.Subscribe<DownloadReadyMessage>(this, (msg) =>
{
_downloadReady[msg.RequestId] = true;
});
}
public Uri? FilesCdnUri { private set; get; }
public List<FileTransfer> ForbiddenTransfers { get; } = [];
public bool IsInitialized => FilesCdnUri != null;
public void ClearDownloadRequest(Guid guid)
{
_downloadReady.Remove(guid, out _);
}
public bool IsDownloadReady(Guid guid)
{
if (_downloadReady.TryGetValue(guid, out bool isReady) && isReady)
{
return true;
}
return false;
}
public void ReleaseDownloadSlot()
{
try
{
_downloadSemaphore.Release();
Mediator.Publish(new DownloadLimitChangedMessage());
}
catch (SemaphoreFullException)
{
// ignore
}
}
public async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, Uri uri,
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
{
using var requestMessage = new HttpRequestMessage(method, uri);
return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption).ConfigureAwait(false);
}
public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct) where T : class
{
using var requestMessage = new HttpRequestMessage(method, uri);
if (content is not ByteArrayContent)
requestMessage.Content = JsonContent.Create(content);
else
requestMessage.Content = content as ByteArrayContent;
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false);
}
public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content, CancellationToken ct)
{
using var requestMessage = new HttpRequestMessage(method, uri);
requestMessage.Content = content;
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false);
}
public async Task WaitForDownloadSlotAsync(CancellationToken token)
{
lock (_semaphoreModificationLock)
{
if (_availableDownloadSlots != _mareConfig.Current.ParallelDownloads && _availableDownloadSlots == _downloadSemaphore.CurrentCount)
{
_availableDownloadSlots = _mareConfig.Current.ParallelDownloads;
_downloadSemaphore = new(_availableDownloadSlots, _availableDownloadSlots);
}
}
await _downloadSemaphore.WaitAsync(token).ConfigureAwait(false);
Mediator.Publish(new DownloadLimitChangedMessage());
}
public long DownloadLimitPerSlot()
{
var limit = _mareConfig.Current.DownloadSpeedLimitInBytes;
if (limit <= 0) return 0;
limit = _mareConfig.Current.DownloadSpeedType switch
{
MareConfiguration.Models.DownloadSpeeds.Bps => limit,
MareConfiguration.Models.DownloadSpeeds.KBps => limit * 1024,
MareConfiguration.Models.DownloadSpeeds.MBps => limit * 1024 * 1024,
_ => limit,
};
var currentUsedDlSlots = CurrentlyUsedDownloadSlots;
var avaialble = _availableDownloadSlots;
var currentCount = _downloadSemaphore.CurrentCount;
var dividedLimit = limit / (currentUsedDlSlots == 0 ? 1 : currentUsedDlSlots);
if (dividedLimit < 0)
{
Logger.LogWarning("Calculated Bandwidth Limit is negative, returning Infinity: {value}, CurrentlyUsedDownloadSlots is {currentSlots}, " +
"DownloadSpeedLimit is {limit}, available slots: {avail}, current count: {count}", dividedLimit, currentUsedDlSlots, limit, avaialble, currentCount);
return long.MaxValue;
}
return Math.Clamp(dividedLimit, 1, long.MaxValue);
}
private async Task<HttpResponseMessage> SendRequestInternalAsync(HttpRequestMessage requestMessage,
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
{
var token = await _tokenProvider.GetToken().ConfigureAwait(false);
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent)
{
var content = await ((JsonContent)requestMessage.Content).ReadAsStringAsync().ConfigureAwait(false);
Logger.LogDebug("Sending {method} to {uri} (Content: {content})", requestMessage.Method, requestMessage.RequestUri, content);
}
else
{
Logger.LogDebug("Sending {method} to {uri}", requestMessage.Method, requestMessage.RequestUri);
}
try
{
if (ct != null)
return await _httpClient.SendAsync(requestMessage, httpCompletionOption, ct.Value).ConfigureAwait(false);
return await _httpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
throw;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error during SendRequestInternal for {uri}", requestMessage.RequestUri);
throw;
}
}
}

View File

@@ -0,0 +1,296 @@
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Files;
using LightlessSync.API.Routes;
using LightlessSync.FileCache;
using LightlessSync.MareConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI;
using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Logging;
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace LightlessSync.WebAPI.Files;
public sealed class FileUploadManager : DisposableMediatorSubscriberBase
{
private readonly FileCacheManager _fileDbManager;
private readonly MareConfigService _mareConfigService;
private readonly FileTransferOrchestrator _orchestrator;
private readonly ServerConfigurationManager _serverManager;
private readonly Dictionary<string, DateTime> _verifiedUploadedHashes = new(StringComparer.Ordinal);
private CancellationTokenSource? _uploadCancellationTokenSource = new();
public FileUploadManager(ILogger<FileUploadManager> logger, MareMediator mediator,
MareConfigService mareConfigService,
FileTransferOrchestrator orchestrator,
FileCacheManager fileDbManager,
ServerConfigurationManager serverManager) : base(logger, mediator)
{
_mareConfigService = mareConfigService;
_orchestrator = orchestrator;
_fileDbManager = fileDbManager;
_serverManager = serverManager;
Mediator.Subscribe<DisconnectedMessage>(this, (msg) =>
{
Reset();
});
}
public List<FileTransfer> CurrentUploads { get; } = [];
public bool IsUploading => CurrentUploads.Count > 0;
public bool CancelUpload()
{
if (CurrentUploads.Any())
{
Logger.LogDebug("Cancelling current upload");
_uploadCancellationTokenSource?.Cancel();
_uploadCancellationTokenSource?.Dispose();
_uploadCancellationTokenSource = null;
CurrentUploads.Clear();
return true;
}
return false;
}
public async Task DeleteAllFiles()
{
if (!_orchestrator.IsInitialized) throw new InvalidOperationException("FileTransferManager is not initialized");
await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.ServerFilesDeleteAllFullPath(_orchestrator.FilesCdnUri!)).ConfigureAwait(false);
}
public async Task<List<string>> UploadFiles(List<string> hashesToUpload, IProgress<string> progress, CancellationToken? ct = null)
{
Logger.LogDebug("Trying to upload files");
var filesPresentLocally = hashesToUpload.Where(h => _fileDbManager.GetFileCacheByHash(h) != null).ToHashSet(StringComparer.Ordinal);
var locallyMissingFiles = hashesToUpload.Except(filesPresentLocally, StringComparer.Ordinal).ToList();
if (locallyMissingFiles.Any())
{
return locallyMissingFiles;
}
progress.Report($"Starting upload for {filesPresentLocally.Count} files");
var filesToUpload = await FilesSend([.. filesPresentLocally], [], ct ?? CancellationToken.None).ConfigureAwait(false);
if (filesToUpload.Exists(f => f.IsForbidden))
{
return [.. filesToUpload.Where(f => f.IsForbidden).Select(f => f.Hash)];
}
Task uploadTask = Task.CompletedTask;
int i = 1;
foreach (var file in filesToUpload)
{
progress.Report($"Uploading file {i++}/{filesToUpload.Count}. Please wait until the upload is completed.");
Logger.LogDebug("[{hash}] Compressing", file);
var data = await _fileDbManager.GetCompressedFileData(file.Hash, ct ?? CancellationToken.None).ConfigureAwait(false);
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, _fileDbManager.GetFileCacheByHash(data.Item1)!.ResolvedFilepath);
await uploadTask.ConfigureAwait(false);
uploadTask = UploadFile(data.Item2, file.Hash, false, ct ?? CancellationToken.None);
(ct ?? CancellationToken.None).ThrowIfCancellationRequested();
}
await uploadTask.ConfigureAwait(false);
return [];
}
public async Task<CharacterData> UploadFiles(CharacterData data, List<UserData> visiblePlayers)
{
CancelUpload();
_uploadCancellationTokenSource = new CancellationTokenSource();
var uploadToken = _uploadCancellationTokenSource.Token;
Logger.LogDebug("Sending Character data {hash} to service {url}", data.DataHash.Value, _serverManager.CurrentApiUrl);
HashSet<string> unverifiedUploads = GetUnverifiedFiles(data);
if (unverifiedUploads.Any())
{
await UploadUnverifiedFiles(unverifiedUploads, visiblePlayers, uploadToken).ConfigureAwait(false);
Logger.LogInformation("Upload complete for {hash}", data.DataHash.Value);
}
foreach (var kvp in data.FileReplacements)
{
data.FileReplacements[kvp.Key].RemoveAll(i => _orchestrator.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, i.Hash, StringComparison.OrdinalIgnoreCase)));
}
return data;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
Reset();
}
private async Task<List<UploadFileDto>> FilesSend(List<string> hashes, List<string> uids, CancellationToken ct)
{
if (!_orchestrator.IsInitialized) throw new InvalidOperationException("FileTransferManager is not initialized");
FilesSendDto filesSendDto = new()
{
FileHashes = hashes,
UIDs = uids
};
var response = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.ServerFilesFilesSendFullPath(_orchestrator.FilesCdnUri!), filesSendDto, ct).ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<List<UploadFileDto>>(cancellationToken: ct).ConfigureAwait(false) ?? [];
}
private HashSet<string> GetUnverifiedFiles(CharacterData data)
{
HashSet<string> unverifiedUploadHashes = new(StringComparer.Ordinal);
foreach (var item in data.FileReplacements.SelectMany(c => c.Value.Where(f => string.IsNullOrEmpty(f.FileSwapPath)).Select(v => v.Hash).Distinct(StringComparer.Ordinal)).Distinct(StringComparer.Ordinal).ToList())
{
if (!_verifiedUploadedHashes.TryGetValue(item, out var verifiedTime))
{
verifiedTime = DateTime.MinValue;
}
if (verifiedTime < DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(10)))
{
Logger.LogTrace("Verifying {item}, last verified: {date}", item, verifiedTime);
unverifiedUploadHashes.Add(item);
}
}
return unverifiedUploadHashes;
}
private void Reset()
{
_uploadCancellationTokenSource?.Cancel();
_uploadCancellationTokenSource?.Dispose();
_uploadCancellationTokenSource = null;
CurrentUploads.Clear();
_verifiedUploadedHashes.Clear();
}
private async Task UploadFile(byte[] compressedFile, string fileHash, bool postProgress, CancellationToken uploadToken)
{
if (!_orchestrator.IsInitialized) throw new InvalidOperationException("FileTransferManager is not initialized");
Logger.LogInformation("[{hash}] Uploading {size}", fileHash, UiSharedService.ByteToString(compressedFile.Length));
if (uploadToken.IsCancellationRequested) return;
try
{
await UploadFileStream(compressedFile, fileHash, _mareConfigService.Current.UseAlternativeFileUpload, postProgress, uploadToken).ConfigureAwait(false);
_verifiedUploadedHashes[fileHash] = DateTime.UtcNow;
}
catch (Exception ex)
{
if (!_mareConfigService.Current.UseAlternativeFileUpload && ex is not OperationCanceledException)
{
Logger.LogWarning(ex, "[{hash}] Error during file upload, trying alternative file upload", fileHash);
await UploadFileStream(compressedFile, fileHash, munged: true, postProgress, uploadToken).ConfigureAwait(false);
}
else
{
Logger.LogWarning(ex, "[{hash}] File upload cancelled", fileHash);
}
}
}
private async Task UploadFileStream(byte[] compressedFile, string fileHash, bool munged, bool postProgress, CancellationToken uploadToken)
{
if (munged)
{
FileDownloadManager.MungeBuffer(compressedFile.AsSpan());
}
using var ms = new MemoryStream(compressedFile);
Progress<UploadProgress>? prog = !postProgress ? null : new((prog) =>
{
try
{
CurrentUploads.Single(f => string.Equals(f.Hash, fileHash, StringComparison.Ordinal)).Transferred = prog.Uploaded;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "[{hash}] Could not set upload progress", fileHash);
}
});
var streamContent = new ProgressableStreamContent(ms, prog);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
HttpResponseMessage response;
if (!munged)
response = await _orchestrator.SendRequestStreamAsync(HttpMethod.Post, LightlessFiles.ServerFilesUploadFullPath(_orchestrator.FilesCdnUri!, fileHash), streamContent, uploadToken).ConfigureAwait(false);
else
response = await _orchestrator.SendRequestStreamAsync(HttpMethod.Post, LightlessFiles.ServerFilesUploadMunged(_orchestrator.FilesCdnUri!, fileHash), streamContent, uploadToken).ConfigureAwait(false);
Logger.LogDebug("[{hash}] Upload Status: {status}", fileHash, response.StatusCode);
}
private async Task UploadUnverifiedFiles(HashSet<string> unverifiedUploadHashes, List<UserData> visiblePlayers, CancellationToken uploadToken)
{
unverifiedUploadHashes = unverifiedUploadHashes.Where(h => _fileDbManager.GetFileCacheByHash(h) != null).ToHashSet(StringComparer.Ordinal);
Logger.LogDebug("Verifying {count} files", unverifiedUploadHashes.Count);
var filesToUpload = await FilesSend([.. unverifiedUploadHashes], visiblePlayers.Select(p => p.UID).ToList(), uploadToken).ConfigureAwait(false);
foreach (var file in filesToUpload.Where(f => !f.IsForbidden).DistinctBy(f => f.Hash))
{
try
{
CurrentUploads.Add(new UploadFileTransfer(file)
{
Total = new FileInfo(_fileDbManager.GetFileCacheByHash(file.Hash)!.ResolvedFilepath).Length,
});
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Tried to request file {hash} but file was not present", file.Hash);
}
}
foreach (var file in filesToUpload.Where(c => c.IsForbidden))
{
if (_orchestrator.ForbiddenTransfers.TrueForAll(f => !string.Equals(f.Hash, file.Hash, StringComparison.Ordinal)))
{
_orchestrator.ForbiddenTransfers.Add(new UploadFileTransfer(file)
{
LocalFile = _fileDbManager.GetFileCacheByHash(file.Hash)?.ResolvedFilepath ?? string.Empty,
});
}
_verifiedUploadedHashes[file.Hash] = DateTime.UtcNow;
}
var totalSize = CurrentUploads.Sum(c => c.Total);
Logger.LogDebug("Compressing and uploading files");
Task uploadTask = Task.CompletedTask;
foreach (var file in CurrentUploads.Where(f => f.CanBeTransferred && !f.IsTransferred).ToList())
{
Logger.LogDebug("[{hash}] Compressing", file);
var data = await _fileDbManager.GetCompressedFileData(file.Hash, uploadToken).ConfigureAwait(false);
CurrentUploads.Single(e => string.Equals(e.Hash, data.Item1, StringComparison.Ordinal)).Total = data.Item2.Length;
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, _fileDbManager.GetFileCacheByHash(data.Item1)!.ResolvedFilepath);
await uploadTask.ConfigureAwait(false);
uploadTask = UploadFile(data.Item2, file.Hash, true, uploadToken);
uploadToken.ThrowIfCancellationRequested();
}
if (CurrentUploads.Any())
{
await uploadTask.ConfigureAwait(false);
var compressedSize = CurrentUploads.Sum(c => c.Total);
Logger.LogDebug("Upload complete, compressed {size} to {compressed}", UiSharedService.ByteToString(totalSize), UiSharedService.ByteToString(compressedSize));
}
foreach (var file in unverifiedUploadHashes.Where(c => !CurrentUploads.Exists(u => string.Equals(u.Hash, c, StringComparison.Ordinal))))
{
_verifiedUploadedHashes[file] = DateTime.UtcNow;
}
CurrentUploads.Clear();
}
}

View File

@@ -0,0 +1,24 @@
using LightlessSync.API.Dto.Files;
namespace LightlessSync.WebAPI.Files.Models;
public class DownloadFileTransfer : FileTransfer
{
public DownloadFileTransfer(DownloadFileDto dto) : base(dto)
{
}
public override bool CanBeTransferred => Dto.FileExists && !Dto.IsForbidden && Dto.Size > 0;
public Uri DownloadUri => new(Dto.Url);
public override long Total
{
set
{
// nothing to set
}
get => Dto.Size;
}
public long TotalRaw => Dto.RawSize;
private DownloadFileDto Dto => (DownloadFileDto)TransferDto;
}

View File

@@ -0,0 +1,10 @@
namespace LightlessSync.WebAPI.Files.Models;
public enum DownloadStatus
{
Initializing,
WaitingForSlot,
WaitingForQueue,
Downloading,
Decompressing
}

View File

@@ -0,0 +1,10 @@
namespace LightlessSync.WebAPI.Files.Models;
public class FileDownloadStatus
{
public DownloadStatus DownloadStatus { get; set; }
public long TotalBytes { get; set; }
public int TotalFiles { get; set; }
public long TransferredBytes { get; set; }
public int TransferredFiles { get; set; }
}

View File

@@ -0,0 +1,27 @@
using LightlessSync.API.Dto.Files;
namespace LightlessSync.WebAPI.Files.Models;
public abstract class FileTransfer
{
protected readonly ITransferFileDto TransferDto;
protected FileTransfer(ITransferFileDto transferDto)
{
TransferDto = transferDto;
}
public virtual bool CanBeTransferred => !TransferDto.IsForbidden && (TransferDto is not DownloadFileDto dto || dto.FileExists);
public string ForbiddenBy => TransferDto.ForbiddenBy;
public string Hash => TransferDto.Hash;
public bool IsForbidden => TransferDto.IsForbidden;
public bool IsInTransfer => Transferred != Total && Transferred > 0;
public bool IsTransferred => Transferred == Total;
public abstract long Total { get; set; }
public long Transferred { get; set; } = 0;
public override string ToString()
{
return Hash;
}
}

View File

@@ -0,0 +1,93 @@
using System.Net;
namespace LightlessSync.WebAPI.Files.Models;
public class ProgressableStreamContent : StreamContent
{
private const int _defaultBufferSize = 4096;
private readonly int _bufferSize;
private readonly IProgress<UploadProgress>? _progress;
private readonly Stream _streamToWrite;
private bool _contentConsumed;
public ProgressableStreamContent(Stream streamToWrite, IProgress<UploadProgress>? downloader)
: this(streamToWrite, _defaultBufferSize, downloader)
{
}
public ProgressableStreamContent(Stream streamToWrite, int bufferSize, IProgress<UploadProgress>? progress)
: base(streamToWrite, bufferSize)
{
if (streamToWrite == null)
{
throw new ArgumentNullException(nameof(streamToWrite));
}
if (bufferSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(bufferSize));
}
_streamToWrite = streamToWrite;
_bufferSize = bufferSize;
_progress = progress;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_streamToWrite.Dispose();
}
base.Dispose(disposing);
}
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
{
PrepareContent();
var buffer = new byte[_bufferSize];
var size = _streamToWrite.Length;
var uploaded = 0;
using (_streamToWrite)
{
while (true)
{
var length = await _streamToWrite.ReadAsync(buffer).ConfigureAwait(false);
if (length <= 0)
{
break;
}
uploaded += length;
_progress?.Report(new UploadProgress(uploaded, size));
await stream.WriteAsync(buffer.AsMemory(0, length)).ConfigureAwait(false);
}
}
}
protected override bool TryComputeLength(out long length)
{
length = _streamToWrite.Length;
return true;
}
private void PrepareContent()
{
if (_contentConsumed)
{
if (_streamToWrite.CanSeek)
{
_streamToWrite.Position = 0;
}
else
{
throw new InvalidOperationException("The stream has already been read.");
}
}
_contentConsumed = true;
}
}

View File

@@ -0,0 +1,13 @@
using LightlessSync.API.Dto.Files;
namespace LightlessSync.WebAPI.Files.Models;
public class UploadFileTransfer : FileTransfer
{
public UploadFileTransfer(UploadFileDto dto) : base(dto)
{
}
public string LocalFile { get; set; } = string.Empty;
public override long Total { get; set; }
}

View File

@@ -0,0 +1,3 @@
namespace LightlessSync.WebAPI.Files.Models;
public record UploadProgress(long Uploaded, long Size);

View File

@@ -0,0 +1,215 @@
namespace LightlessSync.WebAPI.Files
{
/// <summary>
/// Class for streaming data with throttling support.
/// Borrowed from https://github.com/bezzad/Downloader
/// </summary>
internal class ThrottledStream : Stream
{
public static long Infinite => long.MaxValue;
private readonly Stream _baseStream;
private long _bandwidthLimit;
private Bandwidth _bandwidth;
private CancellationTokenSource _bandwidthChangeTokenSource = new CancellationTokenSource();
/// <summary>
/// Initializes a new instance of the <see cref="T:ThrottledStream" /> class.
/// </summary>
/// <param name="baseStream">The base stream.</param>
/// <param name="bandwidthLimit">The maximum bytes per second that can be transferred through the base stream.</param>
/// <exception cref="ArgumentNullException">Thrown when <see cref="baseStream" /> is a null reference.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <see cref="BandwidthLimit" /> is a negative value.</exception>
public ThrottledStream(Stream baseStream, long bandwidthLimit)
{
if (bandwidthLimit < 0)
{
throw new ArgumentOutOfRangeException(nameof(bandwidthLimit),
bandwidthLimit, "The maximum number of bytes per second can't be negative.");
}
_baseStream = baseStream ?? throw new ArgumentNullException(nameof(baseStream));
BandwidthLimit = bandwidthLimit;
}
/// <summary>
/// Bandwidth Limit (in B/s)
/// </summary>
/// <value>The maximum bytes per second.</value>
public long BandwidthLimit
{
get => _bandwidthLimit;
set
{
if (_bandwidthLimit == value) return;
_bandwidthLimit = value <= 0 ? Infinite : value;
_bandwidth ??= new Bandwidth();
_bandwidth.BandwidthLimit = _bandwidthLimit;
_bandwidthChangeTokenSource.Cancel();
_bandwidthChangeTokenSource.Dispose();
_bandwidthChangeTokenSource = new();
}
}
/// <inheritdoc />
public override bool CanRead => _baseStream.CanRead;
/// <inheritdoc />
public override bool CanSeek => _baseStream.CanSeek;
/// <inheritdoc />
public override bool CanWrite => _baseStream.CanWrite;
/// <inheritdoc />
public override long Length => _baseStream.Length;
/// <inheritdoc />
public override long Position
{
get => _baseStream.Position;
set => _baseStream.Position = value;
}
/// <inheritdoc />
public override void Flush()
{
_baseStream.Flush();
}
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin)
{
return _baseStream.Seek(offset, origin);
}
/// <inheritdoc />
public override void SetLength(long value)
{
_baseStream.SetLength(value);
}
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count)
{
Throttle(count).Wait();
return _baseStream.Read(buffer, offset, count);
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count,
CancellationToken cancellationToken)
{
await Throttle(count, cancellationToken).ConfigureAwait(false);
return await _baseStream.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count)
{
Throttle(count).Wait();
_baseStream.Write(buffer, offset, count);
}
/// <inheritdoc />
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await Throttle(count, cancellationToken).ConfigureAwait(false);
await _baseStream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false);
}
public override void Close()
{
_baseStream.Close();
base.Close();
}
private async Task Throttle(int transmissionVolume, CancellationToken token = default)
{
// Make sure the buffer isn't empty.
if (BandwidthLimit > 0 && transmissionVolume > 0)
{
// Calculate the time to sleep.
_bandwidth.CalculateSpeed(transmissionVolume);
await Sleep(_bandwidth.PopSpeedRetrieveTime(), token).ConfigureAwait(false);
}
}
private async Task Sleep(int time, CancellationToken token = default)
{
try
{
if (time > 0)
{
var bandWidthtoken = _bandwidthChangeTokenSource.Token;
var linked = CancellationTokenSource.CreateLinkedTokenSource(token, bandWidthtoken).Token;
await Task.Delay(time, linked).ConfigureAwait(false);
}
}
catch (TaskCanceledException)
{
// ignore
}
}
/// <inheritdoc />
public override string ToString()
{
return _baseStream?.ToString() ?? string.Empty;
}
private sealed class Bandwidth
{
private long _count;
private int _lastSecondCheckpoint;
private long _lastTransferredBytesCount;
private int _speedRetrieveTime;
public double Speed { get; private set; }
public double AverageSpeed { get; private set; }
public long BandwidthLimit { get; set; }
public Bandwidth()
{
BandwidthLimit = long.MaxValue;
Reset();
}
public void CalculateSpeed(long receivedBytesCount)
{
int elapsedTime = Environment.TickCount - _lastSecondCheckpoint + 1;
receivedBytesCount = Interlocked.Add(ref _lastTransferredBytesCount, receivedBytesCount);
double momentSpeed = receivedBytesCount * 1000 / (double)elapsedTime; // B/s
if (1000 < elapsedTime)
{
Speed = momentSpeed;
AverageSpeed = ((AverageSpeed * _count) + Speed) / (_count + 1);
_count++;
SecondCheckpoint();
}
if (momentSpeed >= BandwidthLimit)
{
var expectedTime = receivedBytesCount * 1000 / BandwidthLimit;
Interlocked.Add(ref _speedRetrieveTime, (int)expectedTime - elapsedTime);
}
}
public int PopSpeedRetrieveTime()
{
return Interlocked.Exchange(ref _speedRetrieveTime, 0);
}
public void Reset()
{
SecondCheckpoint();
_count = 0;
Speed = 0;
AverageSpeed = 0;
}
private void SecondCheckpoint()
{
Interlocked.Exchange(ref _lastSecondCheckpoint, Environment.TickCount);
Interlocked.Exchange(ref _lastTransferredBytesCount, 0);
}
}
}
}

View File

@@ -0,0 +1,137 @@
using LightlessSync.API.Data;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.User;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using System.Text;
namespace LightlessSync.WebAPI;
#pragma warning disable MA0040
public partial class ApiController
{
public async Task PushCharacterData(CharacterData data, List<UserData> visibleCharacters)
{
if (!IsConnected) return;
try
{
await PushCharacterDataInternal(data, [.. visibleCharacters]).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Logger.LogDebug("Upload operation was cancelled");
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error during upload of files");
}
}
public async Task UserAddPair(UserDto user)
{
if (!IsConnected) return;
await _mareHub!.SendAsync(nameof(UserAddPair), user).ConfigureAwait(false);
}
public async Task UserDelete()
{
CheckConnection();
await _mareHub!.SendAsync(nameof(UserDelete)).ConfigureAwait(false);
await CreateConnectionsAsync().ConfigureAwait(false);
}
public async Task<List<OnlineUserIdentDto>> UserGetOnlinePairs(CensusDataDto? censusDataDto)
{
return await _mareHub!.InvokeAsync<List<OnlineUserIdentDto>>(nameof(UserGetOnlinePairs), censusDataDto).ConfigureAwait(false);
}
public async Task<List<UserFullPairDto>> UserGetPairedClients()
{
return await _mareHub!.InvokeAsync<List<UserFullPairDto>>(nameof(UserGetPairedClients)).ConfigureAwait(false);
}
public async Task<UserProfileDto> UserGetProfile(UserDto dto)
{
if (!IsConnected) return new UserProfileDto(dto.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, Description: null);
return await _mareHub!.InvokeAsync<UserProfileDto>(nameof(UserGetProfile), dto).ConfigureAwait(false);
}
public async Task UserPushData(UserCharaDataMessageDto dto)
{
try
{
await _mareHub!.InvokeAsync(nameof(UserPushData), dto).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to Push character data");
}
}
public async Task SetBulkPermissions(BulkPermissionsDto dto)
{
CheckConnection();
try
{
await _mareHub!.InvokeAsync(nameof(SetBulkPermissions), dto).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to set permissions");
}
}
public async Task UserRemovePair(UserDto userDto)
{
if (!IsConnected) return;
await _mareHub!.SendAsync(nameof(UserRemovePair), userDto).ConfigureAwait(false);
}
public async Task UserSetPairPermissions(UserPermissionsDto userPermissions)
{
await SetBulkPermissions(new(new(StringComparer.Ordinal)
{
{ userPermissions.User.UID, userPermissions.Permissions }
}, new(StringComparer.Ordinal))).ConfigureAwait(false);
}
public async Task UserSetProfile(UserProfileDto userDescription)
{
if (!IsConnected) return;
await _mareHub!.InvokeAsync(nameof(UserSetProfile), userDescription).ConfigureAwait(false);
}
public async Task UserUpdateDefaultPermissions(DefaultPermissionsDto defaultPermissionsDto)
{
CheckConnection();
await _mareHub!.InvokeAsync(nameof(UserUpdateDefaultPermissions), defaultPermissionsDto).ConfigureAwait(false);
}
private async Task PushCharacterDataInternal(CharacterData character, List<UserData> visibleCharacters)
{
Logger.LogInformation("Pushing character data for {hash} to {charas}", character.DataHash.Value, string.Join(", ", visibleCharacters.Select(c => c.AliasOrUID)));
StringBuilder sb = new();
foreach (var kvp in character.FileReplacements.ToList())
{
sb.AppendLine($"FileReplacements for {kvp.Key}: {kvp.Value.Count}");
}
foreach (var item in character.GlamourerData)
{
sb.AppendLine($"GlamourerData for {item.Key}: {!string.IsNullOrEmpty(item.Value)}");
}
Logger.LogDebug("Chara data contained: {nl} {data}", Environment.NewLine, sb.ToString());
CensusDataDto? censusDto = null;
if (_serverManager.SendCensusData && _lastCensus != null)
{
var world = await _dalamudUtil.GetWorldIdAsync().ConfigureAwait(false);
censusDto = new((ushort)world, _lastCensus.RaceId, _lastCensus.TribeId, _lastCensus.Gender);
Logger.LogDebug("Attaching Census Data: {data}", censusDto);
}
await UserPushData(new(visibleCharacters, character, censusDto)).ConfigureAwait(false);
}
}
#pragma warning restore MA0040

View File

@@ -0,0 +1,400 @@
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;
using LightlessSync.MareConfiguration.Models;
using LightlessSync.Services.Mediator;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using static FFXIVClientStructs.FFXIV.Client.Game.UI.MapMarkerData.Delegates;
namespace LightlessSync.WebAPI;
public partial class ApiController
{
public Task Client_DownloadReady(Guid requestId)
{
Logger.LogDebug("Server sent {requestId} ready", requestId);
Mediator.Publish(new DownloadReadyMessage(requestId));
return Task.CompletedTask;
}
public Task Client_GroupChangePermissions(GroupPermissionDto groupPermission)
{
Logger.LogTrace("Client_GroupChangePermissions: {perm}", groupPermission);
ExecuteSafely(() => _pairManager.SetGroupPermissions(groupPermission));
return Task.CompletedTask;
}
public Task Client_GroupChangeUserPairPermissions(GroupPairUserPermissionDto dto)
{
Logger.LogDebug("Client_GroupChangeUserPairPermissions: {dto}", dto);
ExecuteSafely(() => _pairManager.UpdateGroupPairPermissions(dto));
return Task.CompletedTask;
}
public Task Client_GroupDelete(GroupDto groupDto)
{
Logger.LogTrace("Client_GroupDelete: {dto}", groupDto);
ExecuteSafely(() => _pairManager.RemoveGroup(groupDto.Group));
return Task.CompletedTask;
}
public Task Client_GroupPairChangeUserInfo(GroupPairUserInfoDto userInfo)
{
Logger.LogTrace("Client_GroupPairChangeUserInfo: {dto}", userInfo);
ExecuteSafely(() =>
{
if (string.Equals(userInfo.UID, UID, StringComparison.Ordinal)) _pairManager.SetGroupStatusInfo(userInfo);
else _pairManager.SetGroupPairStatusInfo(userInfo);
});
return Task.CompletedTask;
}
public Task Client_GroupPairJoined(GroupPairFullInfoDto groupPairInfoDto)
{
Logger.LogTrace("Client_GroupPairJoined: {dto}", groupPairInfoDto);
ExecuteSafely(() => _pairManager.AddGroupPair(groupPairInfoDto));
return Task.CompletedTask;
}
public Task Client_GroupPairLeft(GroupPairDto groupPairDto)
{
Logger.LogTrace("Client_GroupPairLeft: {dto}", groupPairDto);
ExecuteSafely(() => _pairManager.RemoveGroupPair(groupPairDto));
return Task.CompletedTask;
}
public Task Client_GroupSendFullInfo(GroupFullInfoDto groupInfo)
{
Logger.LogTrace("Client_GroupSendFullInfo: {dto}", groupInfo);
ExecuteSafely(() => _pairManager.AddGroup(groupInfo));
return Task.CompletedTask;
}
public Task Client_GroupSendInfo(GroupInfoDto groupInfo)
{
Logger.LogTrace("Client_GroupSendInfo: {dto}", groupInfo);
ExecuteSafely(() => _pairManager.SetGroupInfo(groupInfo));
return Task.CompletedTask;
}
public Task Client_ReceiveServerMessage(MessageSeverity messageSeverity, string message)
{
switch (messageSeverity)
{
case MessageSeverity.Error:
Mediator.Publish(new NotificationMessage("Warning from " + _serverManager.CurrentServer!.ServerName, message, NotificationType.Error, TimeSpan.FromSeconds(7.5)));
break;
case MessageSeverity.Warning:
Mediator.Publish(new NotificationMessage("Warning from " + _serverManager.CurrentServer!.ServerName, message, NotificationType.Warning, TimeSpan.FromSeconds(7.5)));
break;
case MessageSeverity.Information:
if (_doNotNotifyOnNextInfo)
{
_doNotNotifyOnNextInfo = false;
break;
}
Mediator.Publish(new NotificationMessage("Info from " + _serverManager.CurrentServer!.ServerName, message, NotificationType.Info, TimeSpan.FromSeconds(5)));
break;
}
return Task.CompletedTask;
}
public Task Client_UpdateSystemInfo(SystemInfoDto systemInfo)
{
SystemInfoDto = systemInfo;
return Task.CompletedTask;
}
public Task Client_UpdateUserIndividualPairStatusDto(UserIndividualPairStatusDto dto)
{
Logger.LogDebug("Client_UpdateUserIndividualPairStatusDto: {dto}", dto);
ExecuteSafely(() => _pairManager.UpdateIndividualPairStatus(dto));
return Task.CompletedTask;
}
public Task Client_UserAddClientPair(UserPairDto dto)
{
Logger.LogDebug("Client_UserAddClientPair: {dto}", dto);
ExecuteSafely(() => _pairManager.AddUserPair(dto, addToLastAddedUser: true));
return Task.CompletedTask;
}
public Task Client_UserReceiveCharacterData(OnlineUserCharaDataDto dataDto)
{
Logger.LogTrace("Client_UserReceiveCharacterData: {user}", dataDto.User);
ExecuteSafely(() => _pairManager.ReceiveCharaData(dataDto));
return Task.CompletedTask;
}
public Task Client_UserReceiveUploadStatus(UserDto dto)
{
Logger.LogTrace("Client_UserReceiveUploadStatus: {dto}", dto);
ExecuteSafely(() => _pairManager.ReceiveUploadStatus(dto));
return Task.CompletedTask;
}
public Task Client_UserRemoveClientPair(UserDto dto)
{
Logger.LogDebug("Client_UserRemoveClientPair: {dto}", dto);
ExecuteSafely(() => _pairManager.RemoveUserPair(dto));
return Task.CompletedTask;
}
public Task Client_UserSendOffline(UserDto dto)
{
Logger.LogDebug("Client_UserSendOffline: {dto}", dto);
ExecuteSafely(() => _pairManager.MarkPairOffline(dto.User));
return Task.CompletedTask;
}
public Task Client_UserSendOnline(OnlineUserIdentDto dto)
{
Logger.LogDebug("Client_UserSendOnline: {dto}", dto);
ExecuteSafely(() => _pairManager.MarkPairOnline(dto));
return Task.CompletedTask;
}
public Task Client_UserUpdateDefaultPermissions(DefaultPermissionsDto dto)
{
Logger.LogDebug("Client_UserUpdateDefaultPermissions: {dto}", dto);
_connectionDto!.DefaultPreferredPermissions = dto;
return Task.CompletedTask;
}
public Task Client_UserUpdateOtherPairPermissions(UserPermissionsDto dto)
{
Logger.LogDebug("Client_UserUpdateOtherPairPermissions: {dto}", dto);
ExecuteSafely(() => _pairManager.UpdatePairPermissions(dto));
return Task.CompletedTask;
}
public Task Client_UserUpdateProfile(UserDto dto)
{
Logger.LogDebug("Client_UserUpdateProfile: {dto}", dto);
ExecuteSafely(() => Mediator.Publish(new ClearProfileDataMessage(dto.User)));
return Task.CompletedTask;
}
public Task Client_UserUpdateSelfPairPermissions(UserPermissionsDto dto)
{
Logger.LogDebug("Client_UserUpdateSelfPairPermissions: {dto}", dto);
ExecuteSafely(() => _pairManager.UpdateSelfPairPermissions(dto));
return Task.CompletedTask;
}
public Task Client_GposeLobbyJoin(UserData userData)
{
Logger.LogDebug("Client_GposeLobbyJoin: {dto}", userData);
ExecuteSafely(() => Mediator.Publish(new GposeLobbyUserJoin(userData)));
return Task.CompletedTask;
}
public Task Client_GposeLobbyLeave(UserData userData)
{
Logger.LogDebug("Client_GposeLobbyLeave: {dto}", userData);
ExecuteSafely(() => Mediator.Publish(new GPoseLobbyUserLeave(userData)));
return Task.CompletedTask;
}
public Task Client_GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto)
{
Logger.LogDebug("Client_GposeLobbyPushCharacterData: {dto}", charaDownloadDto.Uploader);
ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveCharaData(charaDownloadDto)));
return Task.CompletedTask;
}
public Task Client_GposeLobbyPushPoseData(UserData userData, PoseData poseData)
{
Logger.LogDebug("Client_GposeLobbyPushPoseData: {dto}", userData);
ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceivePoseData(userData, poseData)));
return Task.CompletedTask;
}
public Task Client_GposeLobbyPushWorldData(UserData userData, WorldData worldData)
{
//Logger.LogDebug("Client_GposeLobbyPushWorldData: {dto}", userData);
ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveWorldData(userData, worldData)));
return Task.CompletedTask;
}
public void OnDownloadReady(Action<Guid> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_DownloadReady), act);
}
public void OnGroupChangePermissions(Action<GroupPermissionDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GroupChangePermissions), act);
}
public void OnGroupChangeUserPairPermissions(Action<GroupPairUserPermissionDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GroupChangeUserPairPermissions), act);
}
public void OnGroupDelete(Action<GroupDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GroupDelete), act);
}
public void OnGroupPairChangeUserInfo(Action<GroupPairUserInfoDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GroupPairChangeUserInfo), act);
}
public void OnGroupPairJoined(Action<GroupPairFullInfoDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GroupPairJoined), act);
}
public void OnGroupPairLeft(Action<GroupPairDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GroupPairLeft), act);
}
public void OnGroupSendFullInfo(Action<GroupFullInfoDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GroupSendFullInfo), act);
}
public void OnGroupSendInfo(Action<GroupInfoDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GroupSendInfo), act);
}
public void OnReceiveServerMessage(Action<MessageSeverity, string> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_ReceiveServerMessage), act);
}
public void OnUpdateSystemInfo(Action<SystemInfoDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UpdateSystemInfo), act);
}
public void OnUpdateUserIndividualPairStatusDto(Action<UserIndividualPairStatusDto> action)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UpdateUserIndividualPairStatusDto), action);
}
public void OnUserAddClientPair(Action<UserPairDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserAddClientPair), act);
}
public void OnUserDefaultPermissionUpdate(Action<DefaultPermissionsDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserUpdateDefaultPermissions), act);
}
public void OnUserReceiveCharacterData(Action<OnlineUserCharaDataDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserReceiveCharacterData), act);
}
public void OnUserReceiveUploadStatus(Action<UserDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserReceiveUploadStatus), act);
}
public void OnUserRemoveClientPair(Action<UserDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserRemoveClientPair), act);
}
public void OnUserSendOffline(Action<UserDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserSendOffline), act);
}
public void OnUserSendOnline(Action<OnlineUserIdentDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserSendOnline), act);
}
public void OnUserUpdateOtherPairPermissions(Action<UserPermissionsDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserUpdateOtherPairPermissions), act);
}
public void OnUserUpdateProfile(Action<UserDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserUpdateProfile), act);
}
public void OnUserUpdateSelfPairPermissions(Action<UserPermissionsDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_UserUpdateSelfPairPermissions), act);
}
public void OnGposeLobbyJoin(Action<UserData> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GposeLobbyJoin), act);
}
public void OnGposeLobbyLeave(Action<UserData> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GposeLobbyLeave), act);
}
public void OnGposeLobbyPushCharacterData(Action<CharaDataDownloadDto> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GposeLobbyPushCharacterData), act);
}
public void OnGposeLobbyPushPoseData(Action<UserData, PoseData> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GposeLobbyPushPoseData), act);
}
public void OnGposeLobbyPushWorldData(Action<UserData, WorldData> act)
{
if (_initialized) return;
_mareHub!.On(nameof(Client_GposeLobbyPushWorldData), act);
}
private void ExecuteSafely(Action act)
{
try
{
act();
}
catch (Exception ex)
{
Logger.LogCritical(ex, "Error on executing safely");
}
}
}

View File

@@ -0,0 +1,229 @@
using LightlessSync.API.Data;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.Services.CharaData.Models;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
namespace LightlessSync.WebAPI;
public partial class ApiController
{
public async Task<CharaDataFullDto?> CharaDataCreate()
{
if (!IsConnected) return null;
try
{
Logger.LogDebug("Creating new Character Data");
return await _mareHub!.InvokeAsync<CharaDataFullDto?>(nameof(CharaDataCreate)).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to create new character data");
return null;
}
}
public async Task<CharaDataFullDto?> CharaDataUpdate(CharaDataUpdateDto updateDto)
{
if (!IsConnected) return null;
try
{
Logger.LogDebug("Updating chara data for {id}", updateDto.Id);
return await _mareHub!.InvokeAsync<CharaDataFullDto?>(nameof(CharaDataUpdate), updateDto).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to update chara data for {id}", updateDto.Id);
return null;
}
}
public async Task<bool> CharaDataDelete(string id)
{
if (!IsConnected) return false;
try
{
Logger.LogDebug("Deleting chara data for {id}", id);
return await _mareHub!.InvokeAsync<bool>(nameof(CharaDataDelete), id).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to delete chara data for {id}", id);
return false;
}
}
public async Task<CharaDataMetaInfoDto?> CharaDataGetMetainfo(string id)
{
if (!IsConnected) return null;
try
{
Logger.LogDebug("Getting metainfo for chara data {id}", id);
return await _mareHub!.InvokeAsync<CharaDataMetaInfoDto?>(nameof(CharaDataGetMetainfo), id).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to get meta info for chara data {id}", id);
return null;
}
}
public async Task<CharaDataFullDto?> CharaDataAttemptRestore(string id)
{
if (!IsConnected) return null;
try
{
Logger.LogDebug("Attempting to restore chara data {id}", id);
return await _mareHub!.InvokeAsync<CharaDataFullDto?>(nameof(CharaDataAttemptRestore), id).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to restore chara data for {id}", id);
return null;
}
}
public async Task<List<CharaDataFullDto>> CharaDataGetOwn()
{
if (!IsConnected) return [];
try
{
Logger.LogDebug("Getting all own chara data");
return await _mareHub!.InvokeAsync<List<CharaDataFullDto>>(nameof(CharaDataGetOwn)).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to get own chara data");
return [];
}
}
public async Task<List<CharaDataMetaInfoDto>> CharaDataGetShared()
{
if (!IsConnected) return [];
try
{
Logger.LogDebug("Getting all own chara data");
return await _mareHub!.InvokeAsync<List<CharaDataMetaInfoDto>>(nameof(CharaDataGetShared)).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to get shared chara data");
return [];
}
}
public async Task<CharaDataDownloadDto?> CharaDataDownload(string id)
{
if (!IsConnected) return null;
try
{
Logger.LogDebug("Getting download chara data for {id}", id);
return await _mareHub!.InvokeAsync<CharaDataDownloadDto>(nameof(CharaDataDownload), id).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to get download chara data for {id}", id);
return null;
}
}
public async Task<string> GposeLobbyCreate()
{
if (!IsConnected) return string.Empty;
try
{
Logger.LogDebug("Creating GPose Lobby");
return await _mareHub!.InvokeAsync<string>(nameof(GposeLobbyCreate)).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to create GPose lobby");
return string.Empty;
}
}
public async Task<bool> GposeLobbyLeave()
{
if (!IsConnected) return true;
try
{
Logger.LogDebug("Leaving current GPose Lobby");
return await _mareHub!.InvokeAsync<bool>(nameof(GposeLobbyLeave)).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to leave GPose lobby");
return false;
}
}
public async Task<List<UserData>> GposeLobbyJoin(string lobbyId)
{
if (!IsConnected) return [];
try
{
Logger.LogDebug("Joining GPose Lobby {id}", lobbyId);
return await _mareHub!.InvokeAsync<List<UserData>>(nameof(GposeLobbyJoin), lobbyId).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to join GPose lobby {id}", lobbyId);
return [];
}
}
public async Task GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto)
{
if (!IsConnected) return;
try
{
Logger.LogDebug("Sending Chara Data to GPose Lobby");
await _mareHub!.InvokeAsync(nameof(GposeLobbyPushCharacterData), charaDownloadDto).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to send Chara Data to GPose lobby");
}
}
public async Task GposeLobbyPushPoseData(PoseData poseData)
{
if (!IsConnected) return;
try
{
Logger.LogDebug("Sending Pose Data to GPose Lobby");
await _mareHub!.InvokeAsync(nameof(GposeLobbyPushPoseData), poseData).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to send Pose Data to GPose lobby");
}
}
public async Task GposeLobbyPushWorldData(WorldData worldData)
{
if (!IsConnected) return;
try
{
await _mareHub!.InvokeAsync(nameof(GposeLobbyPushWorldData), worldData).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to send World Data to GPose lobby");
}
}
}

View File

@@ -0,0 +1,124 @@
using LightlessSync.API.Dto.Group;
using LightlessSync.WebAPI.SignalR.Utils;
using Microsoft.AspNetCore.SignalR.Client;
namespace LightlessSync.WebAPI;
public partial class ApiController
{
public async Task GroupBanUser(GroupPairDto dto, string reason)
{
CheckConnection();
await _mareHub!.SendAsync(nameof(GroupBanUser), dto, reason).ConfigureAwait(false);
}
public async Task GroupChangeGroupPermissionState(GroupPermissionDto dto)
{
CheckConnection();
await _mareHub!.SendAsync(nameof(GroupChangeGroupPermissionState), dto).ConfigureAwait(false);
}
public async Task GroupChangeIndividualPermissionState(GroupPairUserPermissionDto dto)
{
CheckConnection();
await SetBulkPermissions(new(new(StringComparer.Ordinal),
new(StringComparer.Ordinal) {
{ dto.Group.GID, dto.GroupPairPermissions }
})).ConfigureAwait(false);
}
public async Task GroupChangeOwnership(GroupPairDto groupPair)
{
CheckConnection();
await _mareHub!.SendAsync(nameof(GroupChangeOwnership), groupPair).ConfigureAwait(false);
}
public async Task<bool> GroupChangePassword(GroupPasswordDto groupPassword)
{
CheckConnection();
return await _mareHub!.InvokeAsync<bool>(nameof(GroupChangePassword), groupPassword).ConfigureAwait(false);
}
public async Task GroupClear(GroupDto group)
{
CheckConnection();
await _mareHub!.SendAsync(nameof(GroupClear), group).ConfigureAwait(false);
}
public async Task<GroupJoinDto> GroupCreate()
{
CheckConnection();
return await _mareHub!.InvokeAsync<GroupJoinDto>(nameof(GroupCreate)).ConfigureAwait(false);
}
public async Task<List<string>> GroupCreateTempInvite(GroupDto group, int amount)
{
CheckConnection();
return await _mareHub!.InvokeAsync<List<string>>(nameof(GroupCreateTempInvite), group, amount).ConfigureAwait(false);
}
public async Task GroupDelete(GroupDto group)
{
CheckConnection();
await _mareHub!.SendAsync(nameof(GroupDelete), group).ConfigureAwait(false);
}
public async Task<List<BannedGroupUserDto>> GroupGetBannedUsers(GroupDto group)
{
CheckConnection();
return await _mareHub!.InvokeAsync<List<BannedGroupUserDto>>(nameof(GroupGetBannedUsers), group).ConfigureAwait(false);
}
public async Task<GroupJoinInfoDto> GroupJoin(GroupPasswordDto passwordedGroup)
{
CheckConnection();
return await _mareHub!.InvokeAsync<GroupJoinInfoDto>(nameof(GroupJoin), passwordedGroup).ConfigureAwait(false);
}
public async Task<bool> GroupJoinFinalize(GroupJoinDto passwordedGroup)
{
CheckConnection();
return await _mareHub!.InvokeAsync<bool>(nameof(GroupJoinFinalize), passwordedGroup).ConfigureAwait(false);
}
public async Task GroupLeave(GroupDto group)
{
CheckConnection();
await _mareHub!.SendAsync(nameof(GroupLeave), group).ConfigureAwait(false);
}
public async Task GroupRemoveUser(GroupPairDto groupPair)
{
CheckConnection();
await _mareHub!.SendAsync(nameof(GroupRemoveUser), groupPair).ConfigureAwait(false);
}
public async Task GroupSetUserInfo(GroupPairUserInfoDto groupPair)
{
CheckConnection();
await _mareHub!.SendAsync(nameof(GroupSetUserInfo), groupPair).ConfigureAwait(false);
}
public async Task<int> GroupPrune(GroupDto group, int days, bool execute)
{
CheckConnection();
return await _mareHub!.InvokeAsync<int>(nameof(GroupPrune), group, days, execute).ConfigureAwait(false);
}
public async Task<List<GroupFullInfoDto>> GroupsGetAll()
{
CheckConnection();
return await _mareHub!.InvokeAsync<List<GroupFullInfoDto>>(nameof(GroupsGetAll)).ConfigureAwait(false);
}
public async Task GroupUnbanUser(GroupPairDto groupPair)
{
CheckConnection();
await _mareHub!.SendAsync(nameof(GroupUnbanUser), groupPair).ConfigureAwait(false);
}
private void CheckConnection()
{
if (ServerState is not (ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting)) throw new InvalidDataException("Not connected");
}
}

View File

@@ -0,0 +1,596 @@
using Dalamud.Utility;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.User;
using LightlessSync.API.SignalR;
using LightlessSync.MareConfiguration;
using LightlessSync.MareConfiguration.Models;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.WebAPI.SignalR;
using LightlessSync.WebAPI.SignalR.Utils;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using System.Reflection;
namespace LightlessSync.WebAPI;
#pragma warning disable MA0040
public sealed partial class ApiController : DisposableMediatorSubscriberBase, ILightlessHubClient
{
public const string MainServer = "Lunae Crescere Incipientis (Official Central Server)";
public const string MainServiceUri = "wss://LightlessSync.com";
private readonly DalamudUtilService _dalamudUtil;
private readonly HubFactory _hubFactory;
private readonly PairManager _pairManager;
private readonly ServerConfigurationManager _serverManager;
private readonly TokenProvider _tokenProvider;
private readonly MareConfigService _mareConfigService;
private CancellationTokenSource _connectionCancellationTokenSource;
private ConnectionDto? _connectionDto;
private bool _doNotNotifyOnNextInfo = false;
private CancellationTokenSource? _healthCheckTokenSource = new();
private bool _initialized;
private string? _lastUsedToken;
private HubConnection? _mareHub;
private ServerState _serverState;
private CensusUpdateMessage? _lastCensus;
public ApiController(ILogger<ApiController> logger, HubFactory hubFactory, DalamudUtilService dalamudUtil,
PairManager pairManager, ServerConfigurationManager serverManager, MareMediator mediator,
TokenProvider tokenProvider, MareConfigService mareConfigService) : base(logger, mediator)
{
_hubFactory = hubFactory;
_dalamudUtil = dalamudUtil;
_pairManager = pairManager;
_serverManager = serverManager;
_tokenProvider = tokenProvider;
_mareConfigService = mareConfigService;
_connectionCancellationTokenSource = new CancellationTokenSource();
Mediator.Subscribe<DalamudLoginMessage>(this, (_) => DalamudUtilOnLogIn());
Mediator.Subscribe<DalamudLogoutMessage>(this, (_) => DalamudUtilOnLogOut());
Mediator.Subscribe<HubClosedMessage>(this, (msg) => MareHubOnClosed(msg.Exception));
Mediator.Subscribe<HubReconnectedMessage>(this, (msg) => _ = MareHubOnReconnectedAsync());
Mediator.Subscribe<HubReconnectingMessage>(this, (msg) => MareHubOnReconnecting(msg.Exception));
Mediator.Subscribe<CyclePauseMessage>(this, (msg) => _ = CyclePauseAsync(msg.UserData));
Mediator.Subscribe<CensusUpdateMessage>(this, (msg) => _lastCensus = msg);
Mediator.Subscribe<PauseMessage>(this, (msg) => _ = PauseAsync(msg.UserData));
ServerState = ServerState.Offline;
if (_dalamudUtil.IsLoggedIn)
{
DalamudUtilOnLogIn();
}
}
public string AuthFailureMessage { get; private set; } = string.Empty;
public Version CurrentClientVersion => _connectionDto?.CurrentClientVersion ?? new Version(0, 0, 0);
public DefaultPermissionsDto? DefaultPermissions => _connectionDto?.DefaultPreferredPermissions ?? null;
public string DisplayName => _connectionDto?.User.AliasOrUID ?? string.Empty;
public bool IsConnected => ServerState == ServerState.Connected;
public bool IsCurrentVersion => (Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0, 0)) >= (_connectionDto?.CurrentClientVersion ?? new Version(0, 0, 0, 0));
public int OnlineUsers => SystemInfoDto.OnlineUsers;
public bool ServerAlive => ServerState is ServerState.Connected or ServerState.RateLimited or ServerState.Unauthorized or ServerState.Disconnected;
public ServerInfo ServerInfo => _connectionDto?.ServerInfo ?? new ServerInfo();
public ServerState ServerState
{
get => _serverState;
private set
{
Logger.LogDebug("New ServerState: {value}, prev ServerState: {_serverState}", value, _serverState);
_serverState = value;
}
}
public SystemInfoDto SystemInfoDto { get; private set; } = new();
public string UID => _connectionDto?.User.UID ?? string.Empty;
public async Task<bool> CheckClientHealth()
{
return await _mareHub!.InvokeAsync<bool>(nameof(CheckClientHealth)).ConfigureAwait(false);
}
public async Task CreateConnectionsAsync()
{
if (!_serverManager.ShownCensusPopup)
{
Mediator.Publish(new OpenCensusPopupMessage());
while (!_serverManager.ShownCensusPopup)
{
await Task.Delay(500).ConfigureAwait(false);
}
}
Logger.LogDebug("CreateConnections called");
if (_serverManager.CurrentServer?.FullPause ?? true)
{
Logger.LogInformation("Not recreating Connection, paused");
_connectionDto = null;
await StopConnectionAsync(ServerState.Disconnected).ConfigureAwait(false);
_connectionCancellationTokenSource?.Cancel();
return;
}
if (!_serverManager.CurrentServer.UseOAuth2)
{
var secretKey = _serverManager.GetSecretKey(out bool multi);
if (multi)
{
Logger.LogWarning("Multiple secret keys for current character");
_connectionDto = null;
Mediator.Publish(new NotificationMessage("Multiple Identical Characters detected", "Your Service configuration has multiple characters with the same name and world set up. Delete the duplicates in the character management to be able to connect to Mare.",
NotificationType.Error));
await StopConnectionAsync(ServerState.MultiChara).ConfigureAwait(false);
_connectionCancellationTokenSource?.Cancel();
return;
}
if (secretKey.IsNullOrEmpty())
{
Logger.LogWarning("No secret key set for current character");
_connectionDto = null;
await StopConnectionAsync(ServerState.NoSecretKey).ConfigureAwait(false);
_connectionCancellationTokenSource?.Cancel();
return;
}
}
else
{
var oauth2 = _serverManager.GetOAuth2(out bool multi);
if (multi)
{
Logger.LogWarning("Multiple secret keys for current character");
_connectionDto = null;
Mediator.Publish(new NotificationMessage("Multiple Identical Characters detected", "Your Service configuration has multiple characters with the same name and world set up. Delete the duplicates in the character management to be able to connect to Mare.",
NotificationType.Error));
await StopConnectionAsync(ServerState.MultiChara).ConfigureAwait(false);
_connectionCancellationTokenSource?.Cancel();
return;
}
if (!oauth2.HasValue)
{
Logger.LogWarning("No UID/OAuth set for current character");
_connectionDto = null;
await StopConnectionAsync(ServerState.OAuthMisconfigured).ConfigureAwait(false);
_connectionCancellationTokenSource?.Cancel();
return;
}
if (!await _tokenProvider.TryUpdateOAuth2LoginTokenAsync(_serverManager.CurrentServer).ConfigureAwait(false))
{
Logger.LogWarning("OAuth2 login token could not be updated");
_connectionDto = null;
await StopConnectionAsync(ServerState.OAuthLoginTokenStale).ConfigureAwait(false);
_connectionCancellationTokenSource?.Cancel();
return;
}
}
await StopConnectionAsync(ServerState.Disconnected).ConfigureAwait(false);
Logger.LogInformation("Recreating Connection");
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(ApiController), Services.Events.EventSeverity.Informational,
$"Starting Connection to {_serverManager.CurrentServer.ServerName}")));
_connectionCancellationTokenSource?.Cancel();
_connectionCancellationTokenSource?.Dispose();
_connectionCancellationTokenSource = new CancellationTokenSource();
var token = _connectionCancellationTokenSource.Token;
while (ServerState is not ServerState.Connected && !token.IsCancellationRequested)
{
AuthFailureMessage = string.Empty;
await StopConnectionAsync(ServerState.Disconnected).ConfigureAwait(false);
ServerState = ServerState.Connecting;
try
{
Logger.LogDebug("Building connection");
try
{
_lastUsedToken = await _tokenProvider.GetOrUpdateToken(token).ConfigureAwait(false);
}
catch (LightlessAuthFailureException ex)
{
AuthFailureMessage = ex.Reason;
throw new HttpRequestException("Error during authentication", ex, System.Net.HttpStatusCode.Unauthorized);
}
while (!await _dalamudUtil.GetIsPlayerPresentAsync().ConfigureAwait(false) && !token.IsCancellationRequested)
{
Logger.LogDebug("Player not loaded in yet, waiting");
await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false);
}
if (token.IsCancellationRequested) break;
_mareHub = _hubFactory.GetOrCreate(token);
InitializeApiHooks();
await _mareHub.StartAsync(token).ConfigureAwait(false);
_connectionDto = await GetConnectionDto().ConfigureAwait(false);
ServerState = ServerState.Connected;
var currentClientVer = Assembly.GetExecutingAssembly().GetName().Version!;
if (_connectionDto.ServerVersion != ILightlessHub.ApiVersion)
{
if (_connectionDto.CurrentClientVersion > currentClientVer)
{
Mediator.Publish(new NotificationMessage("Client incompatible",
$"Your client is outdated ({currentClientVer.Major}.{currentClientVer.Minor}.{currentClientVer.Build}), current is: " +
$"{_connectionDto.CurrentClientVersion.Major}.{_connectionDto.CurrentClientVersion.Minor}.{_connectionDto.CurrentClientVersion.Build}. " +
$"This client version is incompatible and will not be able to connect. Please update your Mare Synchronos client.",
NotificationType.Error));
}
await StopConnectionAsync(ServerState.VersionMisMatch).ConfigureAwait(false);
return;
}
if (_connectionDto.CurrentClientVersion > currentClientVer)
{
Mediator.Publish(new NotificationMessage("Client outdated",
$"Your client is outdated ({currentClientVer.Major}.{currentClientVer.Minor}.{currentClientVer.Build}), current is: " +
$"{_connectionDto.CurrentClientVersion.Major}.{_connectionDto.CurrentClientVersion.Minor}.{_connectionDto.CurrentClientVersion.Build}. " +
$"Please keep your Mare Synchronos client up-to-date.",
NotificationType.Warning));
}
if (_dalamudUtil.HasModifiedGameFiles)
{
Logger.LogError("Detected modified game files on connection");
if (!_mareConfigService.Current.DebugStopWhining)
Mediator.Publish(new NotificationMessage("Modified Game Files detected",
"Dalamud is reporting your FFXIV installation has modified game files. Any mods installed through TexTools will produce this message. " +
"Mare Synchronos, Penumbra, and some other plugins assume your FFXIV installation is unmodified in order to work. " +
"Synchronization with pairs/shells can break because of this. Exit the game, open XIVLauncher, click the arrow next to Log In " +
"and select 'repair game files' to resolve this issue. Afterwards, do not install any mods with TexTools. Your plugin configurations will remain, as will mods enabled in Penumbra.",
NotificationType.Error, TimeSpan.FromSeconds(15)));
}
if (_dalamudUtil.IsLodEnabled && !_naggedAboutLod)
{
_naggedAboutLod = true;
Logger.LogWarning("Model LOD is enabled during connection");
if (!_mareConfigService.Current.DebugStopWhining)
{
Mediator.Publish(new NotificationMessage("Model LOD is enabled",
"You have \"Use low-detail models on distant objects (LOD)\" enabled. Having model LOD enabled is known to be a reason to cause " +
"random crashes when loading in or rendering modded pairs. Disabling LOD has a very low performance impact. Disable LOD while using Mare: " +
"Go to XIV Menu -> System Configuration -> Graphics Settings and disable the model LOD option.", NotificationType.Warning, TimeSpan.FromSeconds(15)));
}
}
if (_naggedAboutLod && !_dalamudUtil.IsLodEnabled)
{
_naggedAboutLod = false;
}
await LoadIninitialPairsAsync().ConfigureAwait(false);
await LoadOnlinePairsAsync().ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Logger.LogWarning("Connection attempt cancelled");
return;
}
catch (HttpRequestException ex)
{
Logger.LogWarning(ex, "HttpRequestException on Connection");
if (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
await StopConnectionAsync(ServerState.Unauthorized).ConfigureAwait(false);
return;
}
ServerState = ServerState.Reconnecting;
Logger.LogInformation("Failed to establish connection, retrying");
await Task.Delay(TimeSpan.FromSeconds(new Random().Next(5, 20)), token).ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
Logger.LogWarning(ex, "InvalidOperationException on connection");
await StopConnectionAsync(ServerState.Disconnected).ConfigureAwait(false);
return;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Exception on Connection");
Logger.LogInformation("Failed to establish connection, retrying");
await Task.Delay(TimeSpan.FromSeconds(new Random().Next(5, 20)), token).ConfigureAwait(false);
}
}
}
private bool _naggedAboutLod = false;
public Task CyclePauseAsync(UserData userData)
{
CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromSeconds(5));
_ = Task.Run(async () =>
{
var pair = _pairManager.GetOnlineUserPairs().Single(p => p.UserPair != null && p.UserData == userData);
var perm = pair.UserPair!.OwnPermissions;
perm.SetPaused(paused: true);
await UserSetPairPermissions(new UserPermissionsDto(userData, perm)).ConfigureAwait(false);
// wait until it's changed
while (pair.UserPair!.OwnPermissions != perm)
{
await Task.Delay(250, cts.Token).ConfigureAwait(false);
Logger.LogTrace("Waiting for permissions change for {data}", userData);
}
perm.SetPaused(paused: false);
await UserSetPairPermissions(new UserPermissionsDto(userData, perm)).ConfigureAwait(false);
}, cts.Token).ContinueWith((t) => cts.Dispose());
return Task.CompletedTask;
}
public async Task PauseAsync(UserData userData)
{
var pair = _pairManager.GetOnlineUserPairs().Single(p => p.UserPair != null && p.UserData == userData);
var perm = pair.UserPair!.OwnPermissions;
perm.SetPaused(paused: true);
await UserSetPairPermissions(new UserPermissionsDto(userData, perm)).ConfigureAwait(false);
}
public Task<ConnectionDto> GetConnectionDto() => GetConnectionDtoAsync(true);
public async Task<ConnectionDto> GetConnectionDtoAsync(bool publishConnected)
{
var dto = await _mareHub!.InvokeAsync<ConnectionDto>(nameof(GetConnectionDto)).ConfigureAwait(false);
if (publishConnected) Mediator.Publish(new ConnectedMessage(dto));
return dto;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_healthCheckTokenSource?.Cancel();
_ = Task.Run(async () => await StopConnectionAsync(ServerState.Disconnected).ConfigureAwait(false));
_connectionCancellationTokenSource?.Cancel();
}
private async Task ClientHealthCheckAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && _mareHub != null)
{
await Task.Delay(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
Logger.LogDebug("Checking Client Health State");
bool requireReconnect = await RefreshTokenAsync(ct).ConfigureAwait(false);
if (requireReconnect) break;
_ = await CheckClientHealth().ConfigureAwait(false);
}
}
private void DalamudUtilOnLogIn()
{
var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult();
var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult();
var auth = _serverManager.CurrentServer.Authentications.Find(f => string.Equals(f.CharacterName, charaName, StringComparison.Ordinal) && f.WorldId == worldId);
if (auth?.AutoLogin ?? false)
{
Logger.LogInformation("Logging into {chara}", charaName);
_ = Task.Run(CreateConnectionsAsync);
}
else
{
Logger.LogInformation("Not logging into {chara}, auto login disabled", charaName);
_ = Task.Run(async () => await StopConnectionAsync(ServerState.NoAutoLogon).ConfigureAwait(false));
}
}
private void DalamudUtilOnLogOut()
{
_ = Task.Run(async () => await StopConnectionAsync(ServerState.Disconnected).ConfigureAwait(false));
ServerState = ServerState.Offline;
}
private void InitializeApiHooks()
{
if (_mareHub == null) return;
Logger.LogDebug("Initializing data");
OnDownloadReady((guid) => _ = Client_DownloadReady(guid));
OnReceiveServerMessage((sev, msg) => _ = Client_ReceiveServerMessage(sev, msg));
OnUpdateSystemInfo((dto) => _ = Client_UpdateSystemInfo(dto));
OnUserSendOffline((dto) => _ = Client_UserSendOffline(dto));
OnUserAddClientPair((dto) => _ = Client_UserAddClientPair(dto));
OnUserReceiveCharacterData((dto) => _ = Client_UserReceiveCharacterData(dto));
OnUserRemoveClientPair(dto => _ = Client_UserRemoveClientPair(dto));
OnUserSendOnline(dto => _ = Client_UserSendOnline(dto));
OnUserUpdateOtherPairPermissions(dto => _ = Client_UserUpdateOtherPairPermissions(dto));
OnUserUpdateSelfPairPermissions(dto => _ = Client_UserUpdateSelfPairPermissions(dto));
OnUserReceiveUploadStatus(dto => _ = Client_UserReceiveUploadStatus(dto));
OnUserUpdateProfile(dto => _ = Client_UserUpdateProfile(dto));
OnUserDefaultPermissionUpdate(dto => _ = Client_UserUpdateDefaultPermissions(dto));
OnUpdateUserIndividualPairStatusDto(dto => _ = Client_UpdateUserIndividualPairStatusDto(dto));
OnGroupChangePermissions((dto) => _ = Client_GroupChangePermissions(dto));
OnGroupDelete((dto) => _ = Client_GroupDelete(dto));
OnGroupPairChangeUserInfo((dto) => _ = Client_GroupPairChangeUserInfo(dto));
OnGroupPairJoined((dto) => _ = Client_GroupPairJoined(dto));
OnGroupPairLeft((dto) => _ = Client_GroupPairLeft(dto));
OnGroupSendFullInfo((dto) => _ = Client_GroupSendFullInfo(dto));
OnGroupSendInfo((dto) => _ = Client_GroupSendInfo(dto));
OnGroupChangeUserPairPermissions((dto) => _ = Client_GroupChangeUserPairPermissions(dto));
OnGposeLobbyJoin((dto) => _ = Client_GposeLobbyJoin(dto));
OnGposeLobbyLeave((dto) => _ = Client_GposeLobbyLeave(dto));
OnGposeLobbyPushCharacterData((dto) => _ = Client_GposeLobbyPushCharacterData(dto));
OnGposeLobbyPushPoseData((dto, data) => _ = Client_GposeLobbyPushPoseData(dto, data));
OnGposeLobbyPushWorldData((dto, data) => _ = Client_GposeLobbyPushWorldData(dto, data));
_healthCheckTokenSource?.Cancel();
_healthCheckTokenSource?.Dispose();
_healthCheckTokenSource = new CancellationTokenSource();
_ = ClientHealthCheckAsync(_healthCheckTokenSource.Token);
_initialized = true;
}
private async Task LoadIninitialPairsAsync()
{
foreach (var entry in await GroupsGetAll().ConfigureAwait(false))
{
Logger.LogDebug("Group: {entry}", entry);
_pairManager.AddGroup(entry);
}
foreach (var userPair in await UserGetPairedClients().ConfigureAwait(false))
{
Logger.LogDebug("Individual Pair: {userPair}", userPair);
_pairManager.AddUserPair(userPair);
}
}
private async Task LoadOnlinePairsAsync()
{
CensusDataDto? dto = null;
if (_serverManager.SendCensusData && _lastCensus != null)
{
var world = await _dalamudUtil.GetWorldIdAsync().ConfigureAwait(false);
dto = new((ushort)world, _lastCensus.RaceId, _lastCensus.TribeId, _lastCensus.Gender);
Logger.LogDebug("Attaching Census Data: {data}", dto);
}
foreach (var entry in await UserGetOnlinePairs(dto).ConfigureAwait(false))
{
Logger.LogDebug("Pair online: {pair}", entry);
_pairManager.MarkPairOnline(entry, sendNotif: false);
}
}
private void MareHubOnClosed(Exception? arg)
{
_healthCheckTokenSource?.Cancel();
Mediator.Publish(new DisconnectedMessage());
ServerState = ServerState.Offline;
if (arg != null)
{
Logger.LogWarning(arg, "Connection closed");
}
else
{
Logger.LogInformation("Connection closed");
}
}
private async Task MareHubOnReconnectedAsync()
{
ServerState = ServerState.Reconnecting;
try
{
InitializeApiHooks();
_connectionDto = await GetConnectionDtoAsync(publishConnected: false).ConfigureAwait(false);
if (_connectionDto.ServerVersion != ILightlessHub.ApiVersion)
{
await StopConnectionAsync(ServerState.VersionMisMatch).ConfigureAwait(false);
return;
}
ServerState = ServerState.Connected;
await LoadIninitialPairsAsync().ConfigureAwait(false);
await LoadOnlinePairsAsync().ConfigureAwait(false);
Mediator.Publish(new ConnectedMessage(_connectionDto));
}
catch (Exception ex)
{
Logger.LogCritical(ex, "Failure to obtain data after reconnection");
await StopConnectionAsync(ServerState.Disconnected).ConfigureAwait(false);
}
}
private void MareHubOnReconnecting(Exception? arg)
{
_doNotNotifyOnNextInfo = true;
_healthCheckTokenSource?.Cancel();
ServerState = ServerState.Reconnecting;
Logger.LogWarning(arg, "Connection closed... Reconnecting");
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(ApiController), Services.Events.EventSeverity.Warning,
$"Connection interrupted, reconnecting to {_serverManager.CurrentServer.ServerName}")));
}
private async Task<bool> RefreshTokenAsync(CancellationToken ct)
{
bool requireReconnect = false;
try
{
var token = await _tokenProvider.GetOrUpdateToken(ct).ConfigureAwait(false);
if (!string.Equals(token, _lastUsedToken, StringComparison.Ordinal))
{
Logger.LogDebug("Reconnecting due to updated token");
_doNotNotifyOnNextInfo = true;
await CreateConnectionsAsync().ConfigureAwait(false);
requireReconnect = true;
}
}
catch (LightlessAuthFailureException ex)
{
AuthFailureMessage = ex.Reason;
await StopConnectionAsync(ServerState.Unauthorized).ConfigureAwait(false);
requireReconnect = true;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not refresh token, forcing reconnect");
_doNotNotifyOnNextInfo = true;
await CreateConnectionsAsync().ConfigureAwait(false);
requireReconnect = true;
}
return requireReconnect;
}
private async Task StopConnectionAsync(ServerState state)
{
ServerState = ServerState.Disconnecting;
Logger.LogInformation("Stopping existing connection");
await _hubFactory.DisposeHubAsync().ConfigureAwait(false);
if (_mareHub is not null)
{
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(ApiController), Services.Events.EventSeverity.Informational,
$"Stopping existing connection to {_serverManager.CurrentServer.ServerName}")));
_initialized = false;
_healthCheckTokenSource?.Cancel();
Mediator.Publish(new DisconnectedMessage());
_mareHub = null;
_connectionDto = null;
}
ServerState = state;
}
}
#pragma warning restore MA0040

View File

@@ -0,0 +1,141 @@
using LightlessSync.API.SignalR;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.WebAPI.SignalR.Utils;
using MessagePack;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LightlessSync.WebAPI.SignalR;
public class HubFactory : MediatorSubscriberBase
{
private readonly ILoggerProvider _loggingProvider;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly TokenProvider _tokenProvider;
private HubConnection? _instance;
private bool _isDisposed = false;
private readonly bool _isWine = false;
public HubFactory(ILogger<HubFactory> logger, MareMediator mediator,
ServerConfigurationManager serverConfigurationManager,
TokenProvider tokenProvider, ILoggerProvider pluginLog,
DalamudUtilService dalamudUtilService) : base(logger, mediator)
{
_serverConfigurationManager = serverConfigurationManager;
_tokenProvider = tokenProvider;
_loggingProvider = pluginLog;
_isWine = dalamudUtilService.IsWine;
}
public async Task DisposeHubAsync()
{
if (_instance == null || _isDisposed) return;
Logger.LogDebug("Disposing current HubConnection");
_isDisposed = true;
_instance.Closed -= HubOnClosed;
_instance.Reconnecting -= HubOnReconnecting;
_instance.Reconnected -= HubOnReconnected;
await _instance.StopAsync().ConfigureAwait(false);
await _instance.DisposeAsync().ConfigureAwait(false);
_instance = null;
Logger.LogDebug("Current HubConnection disposed");
}
public HubConnection GetOrCreate(CancellationToken ct)
{
if (!_isDisposed && _instance != null) return _instance;
return BuildHubConnection(ct);
}
private HubConnection BuildHubConnection(CancellationToken ct)
{
var transportType = _serverConfigurationManager.GetTransport() switch
{
HttpTransportType.None => HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling,
HttpTransportType.WebSockets => HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling,
HttpTransportType.ServerSentEvents => HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling,
HttpTransportType.LongPolling => HttpTransportType.LongPolling,
_ => HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling
};
if (_isWine && !_serverConfigurationManager.CurrentServer.ForceWebSockets
&& transportType.HasFlag(HttpTransportType.WebSockets))
{
Logger.LogDebug("Wine detected, falling back to ServerSentEvents / LongPolling");
transportType = HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling;
}
Logger.LogDebug("Building new HubConnection using transport {transport}", transportType);
_instance = new HubConnectionBuilder()
.WithUrl(_serverConfigurationManager.CurrentApiUrl + ILightlessHub.Path, options =>
{
options.AccessTokenProvider = () => _tokenProvider.GetOrUpdateToken(ct);
options.Transports = transportType;
})
.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);
})
.WithAutomaticReconnect(new ForeverRetryPolicy(Mediator))
.ConfigureLogging(a =>
{
a.ClearProviders().AddProvider(_loggingProvider);
a.SetMinimumLevel(LogLevel.Information);
})
.Build();
_instance.Closed += HubOnClosed;
_instance.Reconnecting += HubOnReconnecting;
_instance.Reconnected += HubOnReconnected;
_isDisposed = false;
return _instance;
}
private Task HubOnClosed(Exception? arg)
{
Mediator.Publish(new HubClosedMessage(arg));
return Task.CompletedTask;
}
private Task HubOnReconnected(string? arg)
{
Mediator.Publish(new HubReconnectedMessage(arg));
return Task.CompletedTask;
}
private Task HubOnReconnecting(Exception? arg)
{
Mediator.Publish(new HubReconnectingMessage(arg));
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,9 @@
namespace LightlessSync.WebAPI.SignalR;
public record JwtIdentifier(string ApiUrl, string CharaHash, string UID, string SecretKeyOrOAuth)
{
public override string ToString()
{
return "{JwtIdentifier; Url: " + ApiUrl + ", Chara: " + CharaHash + ", UID: " + UID + ", HasSecretKeyOrOAuth: " + !string.IsNullOrEmpty(SecretKeyOrOAuth) + "}";
}
}

View File

@@ -0,0 +1,11 @@
namespace LightlessSync.WebAPI.SignalR;
public class LightlessAuthFailureException : Exception
{
public LightlessAuthFailureException(string reason)
{
Reason = reason;
}
public string Reason { get; }
}

View File

@@ -0,0 +1,278 @@
using LightlessSync.API.Routes;
using LightlessSync.MareConfiguration.Models;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Reflection;
namespace LightlessSync.WebAPI.SignalR;
public sealed class TokenProvider : IDisposable, IMediatorSubscriber
{
private readonly DalamudUtilService _dalamudUtil;
private readonly HttpClient _httpClient;
private readonly ILogger<TokenProvider> _logger;
private readonly ServerConfigurationManager _serverManager;
private readonly ConcurrentDictionary<JwtIdentifier, string> _tokenCache = new();
public TokenProvider(ILogger<TokenProvider> logger, ServerConfigurationManager serverManager, DalamudUtilService dalamudUtil, MareMediator mareMediator, HttpClient httpClient)
{
_logger = logger;
_serverManager = serverManager;
_dalamudUtil = dalamudUtil;
var ver = Assembly.GetExecutingAssembly().GetName().Version;
Mediator = mareMediator;
_httpClient = httpClient;
Mediator.Subscribe<DalamudLogoutMessage>(this, (_) =>
{
_lastJwtIdentifier = null;
_tokenCache.Clear();
});
Mediator.Subscribe<DalamudLoginMessage>(this, (_) =>
{
_lastJwtIdentifier = null;
_tokenCache.Clear();
});
}
public MareMediator Mediator { get; }
private JwtIdentifier? _lastJwtIdentifier;
public void Dispose()
{
Mediator.UnsubscribeAll(this);
}
public async Task<string> GetNewToken(bool isRenewal, JwtIdentifier identifier, CancellationToken ct)
{
Uri tokenUri;
string response = string.Empty;
HttpResponseMessage result;
try
{
if (!isRenewal)
{
_logger.LogDebug("GetNewToken: Requesting");
if (!_serverManager.CurrentServer.UseOAuth2)
{
tokenUri = LightlessAuth.AuthFullPath(new Uri(_serverManager.CurrentApiUrl
.Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase)
.Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase)));
var secretKey = _serverManager.GetSecretKey(out _)!;
var auth = secretKey.GetHash256();
_logger.LogInformation("Sending SecretKey Request to server with auth {auth}", string.Join("", identifier.SecretKeyOrOAuth.Take(10)));
result = await _httpClient.PostAsync(tokenUri, new FormUrlEncodedContent(
[
new KeyValuePair<string, string>("auth", auth),
new KeyValuePair<string, string>("charaIdent", await _dalamudUtil.GetPlayerNameHashedAsync().ConfigureAwait(false)),
]), ct).ConfigureAwait(false);
}
else
{
tokenUri = LightlessAuth.AuthWithOauthFullPath(new Uri(_serverManager.CurrentApiUrl
.Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase)
.Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase)));
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, tokenUri.ToString());
request.Content = new FormUrlEncodedContent([
new KeyValuePair<string, string>("uid", identifier.UID),
new KeyValuePair<string, string>("charaIdent", identifier.CharaHash)
]);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", identifier.SecretKeyOrOAuth);
_logger.LogInformation("Sending OAuth Request to server with auth {auth}", string.Join("", identifier.SecretKeyOrOAuth.Take(10)));
result = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
}
}
else
{
_logger.LogDebug("GetNewToken: Renewal");
tokenUri = LightlessAuth.RenewTokenFullPath(new Uri(_serverManager.CurrentApiUrl
.Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase)
.Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase)));
HttpRequestMessage request = new(HttpMethod.Get, tokenUri.ToString());
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _tokenCache[identifier]);
result = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
}
response = await result.Content.ReadAsStringAsync().ConfigureAwait(false);
result.EnsureSuccessStatusCode();
_tokenCache[identifier] = response;
}
catch (HttpRequestException ex)
{
_tokenCache.TryRemove(identifier, out _);
_logger.LogError(ex, "GetNewToken: Failure to get token");
if (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
if (isRenewal)
Mediator.Publish(new NotificationMessage("Error refreshing token", "Your authentication token could not be renewed. Try reconnecting to Mare manually.",
NotificationType.Error));
else
Mediator.Publish(new NotificationMessage("Error generating token", "Your authentication token could not be generated. Check Mares Main UI (/mare in chat) to see the error message.",
NotificationType.Error));
Mediator.Publish(new DisconnectedMessage());
throw new LightlessAuthFailureException(response);
}
throw;
}
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(response);
_logger.LogTrace("GetNewToken: JWT {token}", response);
_logger.LogDebug("GetNewToken: Valid until {date}, ValidClaim until {date}", jwtToken.ValidTo,
new DateTime(long.Parse(jwtToken.Claims.Single(c => string.Equals(c.Type, "expiration_date", StringComparison.Ordinal)).Value), DateTimeKind.Utc));
var dateTimeMinus10 = DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(10));
var dateTimePlus10 = DateTime.UtcNow.Add(TimeSpan.FromMinutes(10));
var tokenTime = jwtToken.ValidTo.Subtract(TimeSpan.FromHours(6));
if (tokenTime <= dateTimeMinus10 || tokenTime >= dateTimePlus10)
{
_tokenCache.TryRemove(identifier, out _);
Mediator.Publish(new NotificationMessage("Invalid system clock", "The clock of your computer is invalid. " +
"Mare will not function properly if the time zone is not set correctly. " +
"Please set your computers time zone correctly and keep your clock synchronized with the internet.",
NotificationType.Error));
throw new InvalidOperationException($"JwtToken is behind DateTime.UtcNow, DateTime.UtcNow is possibly wrong. DateTime.UtcNow is {DateTime.UtcNow}, JwtToken.ValidTo is {jwtToken.ValidTo}");
}
return response;
}
private async Task<JwtIdentifier?> GetIdentifier()
{
JwtIdentifier jwtIdentifier;
try
{
var playerIdentifier = await _dalamudUtil.GetPlayerNameHashedAsync().ConfigureAwait(false);
if (string.IsNullOrEmpty(playerIdentifier))
{
_logger.LogTrace("GetIdentifier: PlayerIdentifier was null, returning last identifier {identifier}", _lastJwtIdentifier);
return _lastJwtIdentifier;
}
if (_serverManager.CurrentServer.UseOAuth2)
{
var (OAuthToken, UID) = _serverManager.GetOAuth2(out _)
?? throw new InvalidOperationException("Requested OAuth2 but received null");
jwtIdentifier = new(_serverManager.CurrentApiUrl,
playerIdentifier,
UID, OAuthToken);
}
else
{
var secretKey = _serverManager.GetSecretKey(out _)
?? throw new InvalidOperationException("Requested SecretKey but received null");
jwtIdentifier = new(_serverManager.CurrentApiUrl,
playerIdentifier,
string.Empty,
secretKey);
}
_lastJwtIdentifier = jwtIdentifier;
}
catch (Exception ex)
{
if (_lastJwtIdentifier == null)
{
_logger.LogError("GetIdentifier: No last identifier found, aborting");
return null;
}
_logger.LogWarning(ex, "GetIdentifier: Could not get JwtIdentifier for some reason or another, reusing last identifier {identifier}", _lastJwtIdentifier);
jwtIdentifier = _lastJwtIdentifier;
}
_logger.LogDebug("GetIdentifier: Using identifier {identifier}", jwtIdentifier);
return jwtIdentifier;
}
public async Task<string?> GetToken()
{
JwtIdentifier? jwtIdentifier = await GetIdentifier().ConfigureAwait(false);
if (jwtIdentifier == null) return null;
if (_tokenCache.TryGetValue(jwtIdentifier, out var token))
{
return token;
}
throw new InvalidOperationException("No token present");
}
public async Task<string?> GetOrUpdateToken(CancellationToken ct)
{
JwtIdentifier? jwtIdentifier = await GetIdentifier().ConfigureAwait(false);
if (jwtIdentifier == null) return null;
bool renewal = false;
if (_tokenCache.TryGetValue(jwtIdentifier, out var token))
{
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
if (jwt.ValidTo == DateTime.MinValue || jwt.ValidTo.Subtract(TimeSpan.FromMinutes(5)) > DateTime.UtcNow)
{
return token;
}
_logger.LogDebug("GetOrUpdate: Cached token requires renewal, token valid to: {valid}, UtcTime is {utcTime}", jwt.ValidTo, DateTime.UtcNow);
renewal = true;
}
else
{
_logger.LogDebug("GetOrUpdate: Did not find token in cache, requesting a new one");
}
_logger.LogTrace("GetOrUpdate: Getting new token");
return await GetNewToken(renewal, jwtIdentifier, ct).ConfigureAwait(false);
}
public async Task<bool> TryUpdateOAuth2LoginTokenAsync(ServerStorage currentServer, bool forced = false)
{
var oauth2 = _serverManager.GetOAuth2(out _);
if (oauth2 == null) return false;
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(oauth2.Value.OAuthToken);
if (!forced)
{
if (jwt.ValidTo == DateTime.MinValue || jwt.ValidTo.Subtract(TimeSpan.FromDays(7)) > DateTime.Now)
return true;
if (jwt.ValidTo < DateTime.UtcNow)
return false;
}
var tokenUri = LightlessAuth.RenewOAuthTokenFullPath(new Uri(currentServer.ServerUri
.Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase)
.Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase)));
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, tokenUri.ToString());
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", oauth2.Value.OAuthToken);
_logger.LogInformation("Sending Request to server with auth {auth}", string.Join("", oauth2.Value.OAuthToken.Take(10)));
var result = await _httpClient.SendAsync(request).ConfigureAwait(false);
if (!result.IsSuccessStatusCode)
{
_logger.LogWarning("Could not renew OAuth2 Login token, error code {error}", result.StatusCode);
currentServer.OAuthToken = null;
_serverManager.Save();
return false;
}
var newToken = await result.Content.ReadAsStringAsync().ConfigureAwait(false);
currentServer.OAuthToken = newToken;
_serverManager.Save();
return true;
}
}

View File

@@ -0,0 +1,39 @@
using LightlessSync.MareConfiguration.Models;
using LightlessSync.Services.Mediator;
using Microsoft.AspNetCore.SignalR.Client;
namespace LightlessSync.WebAPI.SignalR.Utils;
public class ForeverRetryPolicy : IRetryPolicy
{
private readonly MareMediator _mediator;
private bool _sentDisconnected = false;
public ForeverRetryPolicy(MareMediator mediator)
{
_mediator = mediator;
}
public TimeSpan? NextRetryDelay(RetryContext retryContext)
{
TimeSpan timeToWait = TimeSpan.FromSeconds(new Random().Next(10, 20));
if (retryContext.PreviousRetryCount == 0)
{
_sentDisconnected = false;
timeToWait = TimeSpan.FromSeconds(3);
}
else if (retryContext.PreviousRetryCount == 1) timeToWait = TimeSpan.FromSeconds(5);
else if (retryContext.PreviousRetryCount == 2) timeToWait = TimeSpan.FromSeconds(10);
else
{
if (!_sentDisconnected)
{
_mediator.Publish(new NotificationMessage("Connection lost", "Connection lost to server", NotificationType.Warning, TimeSpan.FromSeconds(10)));
_mediator.Publish(new DisconnectedMessage());
}
_sentDisconnected = true;
}
return timeToWait;
}
}

View File

@@ -0,0 +1,19 @@
namespace LightlessSync.WebAPI.SignalR.Utils;
public enum ServerState
{
Offline,
Connecting,
Reconnecting,
Disconnecting,
Disconnected,
Connected,
Unauthorized,
VersionMisMatch,
RateLimited,
NoSecretKey,
MultiChara,
OAuthMisconfigured,
OAuthLoginTokenStale,
NoAutoLogon
}