430 lines
18 KiB
C#
430 lines
18 KiB
C#
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);
|
||
}
|
||
}
|