krona-staff/apps/KronaStaffTerminal/Services/TerminalStore.cs
Ochenstarik d45bee960a
Some checks are pending
ci / verify (push) Waiting to run
fix: harden terminal recovery and deployment
2026-09-18 11:10:28 +07:00

268 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<TerminalState> 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<TerminalState?> 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<TerminalState> ReadStateAsync(string path)
{
var encrypted = await File.ReadAllBytesAsync(path);
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
var state = JsonSerializer.Deserialize<TerminalState>(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<string> 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);
}
}