268 lines
11 KiB
C#
268 lines
11 KiB
C#
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);
|
||
}
|
||
}
|