using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using MareSynchronos.API.Dto.McdfShare; using MareSynchronos.MareConfiguration.Models; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.WebAPI; using Microsoft.Extensions.Logging; namespace MareSynchronos.Services.CharaData; public sealed class McdfShareManager { private readonly ILogger _logger; private readonly ApiController _apiController; private readonly CharaDataFileHandler _fileHandler; private readonly CharaDataManager _charaDataManager; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly SemaphoreSlim _operationSemaphore = new(1, 1); private readonly List _ownShares = new(); private readonly List _sharedWithMe = new(); private Task? _currentTask; public McdfShareManager(ILogger logger, ApiController apiController, CharaDataFileHandler fileHandler, CharaDataManager charaDataManager, ServerConfigurationManager serverConfigurationManager) { _logger = logger; _apiController = apiController; _fileHandler = fileHandler; _charaDataManager = charaDataManager; _serverConfigurationManager = serverConfigurationManager; } public IReadOnlyList OwnShares => _ownShares; public IReadOnlyList SharedShares => _sharedWithMe; public bool IsBusy => _currentTask is { IsCompleted: false }; public string? LastError { get; private set; } public string? LastSuccess { get; private set; } public Task RefreshAsync(CancellationToken token) { return RunOperation(() => InternalRefreshAsync(token)); } public Task CreateShareAsync(string description, IReadOnlyList allowedIndividuals, IReadOnlyList allowedSyncshells, DateTime? expiresAtUtc, CancellationToken token) { return RunOperation(async () => { token.ThrowIfCancellationRequested(); var mcdfBytes = await _fileHandler.CreateCharaFileBytesAsync(description, token).ConfigureAwait(false); if (mcdfBytes == null || mcdfBytes.Length == 0) { LastError = "Impossible de préparer les données MCDF."; return; } var secretKey = _serverConfigurationManager.GetSecretKey(out bool hasMultiple); if (hasMultiple) { LastError = "Plusieurs clés secrètes sont configurées pour ce personnage. Corrigez cela dans les paramètres."; return; } if (string.IsNullOrEmpty(secretKey)) { LastError = "Aucune clé secrète n'est configurée pour ce personnage."; return; } var shareId = Guid.NewGuid(); byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] nonce = RandomNumberGenerator.GetBytes(12); byte[] key = DeriveKey(secretKey, shareId, salt); byte[] cipher = new byte[mcdfBytes.Length]; byte[] tag = new byte[16]; using (var aes = new AesGcm(key, 16)) { aes.Encrypt(nonce, mcdfBytes, cipher, tag); } var uploadDto = new McdfShareUploadRequestDto { ShareId = shareId, Description = description, CipherData = cipher, Nonce = nonce, Salt = salt, Tag = tag, ExpiresAtUtc = expiresAtUtc, AllowedIndividuals = allowedIndividuals.ToList(), AllowedSyncshells = allowedSyncshells.ToList() }; await _apiController.McdfShareUpload(uploadDto).ConfigureAwait(false); await InternalRefreshAsync(token).ConfigureAwait(false); LastSuccess = "Partage MCDF créé."; }); } public Task DeleteShareAsync(Guid shareId) { return RunOperation(async () => { var result = await _apiController.McdfShareDelete(shareId).ConfigureAwait(false); if (!result) { LastError = "Le serveur a refusé de supprimer le partage MCDF."; return; } _ownShares.RemoveAll(s => s.Id == shareId); _sharedWithMe.RemoveAll(s => s.Id == shareId); await InternalRefreshAsync(CancellationToken.None).ConfigureAwait(false); LastSuccess = "Partage MCDF supprimé."; }); } public Task UpdateShareAsync(McdfShareUpdateRequestDto updateRequest) { return RunOperation(async () => { var updated = await _apiController.McdfShareUpdate(updateRequest).ConfigureAwait(false); if (updated == null) { LastError = "Le serveur a refusé de mettre à jour le partage MCDF."; return; } var idx = _ownShares.FindIndex(s => s.Id == updated.Id); if (idx >= 0) { _ownShares[idx] = updated; } LastSuccess = "Partage MCDF mis à jour."; }); } public Task ApplyShareAsync(Guid shareId, CancellationToken token) { return RunOperation(async () => { token.ThrowIfCancellationRequested(); var plainBytes = await DownloadAndDecryptShareAsync(shareId, token).ConfigureAwait(false); if (plainBytes == null) { LastError ??= "Échec du téléchargement du partage MCDF."; return; } var tempPath = await _charaDataManager.LoadMcdfFromBytes(plainBytes, token).ConfigureAwait(false); try { await _charaDataManager.McdfApplyToGposeTarget().ConfigureAwait(false); } finally { try { if (File.Exists(tempPath)) { File.Delete(tempPath); } } catch { // ignored } } LastSuccess = "Partage MCDF appliqué sur la cible GPose."; }); } public Task ExportShareAsync(Guid shareId, string filePath, CancellationToken token) { return RunOperation(async () => { token.ThrowIfCancellationRequested(); var plainBytes = await DownloadAndDecryptShareAsync(shareId, token).ConfigureAwait(false); if (plainBytes == null) { LastError ??= "Échec du téléchargement du partage MCDF."; return; } var directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } await File.WriteAllBytesAsync(filePath, plainBytes, token).ConfigureAwait(false); LastSuccess = "Partage MCDF exporté."; }); } public Task DownloadShareToFileAsync(McdfShareEntryDto entry, string filePath, CancellationToken token) { return ExportShareAsync(entry.Id, filePath, token); } private async Task DownloadAndDecryptShareAsync(Guid shareId, CancellationToken token) { var payload = await _apiController.McdfShareDownload(shareId).ConfigureAwait(false); if (payload == null) { LastError = "Partage indisponible."; return null; } var secretKey = _serverConfigurationManager.GetSecretKey(out bool hasMultiple); if (hasMultiple) { LastError = "Plusieurs clés secrètes sont configurées pour ce personnage."; return null; } if (string.IsNullOrEmpty(secretKey)) { LastError = "Aucune clé secrète n'est configurée pour ce personnage."; return null; } byte[] key = DeriveKey(secretKey, payload.ShareId, payload.Salt); byte[] plaintext = new byte[payload.CipherData.Length]; try { using var aes = new AesGcm(key, 16); aes.Decrypt(payload.Nonce, payload.CipherData, payload.Tag, plaintext); } catch (CryptographicException ex) { _logger.LogWarning(ex, "Failed to decrypt MCDF share {ShareId}", shareId); LastError = "Impossible de déchiffrer le partage MCDF."; return null; } token.ThrowIfCancellationRequested(); return plaintext; } private async Task InternalRefreshAsync(CancellationToken token) { token.ThrowIfCancellationRequested(); var own = await _apiController.McdfShareGetOwn().ConfigureAwait(false); token.ThrowIfCancellationRequested(); var shared = await _apiController.McdfShareGetShared().ConfigureAwait(false); _ownShares.Clear(); _ownShares.AddRange(own); _sharedWithMe.Clear(); _sharedWithMe.AddRange(shared); LastSuccess = "Partages MCDF actualisés."; } private Task RunOperation(Func operation) { async Task Wrapper() { await _operationSemaphore.WaitAsync().ConfigureAwait(false); try { LastError = null; LastSuccess = null; await operation().ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error during MCDF share operation"); LastError = ex.Message; } finally { _operationSemaphore.Release(); } } var task = Wrapper(); _currentTask = task; return task; } private static byte[] DeriveKey(string secretKey, Guid shareId, byte[] salt) { byte[] secretBytes; try { secretBytes = Convert.FromHexString(secretKey); } catch (FormatException) { // fallback to UTF8 if not hex secretBytes = System.Text.Encoding.UTF8.GetBytes(secretKey); } byte[] shareBytes = shareId.ToByteArray(); byte[] material = new byte[secretBytes.Length + shareBytes.Length + salt.Length]; Buffer.BlockCopy(secretBytes, 0, material, 0, secretBytes.Length); Buffer.BlockCopy(shareBytes, 0, material, secretBytes.Length, shareBytes.Length); Buffer.BlockCopy(salt, 0, material, secretBytes.Length + shareBytes.Length, salt.Length); return SHA256.HashData(material); } }