krona-staff/apps/KronaStaffTerminal/Services/TerminalStore.cs

368 lines
15 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.IO;
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
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;
public static string MachineFilePath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Krona",
"StaffTerminal",
"terminal-state.dat");
public static string LegacyUserFilePath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona",
"StaffTerminal",
"terminal-state.dat");
public string FilePath => _filePath;
public string? RecoveryNotice { get; private set; }
public TerminalStore(string? filePath = null)
{
_filePath = filePath ?? MachineFilePath;
}
public async Task<TerminalState> LoadAsync()
{
RecoveryNotice = null;
var sourcePath = File.Exists(_filePath) ? _filePath : null;
if (sourcePath is null && File.Exists(LegacyUserFilePath))
{
sourcePath = LegacyUserFilePath;
}
if (sourcePath is null)
{
sourcePath = 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 = MigrateEmployees(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);
MigrateEmployees(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);
byte[] clear;
try
{
// Primary: LocalMachine (accessible by both Service and Terminal UI on this machine)
clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.LocalMachine);
}
catch (CryptographicException)
{
// Legacy fallback: CurrentUser (if upgrading an older installation)
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 MigrateEmployees(TerminalState state)
{
var changed = false;
foreach (var employee in state.Employees)
{
employee.Fingerprints ??= [];
if (employee.FingerprintId > 0 && !string.IsNullOrWhiteSpace(employee.FingerprintTemplate))
{
if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId))
{
employee.Fingerprints.Add(new FingerprintRecord
{
Id = employee.FingerprintId,
Template = employee.FingerprintTemplate,
Label = "Отпечаток 1",
EnrolledAtUtc = DateTimeOffset.UtcNow
});
}
employee.FingerprintId = 0;
employee.FingerprintTemplate = null;
changed = true;
}
// Populate FIO if empty and DisplayName is available
if (string.IsNullOrWhiteSpace(employee.LastName) && !string.IsNullOrWhiteSpace(employee.DisplayName))
{
var parts = employee.DisplayName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) employee.LastName = parts[0];
if (parts.Length > 1) employee.FirstName = parts[1];
if (parts.Length > 2) employee.Patronymic = string.Join(" ", parts.Skip(2));
changed = true;
}
}
return changed;
}
public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false)
{
await _saveGate.WaitAsync();
try
{
var directory = Path.GetDirectoryName(_filePath)!;
EnsureDirectoryWithRestrictedAcl(directory);
var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions);
var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.LocalMachine);
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 static void EnsureDirectoryWithRestrictedAcl(string directoryPath)
{
if (Directory.Exists(directoryPath)) return;
try
{
var directorySecurity = new DirectorySecurity();
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
var usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null);
directorySecurity.AddAccessRule(new FileSystemAccessRule(
systemSid,
FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
directorySecurity.AddAccessRule(new FileSystemAccessRule(
adminSid,
FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
directorySecurity.AddAccessRule(new FileSystemAccessRule(
usersSid,
FileSystemRights.ReadAndExecute | FileSystemRights.Modify | FileSystemRights.Write,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
FileSystemAclExtensions.CreateDirectory(directorySecurity, directoryPath);
}
catch
{
Directory.CreateDirectory(directoryPath);
}
}
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 lmKey = Registry.LocalMachine.OpenSubKey(RegistryPath);
if (lmKey?.GetValue(InitializedValueName) is int lmValue && lmValue == 1) return true;
using var cuKey = Registry.CurrentUser.OpenSubKey(RegistryPath);
return cuKey?.GetValue(InitializedValueName) is int cuValue && cuValue == 1;
}
private static void MarkInitialized()
{
try
{
using var lmKey = Registry.LocalMachine.CreateSubKey(RegistryPath, writable: true);
lmKey?.SetValue(InitializedValueName, 1, RegistryValueKind.DWord);
}
catch (UnauthorizedAccessException)
{
// Best effort for non-elevated sessions
}
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);
}
}