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

430 lines
18 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
{
public const string OperatorGroupName = "KronaStaffTerminalOperators";
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 await InitializeFreshStateAsync();
}
try
{
if (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
{
EnsureDirectoryWithRestrictedAcl(Path.GetDirectoryName(_filePath)!);
}
var state = await ReadStateAsync(sourcePath);
var migrated = MigrateEmployees(state) | MigrateSecurity(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);
ApplyRestrictedFileAcl(corruptPath);
}
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;
}
private static bool MigrateSecurity(TerminalState state)
{
if (!string.IsNullOrWhiteSpace(state.Settings.IpcAuthenticationKey)) return false;
state.Settings.IpcAuthenticationKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
return true;
}
private async Task<TerminalState> InitializeFreshStateAsync()
{
var directory = Path.GetDirectoryName(_filePath)!;
EnsureDirectoryWithRestrictedAcl(directory);
var lockPath = _filePath + ".init.lock";
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
FileStream? initializationLock = null;
while (initializationLock is null)
{
try
{
initializationLock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite,
FileShare.None, 1, FileOptions.WriteThrough);
}
catch (IOException) when (DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(100);
}
}
await using (initializationLock)
{
ApplyRestrictedFileAcl(lockPath);
if (File.Exists(_filePath)) return await LoadAsync();
var state = new TerminalState();
await SaveAsync(state);
return state;
}
}
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);
ApplyRestrictedFileAcl(_filePath);
MarkInitialized();
if (previousStatePath is not null)
{
try
{
RotateBackups(previousStatePath);
}
finally
{
File.Delete(previousStatePath);
}
}
}
finally
{
_saveGate.Release();
}
}
private static void EnsureDirectoryWithRestrictedAcl(string directoryPath)
{
Directory.CreateDirectory(directoryPath);
var identities = AllowedIdentities(directoryPath).ToArray();
var directorySecurity = new DirectorySecurity();
directorySecurity.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
foreach (var identity in identities)
{
directorySecurity.AddAccessRule(new FileSystemAccessRule(
identity,
FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
}
new DirectoryInfo(directoryPath).SetAccessControl(directorySecurity);
foreach (var stateFile in Directory.EnumerateFiles(directoryPath, "terminal-state.dat*"))
ApplyRestrictedFileAcl(stateFile);
}
private static IEnumerable<IdentityReference> AllowedIdentities(string? existingDirectory = null)
{
yield return new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
yield return new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
SecurityIdentifier? operatorSid = null;
try
{
operatorSid = (SecurityIdentifier)new NTAccount(Environment.MachineName, OperatorGroupName)
.Translate(typeof(SecurityIdentifier));
}
catch (IdentityNotMappedException) { }
if (operatorSid is not null) yield return operatorSid;
using var current = WindowsIdentity.GetCurrent();
if (current.User is not null && !current.User.IsWellKnown(WellKnownSidType.LocalSystemSid))
yield return current.User;
if (existingDirectory is null || !Directory.Exists(existingDirectory)) yield break;
var existing = new DirectoryInfo(existingDirectory).GetAccessControl(AccessControlSections.Access);
foreach (FileSystemAccessRule rule in existing.GetAccessRules(
includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier)))
{
if (rule.AccessControlType != AccessControlType.Allow ||
rule.IdentityReference is not SecurityIdentifier sid || !sid.IsAccountSid()) continue;
yield return sid;
}
}
private static void ApplyRestrictedFileAcl(string path)
{
var security = new FileSecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
foreach (var identity in AllowedIdentities(Path.GetDirectoryName(path)))
security.AddAccessRule(new FileSystemAccessRule(identity, FileSystemRights.FullControl,
AccessControlType.Allow));
new FileInfo(path).SetAccessControl(security);
}
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);
}
}