using System.Security.Cryptography; using System.Text.Json; using KronaStaffTerminal.Models; using Microsoft.Win32; namespace KronaStaffTerminal.Services; public sealed class TerminalStore { private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray(); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private const string RegistryPath = @"Software\Krona\StaffTerminal"; private const string InitializedValueName = "StateInitialized"; private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12); private readonly SemaphoreSlim _saveGate = new(1, 1); private readonly string _filePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Krona", "StaffTerminal", "terminal-state.dat"); public string? RecoveryNotice { get; private set; } public async Task LoadAsync() { RecoveryNotice = null; var sourcePath = File.Exists(_filePath) ? _filePath : FindLegacyPackagedState(); if (sourcePath is null) { var recovered = await TryRecoverFromBackupAsync(); if (recovered is not null) { RecoveryNotice = "Основной файл отсутствовал. Данные восстановлены из защищённой резервной копии."; await SaveAsync(recovered); return recovered; } if (WasPreviouslyInitialized() || HasRecoveryArtifacts()) { throw new InvalidDataException( "Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных."); } return new TerminalState(); } try { var state = await ReadStateAsync(sourcePath); var migrated = MigrateFingerprints(state); if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { await SaveAsync(state); } else { MarkInitialized(); } return state; } catch (Exception primaryException) when ( string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase) && IsCorruptStateException(primaryException)) { var corruptPath = _filePath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}"; try { File.Move(_filePath, corruptPath, false); } catch (Exception moveException) { throw new InvalidDataException( "Хранилище терминала повреждено и не может быть сохранено отдельно. Работа заблокирована, чтобы не потерять данные.", new AggregateException(primaryException, moveException)); } var recovered = await TryRecoverFromBackupAsync(); if (recovered is not null) { RecoveryNotice = $"Повреждённый файл сохранён как {Path.GetFileName(corruptPath)}. Данные восстановлены из защищённой резервной копии."; await SaveAsync(recovered); return recovered; } throw new InvalidDataException( $"Хранилище терминала повреждено. Исходный файл сохранён как {Path.GetFileName(corruptPath)}. Работа заблокирована до восстановления резервной копии.", primaryException); } catch (IOException exception) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { throw new IOException( "Хранилище терминала временно недоступно. Файл не изменён; закройте программу, которая его удерживает, и повторите запуск.", exception); } catch (UnauthorizedAccessException exception) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException( "Нет доступа к хранилищу терминала. Файл не изменён; проверьте права текущего пользователя.", exception); } } private async Task TryRecoverFromBackupAsync() { foreach (var backupPath in BackupPaths()) { if (!File.Exists(backupPath)) continue; try { var recovered = await ReadStateAsync(backupPath); MigrateFingerprints(recovered); return recovered; } catch (Exception backupException) when (IsCorruptStateException(backupException)) { CrashLogService.Write(backupException); } } return null; } private static bool IsCorruptStateException(Exception exception) => exception is CryptographicException or JsonException or InvalidDataException or NotSupportedException; private static async Task ReadStateAsync(string path) { var encrypted = await File.ReadAllBytesAsync(path); var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); var state = JsonSerializer.Deserialize(clear, JsonOptions) ?? throw new InvalidDataException("Файл состояния не содержит данных."); if (state.Employees is null || state.Attendance is null || state.SecurityAudit is null || state.Settings is null) { throw new InvalidDataException("Файл состояния имеет неполную структуру."); } return state; } private static bool MigrateFingerprints(TerminalState state) { var changed = false; foreach (var employee in state.Employees) { employee.Fingerprints ??= []; if (employee.FingerprintId <= 0 || string.IsNullOrWhiteSpace(employee.FingerprintTemplate)) { continue; } if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId)) { employee.Fingerprints.Add(new FingerprintRecord { Id = employee.FingerprintId, Template = employee.FingerprintTemplate, Label = "Отпечаток 1" }); } employee.FingerprintId = 0; employee.FingerprintTemplate = null; changed = true; } return changed; } public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false) { await _saveGate.WaitAsync(); try { var directory = Path.GetDirectoryName(_filePath)!; Directory.CreateDirectory(directory); var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions); var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser); var temporaryPath = _filePath + ".tmp"; await using (var stream = new FileStream( temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.WriteThrough)) { await stream.WriteAsync(encrypted); await stream.FlushAsync(); stream.Flush(true); } string? previousStatePath = null; if (File.Exists(_filePath) && ShouldCreateRecoveryPoint(createRecoveryPoint)) { previousStatePath = _filePath + ".previous"; File.Copy(_filePath, previousStatePath, true); } File.Move(temporaryPath, _filePath, true); MarkInitialized(); if (previousStatePath is not null) { try { RotateBackups(previousStatePath); } finally { File.Delete(previousStatePath); } } } finally { _saveGate.Release(); } } private bool ShouldCreateRecoveryPoint(bool requested) { var latestBackup = BackupPaths().First(); var minimumInterval = requested ? TimeSpan.FromHours(24) : AutomaticRecoveryPointInterval; return !File.Exists(latestBackup) || DateTimeOffset.UtcNow - File.GetLastWriteTimeUtc(latestBackup) >= minimumInterval; } private IEnumerable BackupPaths() { for (var index = 1; index <= 3; index++) yield return _filePath + $".bak{index}"; } private void RotateBackups(string previousStatePath) { var backups = BackupPaths().ToArray(); for (var index = backups.Length - 1; index > 0; index--) { if (File.Exists(backups[index - 1])) File.Copy(backups[index - 1], backups[index], true); } File.Copy(previousStatePath, backups[0], true); } private bool HasRecoveryArtifacts() { if (BackupPaths().Any(File.Exists) || File.Exists(_filePath + ".tmp") || File.Exists(_filePath + ".previous")) return true; var directory = Path.GetDirectoryName(_filePath)!; return Directory.Exists(directory) && Directory.EnumerateFiles(directory, "terminal-state.dat.corrupt-*").Any(); } private static bool WasPreviouslyInitialized() { using var key = Registry.CurrentUser.OpenSubKey(RegistryPath); return key?.GetValue(InitializedValueName) is int value && value == 1; } private static void MarkInitialized() { using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true) ?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала."); key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord); } private static string? FindLegacyPackagedState() { var packagesPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Packages"); if (!Directory.Exists(packagesPath)) return null; return Directory.EnumerateDirectories(packagesPath, "223A9635-262B-4298-9F19-1D2FE0A6B5F7_*") .Select(directory => Path.Combine(directory, "LocalCache", "Local", "Krona", "StaffTerminal", "terminal-state.dat")) .FirstOrDefault(File.Exists); } }