310 lines
11 KiB
C#
310 lines
11 KiB
C#
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<McdfShareManager> _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<McdfShareEntryDto> _ownShares = new();
|
|
private readonly List<McdfShareEntryDto> _sharedWithMe = new();
|
|
private Task? _currentTask;
|
|
|
|
public McdfShareManager(ILogger<McdfShareManager> logger, ApiController apiController,
|
|
CharaDataFileHandler fileHandler, CharaDataManager charaDataManager,
|
|
ServerConfigurationManager serverConfigurationManager)
|
|
{
|
|
_logger = logger;
|
|
_apiController = apiController;
|
|
_fileHandler = fileHandler;
|
|
_charaDataManager = charaDataManager;
|
|
_serverConfigurationManager = serverConfigurationManager;
|
|
}
|
|
|
|
public IReadOnlyList<McdfShareEntryDto> OwnShares => _ownShares;
|
|
public IReadOnlyList<McdfShareEntryDto> 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<string> allowedIndividuals, IReadOnlyList<string> 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<byte[]?> 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<Task> 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);
|
|
}
|
|
}
|