fix: harden terminal recovery and deployment
Some checks are pending
ci / verify (push) Waiting to run
Some checks are pending
ci / verify (push) Waiting to run
This commit is contained in:
parent
7c8e7fcaf1
commit
d45bee960a
20 changed files with 585 additions and 142 deletions
|
|
@ -3,6 +3,7 @@ PORT=8080
|
|||
LOG_LEVEL=info
|
||||
STAFF_RUNTIME_MODE=standalone
|
||||
POSTGRES_MIGRATION_PASSWORD=local-migration-only
|
||||
POSTGRES_MIGRATION_USER=krona_migrator
|
||||
POSTGRES_APP_PASSWORD=local-app-only
|
||||
DATABASE_MIGRATION_URL=postgres://krona_migrator:local-migration-only@postgres:5432/krona_staff
|
||||
DATABASE_URL=postgres://krona_staff_app:local-app-only@postgres:5432/krona_staff
|
||||
|
|
|
|||
|
|
@ -18,5 +18,5 @@ RUN npm ci --omit=dev && npm cache clean --force
|
|||
COPY --from=build /app/dist ./dist
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O - http://127.0.0.1:8080/live || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O - http://127.0.0.1:8080/api/v1/staff/live || exit 1
|
||||
CMD ["node", "dist/server.js"]
|
||||
|
|
|
|||
|
|
@ -16,13 +16,14 @@ namespace KronaStaffTerminal;
|
|||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
private const string InstanceMutexName = @"Local\KronaStaffTerminal.Singleton";
|
||||
private const string ActivationEventName = @"Local\KronaStaffTerminal.Activate";
|
||||
private const string InstanceMutexName = @"Global\KronaStaffTerminal.Singleton";
|
||||
private const string ActivationEventName = @"Global\KronaStaffTerminal.Activate";
|
||||
private TrayService? _trayService;
|
||||
private Microsoft.UI.Dispatching.DispatcherQueueTimer? _autostartTimer;
|
||||
private Mutex? _instanceMutex;
|
||||
private EventWaitHandle? _activationEvent;
|
||||
private CancellationTokenSource? _activationCancellation;
|
||||
private Task? _activationTask;
|
||||
/// <summary>
|
||||
/// The main application window. Use <c>App.Window</c> from any class that needs
|
||||
/// the window reference (for dialogs, pickers, interop, etc.).
|
||||
|
|
@ -88,7 +89,7 @@ public partial class App : Application
|
|||
_trayService = new TrayService(ShowWindow, RequestExit);
|
||||
_activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName);
|
||||
_activationCancellation = new CancellationTokenSource();
|
||||
_ = Task.Run(() => ListenForActivation(_activationCancellation.Token));
|
||||
_activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token));
|
||||
Window.Activate();
|
||||
if (Environment.GetCommandLineArgs().Any(argument =>
|
||||
argument.Equals("--autostart", StringComparison.OrdinalIgnoreCase)))
|
||||
|
|
@ -114,6 +115,7 @@ public partial class App : Application
|
|||
{
|
||||
if (_activationEvent?.WaitOne(TimeSpan.FromSeconds(1)) == true)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return;
|
||||
DispatcherQueue.TryEnqueue(ShowWindow);
|
||||
}
|
||||
}
|
||||
|
|
@ -141,10 +143,28 @@ public partial class App : Application
|
|||
public void CompleteExit()
|
||||
{
|
||||
_activationCancellation?.Cancel();
|
||||
_activationCancellation?.Dispose();
|
||||
_activationCancellation = null;
|
||||
_activationEvent?.Dispose();
|
||||
_activationEvent = null;
|
||||
_activationEvent?.Set();
|
||||
var activationStopped = true;
|
||||
try
|
||||
{
|
||||
activationStopped = _activationTask?.Wait(TimeSpan.FromSeconds(2)) != false;
|
||||
}
|
||||
catch (AggregateException exception)
|
||||
{
|
||||
CrashLogService.Write(exception.Flatten());
|
||||
}
|
||||
if (activationStopped)
|
||||
{
|
||||
_activationTask = null;
|
||||
_activationCancellation?.Dispose();
|
||||
_activationCancellation = null;
|
||||
_activationEvent?.Dispose();
|
||||
_activationEvent = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
CrashLogService.Write(new TimeoutException("Слушатель активации не завершился; его ресурсы оставлены до выхода процесса."));
|
||||
}
|
||||
_instanceMutex?.ReleaseMutex();
|
||||
_instanceMutex?.Dispose();
|
||||
_instanceMutex = null;
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ public sealed partial class MainPage : Page
|
|||
private bool _adminNavigationInProgress;
|
||||
private bool _stateLoadFailed;
|
||||
private bool _scannerReconnectPending;
|
||||
private int _failedPinAttempts;
|
||||
private DateTimeOffset _pinBlockedUntilUtc;
|
||||
private string? _registrationEmployeeId;
|
||||
private bool _refreshingLists;
|
||||
private string _lastAdminActor = "unknown";
|
||||
private DateTimeOffset _adminSessionExpiresAtUtc;
|
||||
private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
|
|
@ -73,6 +73,10 @@ public sealed partial class MainPage : Page
|
|||
RefreshLists();
|
||||
ConnectScanner();
|
||||
MainTabs.SelectedItem = TerminalTab;
|
||||
if (_store.RecoveryNotice is not null)
|
||||
{
|
||||
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void Page_Unloaded(object sender, RoutedEventArgs e)
|
||||
|
|
@ -209,7 +213,8 @@ public sealed partial class MainPage : Page
|
|||
_state.Settings.AdminPinSalt = pin.Salt;
|
||||
_state.Settings.AdminPinHash = pin.Hash;
|
||||
_state.Settings.AdminPinIterations = pin.Iterations;
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
_lastAdminActor = "initial-pin";
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -284,7 +289,7 @@ public sealed partial class MainPage : Page
|
|||
IsAdministrator = AdministratorCheckBox.IsChecked == true
|
||||
};
|
||||
_state.Employees.Add(employee);
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
EmployeeNameBox.Text = "";
|
||||
PersonnelNumberBox.Text = "";
|
||||
PositionBox.Text = "";
|
||||
|
|
@ -374,12 +379,7 @@ public sealed partial class MainPage : Page
|
|||
2 => "Женский",
|
||||
_ => ""
|
||||
};
|
||||
foreach (var attendance in _state.Attendance.Where(item => item.EmployeeId == employee.Id))
|
||||
{
|
||||
attendance.EmployeeName = name;
|
||||
}
|
||||
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
RefreshLists();
|
||||
EmployeesList.SelectedItem = employee;
|
||||
SetStatus($"Данные сотрудника «{name}» обновлены. Отпечатки и журнал сохранены.", InfoBarSeverity.Success);
|
||||
|
|
@ -420,6 +420,7 @@ public sealed partial class MainPage : Page
|
|||
|
||||
private void EmployeesList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_refreshingLists) return;
|
||||
if (_registrationEmployeeId is null) return;
|
||||
if (EmployeesList.SelectedItem is EmployeeRecord employee && employee.Id == _registrationEmployeeId) return;
|
||||
CancelRegistration();
|
||||
|
|
@ -443,9 +444,9 @@ public sealed partial class MainPage : Page
|
|||
Label = $"Отпечаток {employee.Fingerprints.Count + 1}"
|
||||
});
|
||||
_registrationEmployeeId = null;
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
RefreshLists();
|
||||
TerminalMessage.Text = $"Новый отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
|
||||
TerminalMessage.Text = $"Новый отпечаток сотрудника «{TerminalDisplayName(employee)}» зарегистрирован";
|
||||
SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
|
|
@ -482,7 +483,7 @@ public sealed partial class MainPage : Page
|
|||
if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return;
|
||||
|
||||
employee.Fingerprints.Clear();
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
RefreshLists();
|
||||
ConnectScanner();
|
||||
SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success);
|
||||
|
|
@ -503,7 +504,7 @@ public sealed partial class MainPage : Page
|
|||
return;
|
||||
}
|
||||
employee.IsAdministrator = !employee.IsAdministrator;
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
RefreshLists();
|
||||
SetStatus(employee.IsAdministrator ? $"{employee.DisplayName} назначен администратором." : $"Права администратора у {employee.DisplayName} сняты.", InfoBarSeverity.Success);
|
||||
}
|
||||
|
|
@ -539,7 +540,7 @@ public sealed partial class MainPage : Page
|
|||
}
|
||||
|
||||
_state.Employees.Remove(employee);
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
RefreshLists();
|
||||
ConnectScanner();
|
||||
SetStatus("Сотрудник и его отпечаток удалены.", InfoBarSeverity.Success);
|
||||
|
|
@ -554,7 +555,8 @@ public sealed partial class MainPage : Page
|
|||
{
|
||||
if (employee.IsAdministrator)
|
||||
{
|
||||
TerminalMessage.Text = $"Администратор {employee.DisplayName} подтверждён";
|
||||
_lastAdminActor = $"employee:{employee.Id}";
|
||||
TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён";
|
||||
_adminAuthCompletion.TrySetResult(true);
|
||||
_adminAuthDialog?.Hide();
|
||||
}
|
||||
|
|
@ -578,7 +580,7 @@ public sealed partial class MainPage : Page
|
|||
if (last is not null && now - last.OccurredAtUtc < guard)
|
||||
{
|
||||
var remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds));
|
||||
TerminalMessage.Text = $"{employee.DisplayName}\nПовторный скан отклонён\nПовторите через {remaining} сек.";
|
||||
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {remaining} сек.";
|
||||
SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational);
|
||||
return;
|
||||
}
|
||||
|
|
@ -602,7 +604,7 @@ public sealed partial class MainPage : Page
|
|||
}
|
||||
await _store.SaveAsync(_state);
|
||||
RefreshLists();
|
||||
TerminalMessage.Text = $"{employee.DisplayName}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}";
|
||||
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}";
|
||||
SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
|
||||
|
||||
var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee.DisplayName, eventType, now.ToLocalTime());
|
||||
|
|
@ -632,7 +634,7 @@ public sealed partial class MainPage : Page
|
|||
if (!await AuthorizeAdminAsync("очистка журнала прихода и ухода")) return;
|
||||
|
||||
_state.Attendance.Clear();
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
RefreshLists();
|
||||
TerminalMessage.Text = "Журнал отметок очищен";
|
||||
SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success);
|
||||
|
|
@ -641,6 +643,19 @@ public sealed partial class MainPage : Page
|
|||
private async void ExportAttendance_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("экспорт журнала")) return;
|
||||
var warning = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = "Экспорт персональных данных",
|
||||
Content = "CSV-файл не шифруется и содержит ФИО, табельные номера, должности и время отметок. Сохраняйте его только в защищённом месте.",
|
||||
PrimaryButtonText = "Продолжить экспорт",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Close
|
||||
};
|
||||
if (await warning.ShowAsync() != ContentDialogResult.Primary) return;
|
||||
_lastAdminActor = "unknown";
|
||||
if (!await AuthorizeAdminAsync("экспорт персональных данных", requireIdentifiedActor: true)) return;
|
||||
|
||||
var picker = new FileSavePicker
|
||||
{
|
||||
SuggestedFileName = $"krona-staff-attendance-{DateTime.Now:yyyy-MM-dd}"
|
||||
|
|
@ -650,25 +665,44 @@ public sealed partial class MainPage : Page
|
|||
var file = await picker.PickSaveFileAsync();
|
||||
if (file is null) return;
|
||||
|
||||
var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n");
|
||||
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
|
||||
AppendSecurityAudit("attendance.export", "started", $"records={_state.Attendance.Count}; file={file.Name}");
|
||||
await _store.SaveAsync(_state);
|
||||
try
|
||||
{
|
||||
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
|
||||
csv.Append(Csv(item.EmployeeName)).Append(';')
|
||||
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
|
||||
.Append(Csv(employee?.Position ?? "")).Append(';')
|
||||
.Append(Csv(item.EventType)).Append(';')
|
||||
.Append(Csv(item.LocalTime)).Append(';')
|
||||
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
|
||||
var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n");
|
||||
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
|
||||
{
|
||||
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
|
||||
csv.Append(Csv(item.EmployeeName)).Append(';')
|
||||
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
|
||||
.Append(Csv(employee?.Position ?? "")).Append(';')
|
||||
.Append(Csv(item.EventType)).Append(';')
|
||||
.Append(Csv(item.LocalTime)).Append(';')
|
||||
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
|
||||
}
|
||||
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
|
||||
AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}");
|
||||
await _store.SaveAsync(_state);
|
||||
SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
AppendSecurityAudit("attendance.export", "failed", $"records={_state.Attendance.Count}; error={exception.GetType().Name}");
|
||||
await _store.SaveAsync(_state);
|
||||
CrashLogService.Write(exception);
|
||||
SetStatus($"Экспорт не выполнен: {exception.Message}", InfoBarSeverity.Error);
|
||||
}
|
||||
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
|
||||
SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
|
||||
public async Task<bool> AuthorizeExitAsync()
|
||||
{
|
||||
if (_stateLoadFailed)
|
||||
{
|
||||
SetStatus("Хранилище не открыто. Программа завершает работу без изменения данных.", InfoBarSeverity.Warning);
|
||||
return true;
|
||||
}
|
||||
if (!SecurityConfigured)
|
||||
{
|
||||
SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
|
||||
|
|
@ -686,26 +720,37 @@ public sealed partial class MainPage : Page
|
|||
private bool SecurityConfigured =>
|
||||
PinConfigured || AdministratorFingerprintConfigured;
|
||||
|
||||
private async Task<bool> AuthorizeAdminAsync(string operation)
|
||||
private async Task<bool> AuthorizeAdminAsync(string operation, bool requireIdentifiedActor = false)
|
||||
{
|
||||
var remainingLockout = _pinBlockedUntilUtc - DateTimeOffset.UtcNow;
|
||||
if (remainingLockout > TimeSpan.Zero)
|
||||
var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow;
|
||||
var pinBlocked = remainingLockout > TimeSpan.Zero;
|
||||
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
|
||||
if (requireIdentifiedActor && !hasAdministratorFingerprint)
|
||||
{
|
||||
SetStatus("Для экспорта персональных данных требуется отпечаток сотрудника-администратора.", InfoBarSeverity.Warning);
|
||||
return false;
|
||||
}
|
||||
if (pinBlocked && !hasAdministratorFingerprint)
|
||||
{
|
||||
SetStatus($"Слишком много неверных попыток PIN. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек.", InfoBarSeverity.Warning);
|
||||
return false;
|
||||
}
|
||||
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
|
||||
var pinBox = new PasswordBox
|
||||
{
|
||||
Header = "PIN администратора",
|
||||
PasswordRevealMode = PasswordRevealMode.Peek,
|
||||
MinWidth = 280
|
||||
MinWidth = 280,
|
||||
IsEnabled = !pinBlocked && !requireIdentifiedActor
|
||||
};
|
||||
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = hasAdministratorFingerprint
|
||||
? "Введите PIN или приложите зарегистрированный палец администратора."
|
||||
Text = requireIdentifiedActor
|
||||
? "Для записи автора экспорта приложите зарегистрированный палец сотрудника-администратора."
|
||||
: hasAdministratorFingerprint
|
||||
? pinBlocked
|
||||
? $"PIN временно заблокирован ещё на {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Приложите зарегистрированный палец администратора."
|
||||
: "Введите PIN или приложите зарегистрированный палец администратора."
|
||||
: "Введите PIN. После входа зарегистрируйте отпечаток администратора.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
});
|
||||
|
|
@ -718,6 +763,7 @@ public sealed partial class MainPage : Page
|
|||
Title = $"Вход администратора: {operation}",
|
||||
Content = content,
|
||||
PrimaryButtonText = "Войти по PIN",
|
||||
IsPrimaryButtonEnabled = !pinBlocked && !requireIdentifiedActor,
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
|
|
@ -726,7 +772,8 @@ public sealed partial class MainPage : Page
|
|||
_adminAuthCompletion.Task.Result;
|
||||
_adminAuthCompletion.TrySetResult(false);
|
||||
var pinAuthorized = false;
|
||||
if (dialogResult == ContentDialogResult.Primary)
|
||||
string verifiedPin = pinBox.Password;
|
||||
if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
|
||||
{
|
||||
pinAuthorized = await Task.Run(() =>
|
||||
AdminPinService.Verify(
|
||||
|
|
@ -742,17 +789,36 @@ public sealed partial class MainPage : Page
|
|||
_state.Settings.AdminPinIterations)));
|
||||
}
|
||||
var authorized = fingerprintAuthorized || pinAuthorized;
|
||||
var securityStateChanged = false;
|
||||
if (authorized)
|
||||
{
|
||||
_failedPinAttempts = 0;
|
||||
_pinBlockedUntilUtc = default;
|
||||
_state.Settings.FailedAdminPinAttempts = 0;
|
||||
_state.Settings.AdminPinBlockedUntilUtc = default;
|
||||
securityStateChanged = true;
|
||||
if (pinAuthorized)
|
||||
{
|
||||
_lastAdminActor = "local-pin";
|
||||
if (_state.Settings.AdminPinIterations < AdminPinService.CurrentIterations)
|
||||
{
|
||||
if (!AdminPinService.Verify(verifiedPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations))
|
||||
{
|
||||
verifiedPin = verifiedPin.Trim();
|
||||
}
|
||||
var upgraded = await Task.Run(() => AdminPinService.Hash(verifiedPin));
|
||||
_state.Settings.AdminPinSalt = upgraded.Salt;
|
||||
_state.Settings.AdminPinHash = upgraded.Hash;
|
||||
_state.Settings.AdminPinIterations = upgraded.Iterations;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (dialogResult == ContentDialogResult.Primary)
|
||||
else if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
|
||||
{
|
||||
_failedPinAttempts++;
|
||||
var delaySeconds = Math.Min(30, 1 << Math.Min(_failedPinAttempts - 1, 5));
|
||||
_pinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
|
||||
_state.Settings.FailedAdminPinAttempts++;
|
||||
var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10));
|
||||
_state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
|
||||
securityStateChanged = true;
|
||||
}
|
||||
if (securityStateChanged) await _store.SaveAsync(_state);
|
||||
_adminAuthCompletion = null;
|
||||
_adminAuthDialog = null;
|
||||
SetStatus(authorized
|
||||
|
|
@ -777,7 +843,7 @@ public sealed partial class MainPage : Page
|
|||
_state.Settings.AdminPinHash = pin.Hash;
|
||||
_state.Settings.AdminPinIterations = pin.Iterations;
|
||||
NewAdminPinBox.Password = "";
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
|
|
@ -785,7 +851,7 @@ public sealed partial class MainPage : Page
|
|||
{
|
||||
if (!await EnsureAdminSessionAsync("изменение настроек")) return;
|
||||
CopySettingsFromControls();
|
||||
await _store.SaveAsync(_state);
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
ApplyStartupSetting();
|
||||
ConnectScanner();
|
||||
SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
|
||||
|
|
@ -843,26 +909,47 @@ public sealed partial class MainPage : Page
|
|||
|
||||
private void RefreshLists()
|
||||
{
|
||||
EmployeesList.ItemsSource = null;
|
||||
EmployeesList.ItemsSource = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
|
||||
AttendanceList.ItemsSource = null;
|
||||
AttendanceList.ItemsSource = _state.Attendance
|
||||
.OrderByDescending(item => item.OccurredAtUtc)
|
||||
.Take(100)
|
||||
.Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord
|
||||
{
|
||||
Id = item.Id,
|
||||
EmployeeId = item.EmployeeId,
|
||||
EmployeeName = MaskName(item.EmployeeName),
|
||||
OccurredAtUtc = item.OccurredAtUtc,
|
||||
ReaderIndex = item.ReaderIndex,
|
||||
EventType = item.EventType,
|
||||
NotificationDelivered = item.NotificationDelivered,
|
||||
NotificationError = item.NotificationError
|
||||
})
|
||||
.ToList();
|
||||
var selectedEmployeeId = (EmployeesList.SelectedItem as EmployeeRecord)?.Id;
|
||||
_refreshingLists = true;
|
||||
try
|
||||
{
|
||||
var employees = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
|
||||
EmployeesList.ItemsSource = employees;
|
||||
EmployeesList.SelectedItem = employees.FirstOrDefault(employee => employee.Id == selectedEmployeeId);
|
||||
AttendanceList.ItemsSource = _state.Attendance
|
||||
.OrderByDescending(item => item.OccurredAtUtc)
|
||||
.Take(100)
|
||||
.Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord
|
||||
{
|
||||
Id = item.Id,
|
||||
EmployeeId = item.EmployeeId,
|
||||
EmployeeName = MaskName(item.EmployeeName),
|
||||
OccurredAtUtc = item.OccurredAtUtc,
|
||||
ReaderIndex = item.ReaderIndex,
|
||||
EventType = item.EventType,
|
||||
NotificationDelivered = item.NotificationDelivered,
|
||||
NotificationError = item.NotificationError
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshingLists = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string TerminalDisplayName(EmployeeRecord employee) =>
|
||||
_state.Settings.ShowEmployeeNamesOnTerminal ? employee.DisplayName : MaskName(employee.DisplayName);
|
||||
|
||||
private void AppendSecurityAudit(string action, string outcome, string detail) =>
|
||||
_state.SecurityAudit.Add(new TerminalAuditRecord
|
||||
{
|
||||
Action = action,
|
||||
Actor = _lastAdminActor,
|
||||
Outcome = outcome,
|
||||
Detail = detail
|
||||
});
|
||||
|
||||
private static string MaskName(string name) => string.Join(' ', name
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(part => part.Length == 1 ? $"{part[0]}…" : $"{part[0]}***"));
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ public sealed class TerminalSettings
|
|||
public string AdminPinSalt { get; set; } = "";
|
||||
public string AdminPinHash { get; set; } = "";
|
||||
public int AdminPinIterations { get; set; } = 120_000;
|
||||
public int FailedAdminPinAttempts { get; set; }
|
||||
public DateTimeOffset AdminPinBlockedUntilUtc { get; set; }
|
||||
public int DuplicateScanGuardSeconds { get; set; } = 120;
|
||||
public int MaximumShiftHours { get; set; } = 20;
|
||||
public bool ShowEmployeeNamesOnTerminal { get; set; }
|
||||
|
|
@ -82,5 +84,16 @@ public sealed class TerminalState
|
|||
{
|
||||
public List<EmployeeRecord> Employees { get; set; } = [];
|
||||
public List<AttendanceRecord> Attendance { get; set; } = [];
|
||||
public List<TerminalAuditRecord> SecurityAudit { get; set; } = [];
|
||||
public TerminalSettings Settings { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TerminalAuditRecord
|
||||
{
|
||||
public string Id { get; set; } = UlidGenerator.New();
|
||||
public DateTimeOffset OccurredAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string Action { get; set; } = "";
|
||||
public string Actor { get; set; } = "";
|
||||
public string Outcome { get; set; } = "";
|
||||
public string Detail { get; set; } = "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public sealed class FingerprintScanner : IDisposable
|
|||
private DateTimeOffset _registrationExpiresAtUtc;
|
||||
private DateTimeOffset _lastProcessedCapture = DateTimeOffset.MinValue;
|
||||
private bool _fingerOnSensor;
|
||||
private int _cleanupScheduled;
|
||||
|
||||
public event Action<int, int>? FingerRecognized;
|
||||
public event Action<int>? RegistrationProgress;
|
||||
|
|
@ -271,26 +272,63 @@ public sealed class FingerprintScanner : IDisposable
|
|||
_captureCancellation?.Cancel();
|
||||
try
|
||||
{
|
||||
_captureTask?.Wait(TimeSpan.FromSeconds(2));
|
||||
if (_captureTask is not null && !_captureTask.Wait(TimeSpan.FromSeconds(2)))
|
||||
{
|
||||
if (Interlocked.Exchange(ref _cleanupScheduled, 1) == 0)
|
||||
{
|
||||
_ = _captureTask.ContinueWith(
|
||||
_ =>
|
||||
{
|
||||
ReleaseNativeResources();
|
||||
ConnectionLost?.Invoke();
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
throw new TimeoutException(
|
||||
"Поток чтения сканера не завершился вовремя. Ресурсы будут освобождены сразу после безопасной остановки чтения.");
|
||||
}
|
||||
}
|
||||
catch (AggregateException exception)
|
||||
{
|
||||
CrashLogService.Write(exception.Flatten());
|
||||
}
|
||||
_captureCancellation = null;
|
||||
_captureTask = null;
|
||||
if (_database != IntPtr.Zero)
|
||||
{
|
||||
zkfp2.DBFree(_database);
|
||||
_database = IntPtr.Zero;
|
||||
}
|
||||
if (_device != IntPtr.Zero)
|
||||
{
|
||||
zkfp2.CloseDevice(_device);
|
||||
_device = IntPtr.Zero;
|
||||
}
|
||||
zkfp2.Terminate();
|
||||
ReleaseNativeResources();
|
||||
}
|
||||
|
||||
public void Dispose() => Disconnect();
|
||||
private void ReleaseNativeResources()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_captureCancellation?.Dispose();
|
||||
_captureCancellation = null;
|
||||
_captureTask = null;
|
||||
_fingerOnSensor = false;
|
||||
if (_database != IntPtr.Zero)
|
||||
{
|
||||
zkfp2.DBFree(_database);
|
||||
_database = IntPtr.Zero;
|
||||
}
|
||||
if (_device != IntPtr.Zero)
|
||||
{
|
||||
zkfp2.CloseDevice(_device);
|
||||
_device = IntPtr.Zero;
|
||||
}
|
||||
zkfp2.Terminate();
|
||||
Interlocked.Exchange(ref _cleanupScheduled, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Disconnect();
|
||||
}
|
||||
catch (TimeoutException exception)
|
||||
{
|
||||
CrashLogService.Write(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,17 +24,8 @@ public static class StartupService
|
|||
|
||||
private static string ResolveExecutablePath()
|
||||
{
|
||||
var executablePath = Environment.ProcessPath
|
||||
return Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Не удалось определить путь программы.");
|
||||
var debugSegment = $"{Path.DirectorySeparatorChar}Debug{Path.DirectorySeparatorChar}";
|
||||
if (!executablePath.Contains(debugSegment, StringComparison.OrdinalIgnoreCase)) return executablePath;
|
||||
|
||||
var releasePath = executablePath.Replace(
|
||||
debugSegment,
|
||||
$"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
if (File.Exists(releasePath)) return releasePath;
|
||||
throw new InvalidOperationException("Автозагрузка доступна после сборки Release-версии программы.");
|
||||
}
|
||||
|
||||
public static bool IsEnabled()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using KronaStaffTerminal.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace KronaStaffTerminal.Services;
|
||||
|
||||
|
|
@ -8,6 +9,9 @@ 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),
|
||||
|
|
@ -15,10 +19,30 @@ public sealed class TerminalStore
|
|||
"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) return new TerminalState();
|
||||
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
|
||||
{
|
||||
|
|
@ -28,9 +52,15 @@ public sealed class TerminalStore
|
|||
{
|
||||
await SaveAsync(state);
|
||||
}
|
||||
else
|
||||
{
|
||||
MarkInitialized();
|
||||
}
|
||||
return state;
|
||||
}
|
||||
catch (Exception primaryException) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
||||
catch (Exception primaryException) when (
|
||||
string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase) &&
|
||||
IsCorruptStateException(primaryException))
|
||||
{
|
||||
var corruptPath = _filePath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}";
|
||||
try
|
||||
|
|
@ -44,34 +74,65 @@ public sealed class TerminalStore
|
|||
new AggregateException(primaryException, moveException));
|
||||
}
|
||||
|
||||
foreach (var backupPath in BackupPaths())
|
||||
var recovered = await TryRecoverFromBackupAsync();
|
||||
if (recovered is not null)
|
||||
{
|
||||
if (!File.Exists(backupPath)) continue;
|
||||
try
|
||||
{
|
||||
var recovered = await ReadStateAsync(backupPath);
|
||||
MigrateFingerprints(recovered);
|
||||
await SaveAsync(recovered);
|
||||
return recovered;
|
||||
}
|
||||
catch (Exception backupException)
|
||||
{
|
||||
CrashLogService.Write(backupException);
|
||||
}
|
||||
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);
|
||||
return JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions)
|
||||
?? throw new InvalidDataException("Файл состояния не содержит данных.");
|
||||
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)
|
||||
|
|
@ -101,7 +162,7 @@ public sealed class TerminalStore
|
|||
return changed;
|
||||
}
|
||||
|
||||
public async Task SaveAsync(TerminalState state)
|
||||
public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false)
|
||||
{
|
||||
await _saveGate.WaitAsync();
|
||||
try
|
||||
|
|
@ -124,8 +185,25 @@ public sealed class TerminalStore
|
|||
stream.Flush(true);
|
||||
}
|
||||
|
||||
RotateBackups();
|
||||
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
|
||||
{
|
||||
|
|
@ -133,19 +211,48 @@ public sealed class TerminalStore
|
|||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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);
|
||||
}
|
||||
if (File.Exists(_filePath)) File.Copy(_filePath, backups[0], 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()
|
||||
|
|
|
|||
41
compose.yaml
41
compose.yaml
|
|
@ -1,30 +1,52 @@
|
|||
services:
|
||||
app:
|
||||
build: .
|
||||
env_file: .env
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
PORT: ${PORT:-8080}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
STAFF_RUNTIME_MODE: ${STAFF_RUNTIME_MODE:-standalone}
|
||||
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD:-local-app-only}@postgres:5432/krona_staff
|
||||
PLATFORM_GATEWAY_URL: ${PLATFORM_GATEWAY_URL:-}
|
||||
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
|
||||
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
|
||||
PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-}
|
||||
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:-01J00000000000000000000000}
|
||||
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
|
||||
ports: ['8080:8080']
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
migrate: { condition: service_completed_successfully }
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs: [/tmp]
|
||||
security_opt: ['no-new-privileges:true']
|
||||
migrate:
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget -q -O - http://127.0.0.1:8080/api/v1/staff/live || exit 1']
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
db-role:
|
||||
build: { context: ., target: build }
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_MIGRATION_URL: postgres://krona_migrator:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff
|
||||
command: ['node', 'scripts/migrate.mjs', 'up']
|
||||
DATABASE_MIGRATION_URL: postgres://${POSTGRES_MIGRATION_USER:-krona_migrator}:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff
|
||||
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only}
|
||||
command: ['node', 'scripts/create-app-role.mjs']
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
profiles: ['tools']
|
||||
restart: 'no'
|
||||
migrate:
|
||||
build: { context: ., target: build }
|
||||
environment:
|
||||
DATABASE_MIGRATION_URL: postgres://${POSTGRES_MIGRATION_USER:-krona_migrator}:${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}@postgres:5432/krona_staff
|
||||
command: ['node', 'scripts/migrate.mjs', 'up']
|
||||
depends_on:
|
||||
db-role: { condition: service_completed_successfully }
|
||||
restart: 'no'
|
||||
postgres:
|
||||
image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
|
||||
environment:
|
||||
POSTGRES_DB: krona_staff
|
||||
POSTGRES_USER: krona_migrator
|
||||
POSTGRES_USER: ${POSTGRES_MIGRATION_USER:-krona_migrator}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:-local-migration-only}
|
||||
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:-local-app-only}
|
||||
volumes:
|
||||
|
|
@ -32,7 +54,8 @@ services:
|
|||
- ./docker/postgres/init-app-role.sh:/docker-entrypoint-initdb.d/10-app-role.sh:ro
|
||||
healthcheck:
|
||||
{
|
||||
test: ['CMD-SHELL', 'pg_isready -U krona_migrator -d krona_staff'],
|
||||
test:
|
||||
['CMD-SHELL', 'pg_isready -U ${POSTGRES_MIGRATION_USER:-krona_migrator} -d krona_staff'],
|
||||
interval: 5s,
|
||||
timeout: 3s,
|
||||
retries: 20,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# Развёртывание
|
||||
|
||||
Одна команда локально: скопировать `.env.example` в `.env`, затем `docker compose --profile tools run --rm migrate` и `docker compose up --build`. Compose поднимает приложение и PostgreSQL. Контейнер PostgreSQL создаёт отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`; миграции выполняются только ролью `krona_migrator`. Redis и object storage не включаются до появления реального сценария очередей или файлов.
|
||||
Локально скопируйте `.env.example` в `.env` и выполните `docker compose up --build`. Compose последовательно проверит PostgreSQL, создаст или обновит отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, выполнит миграции ролью `krona_migrator` и только затем запустит приложение. Runtime-контейнер не получает пароль или URL мигратора. Redis и object storage не включаются до появления реального сценария очередей или файлов.
|
||||
|
||||
Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges.
|
||||
|
||||
Production: один stateless app container/process, отдельный PostgreSQL, TLS/reverse proxy, secret manager и централизованные logs/metrics/traces. Приложению выдаётся только роль без `SUPERUSER` и `BYPASSRLS`; миграционный URL хранится отдельно и не передаётся runtime-контейнеру. `/api/v1/staff/live` проверяет процесс, `/api/v1/staff/ready` — обязательные зависимости, `/api/v1/staff/version` — release и runtime mode. Container работает non-root, read-only filesystem.
|
||||
|
||||
HA сейчас не требуется. Для будущего scale-out: общий PostgreSQL/Redis/S3, несколько API/worker nodes, leader election для scheduler, outbox relay и идемпотентные consumers. Локальные вложения запрещены. Linux и Windows deployment сохраняют паритет на уровне Node/container contract.
|
||||
|
||||
Миграции запускаются отдельным one-shot job до rollout, учитываются в `schema_migrations` и защищены advisory lock. Down откатывает только последнюю миграцию и требует backup плюс `ALLOW_MIGRATION_DOWN=true`.
|
||||
Миграции запускаются отдельным one-shot job до rollout, учитываются в `schema_migrations` вместе с SHA-256 исходного SQL и защищены advisory lock. Для старых записей без checksum первый запуск фиксирует текущую версию как доверенную базовую; последующие изменения применённого SQL блокируются. Down откатывает только последнюю миграцию и требует backup плюс `ALLOW_MIGRATION_DOWN=true`.
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
|
||||
## Context
|
||||
|
||||
Platform contracts are TypeScript, baseline API is REST/OpenAPI 3.1, PostgreSQL/Redis/BullMQ are normative, blocks need independent deployment without duplicating Core identity. Staff business rules are unknown.
|
||||
Platform contracts are TypeScript, baseline API is REST/OpenAPI 3.1, PostgreSQL is the current durable store, and blocks need independent deployment without duplicating Core identity. Staff business rules are unknown. Redis/BullMQ are intentionally deferred until a confirmed queue or worker scenario exists.
|
||||
|
||||
## Decision
|
||||
|
||||
Use Node.js 22 + strict TypeScript, Fastify, PostgreSQL 17, Redis/BullMQ, Zod, Vitest and a single stateless container. Keep domain code behind ports. Select either standalone adapters or platform-gateway adapters through configuration. Use ULID `VARCHAR(26)`, UTC, `BIGINT` minor money, transactional outbox/inbox and S3 abstraction.
|
||||
Use Node.js 22 + strict TypeScript, Fastify, PostgreSQL 17, Zod, Vitest and a single stateless container. Keep domain code behind ports. Select either standalone adapters or platform-gateway adapters through configuration. Use ULID `VARCHAR(26)`, UTC, `BIGINT` minor money, transactional outbox/inbox and S3 abstraction. Add Redis/BullMQ only with a confirmed workload and a separate ADR.
|
||||
|
||||
Standalone provides only explicit development tenant/actor references; no durable user/role source. Platform mode fails closed until verified Core JWT/gateway contracts are implemented.
|
||||
|
||||
|
|
|
|||
4
migrations/0003_app_role_privileges.down.sql
Normal file
4
migrations/0003_app_role_privileges.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM krona_staff_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
REVOKE USAGE, SELECT ON SEQUENCES FROM krona_staff_app;
|
||||
17
migrations/0003_app_role_privileges.up.sql
Normal file
17
migrations/0003_app_role_privileges.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'krona_staff_app') THEN
|
||||
RAISE EXCEPTION 'Required database role krona_staff_app does not exist. Run scripts/create-app-role.mjs before migrations.';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
GRANT USAGE ON SCHEMA public TO krona_staff_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_outbox, staff_inbox, staff_idempotency_keys TO krona_staff_app;
|
||||
GRANT SELECT, INSERT ON staff_audit_log TO krona_staff_app;
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON staff_audit_log FROM krona_staff_app;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO krona_staff_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO krona_staff_app;
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
import {
|
||||
assertAppliedMigrationsPresent,
|
||||
assertMigrationChecksum,
|
||||
migrationChecksum,
|
||||
} from './migration-checksum.mjs';
|
||||
|
||||
const direction = process.argv[2];
|
||||
if (direction !== 'up' && direction !== 'down') {
|
||||
|
|
@ -28,27 +33,50 @@ try {
|
|||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
checksum TEXT,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await client.query('ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum TEXT');
|
||||
|
||||
if (direction === 'up') {
|
||||
const appliedResult = await client.query('SELECT version FROM schema_migrations');
|
||||
const applied = new Set(appliedResult.rows.map((row) => String(row.version)));
|
||||
const appliedResult = await client.query('SELECT version, checksum FROM schema_migrations');
|
||||
const applied = new Map(
|
||||
appliedResult.rows.map((row) => [String(row.version), row.checksum && String(row.checksum)]),
|
||||
);
|
||||
const availableVersions = new Set(files.map((file) => file.replace(/\.up\.sql$/, '')));
|
||||
assertAppliedMigrationsPresent(applied.keys(), availableVersions);
|
||||
for (const file of files) {
|
||||
const version = file.replace(/\.up\.sql$/, '');
|
||||
if (applied.has(version)) continue;
|
||||
const sql = await readFile(path.join(migrationsDirectory, file), 'utf8');
|
||||
const content = await readFile(path.join(migrationsDirectory, file));
|
||||
const checksum = migrationChecksum(content);
|
||||
if (applied.has(version)) {
|
||||
const storedChecksum = applied.get(version);
|
||||
if (storedChecksum) {
|
||||
assertMigrationChecksum(version, storedChecksum, checksum);
|
||||
} else {
|
||||
await client.query('UPDATE schema_migrations SET checksum = $2 WHERE version = $1', [
|
||||
version,
|
||||
checksum,
|
||||
]);
|
||||
console.warn(`baselined checksum for legacy migration ${version}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const sql = content.toString('utf8');
|
||||
await applyTransaction(async () => {
|
||||
await client.query(sql);
|
||||
await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [version]);
|
||||
await client.query('INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2)', [
|
||||
version,
|
||||
checksum,
|
||||
]);
|
||||
});
|
||||
console.log(`applied ${file}`);
|
||||
}
|
||||
} else {
|
||||
const requestedVersion = process.argv[3];
|
||||
const latestResult = await client.query(
|
||||
'SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1',
|
||||
'SELECT version, checksum FROM schema_migrations ORDER BY version DESC LIMIT 1',
|
||||
);
|
||||
const latestVersion = latestResult.rows[0]?.version;
|
||||
if (!latestVersion) {
|
||||
|
|
@ -60,6 +88,18 @@ try {
|
|||
);
|
||||
}
|
||||
const downFile = `${latestVersion}.down.sql`;
|
||||
const upContent = await readFile(path.join(migrationsDirectory, `${latestVersion}.up.sql`));
|
||||
const currentChecksum = migrationChecksum(upContent);
|
||||
if (!latestResult.rows[0].checksum) {
|
||||
throw new Error(
|
||||
`Migration ${latestVersion} has no checksum. Run the up command once to establish the legacy baseline before rollback.`,
|
||||
);
|
||||
}
|
||||
assertMigrationChecksum(
|
||||
latestVersion,
|
||||
String(latestResult.rows[0].checksum),
|
||||
currentChecksum,
|
||||
);
|
||||
const sql = await readFile(path.join(migrationsDirectory, downFile), 'utf8');
|
||||
await applyTransaction(async () => {
|
||||
await client.query(sql);
|
||||
|
|
|
|||
6
scripts/migration-checksum.d.mts
Normal file
6
scripts/migration-checksum.d.mts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function migrationChecksum(content: Uint8Array): string;
|
||||
export function assertMigrationChecksum(version: string, expected: string, actual: string): void;
|
||||
export function assertAppliedMigrationsPresent(
|
||||
appliedVersions: Iterable<string>,
|
||||
availableVersions: Iterable<string>,
|
||||
): void;
|
||||
22
scripts/migration-checksum.mjs
Normal file
22
scripts/migration-checksum.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
|
||||
export function migrationChecksum(content) {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
export function assertMigrationChecksum(version, expected, actual) {
|
||||
if (expected !== actual) {
|
||||
throw new Error(
|
||||
`Applied migration ${version} was modified: expected sha256 ${expected}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAppliedMigrationsPresent(appliedVersions, availableVersions) {
|
||||
const available = new Set(availableVersions);
|
||||
for (const version of appliedVersions) {
|
||||
if (!available.has(version)) {
|
||||
throw new Error(`Applied migration ${version} is missing from the repository`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,15 +6,23 @@ const configSchema = z.object({
|
|||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
|
||||
STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
PLATFORM_GATEWAY_URL: z.string().url().optional(),
|
||||
PLATFORM_JWT_ISSUER: z.string().url().optional(),
|
||||
PLATFORM_JWT_AUDIENCE: z.string().optional(),
|
||||
PLATFORM_JWKS_URL: z.string().url().optional(),
|
||||
REDIS_URL: optionalUrl(),
|
||||
PLATFORM_GATEWAY_URL: optionalUrl(),
|
||||
PLATFORM_JWT_ISSUER: optionalUrl(),
|
||||
PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()),
|
||||
PLATFORM_JWKS_URL: optionalUrl(),
|
||||
STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||
});
|
||||
|
||||
function emptyStringAsUndefined<T extends z.ZodTypeAny>(schema: T) {
|
||||
return z.preprocess((value) => (value === '' ? undefined : value), schema);
|
||||
}
|
||||
|
||||
function optionalUrl() {
|
||||
return emptyStringAsUndefined(z.string().url().optional());
|
||||
}
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>;
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
|
|
|
|||
|
|
@ -18,4 +18,14 @@ describe('runtime modes', () => {
|
|||
expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow(
|
||||
/standalone is not allowed/,
|
||||
));
|
||||
it('treats empty optional platform variables as absent in compose', () =>
|
||||
expect(
|
||||
loadConfig({
|
||||
...base,
|
||||
PLATFORM_GATEWAY_URL: '',
|
||||
PLATFORM_JWT_ISSUER: '',
|
||||
PLATFORM_JWT_AUDIENCE: '',
|
||||
PLATFORM_JWKS_URL: '',
|
||||
}).PLATFORM_GATEWAY_URL,
|
||||
).toBeUndefined());
|
||||
});
|
||||
|
|
|
|||
27
tests/unit/deployment.test.ts
Normal file
27
tests/unit/deployment.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
describe('container deployment boundaries', () => {
|
||||
const compose = parse(readFileSync('compose.yaml', 'utf8')) as {
|
||||
services: Record<string, { environment?: Record<string, string>; depends_on?: unknown }>;
|
||||
};
|
||||
|
||||
it('probes the versioned liveness route', () => {
|
||||
expect(readFileSync('Dockerfile', 'utf8')).toContain('/api/v1/staff/live');
|
||||
expect(readFileSync('Dockerfile', 'utf8')).not.toMatch(/8080\/live\b/);
|
||||
});
|
||||
|
||||
it('does not expose migration credentials to the runtime service', () => {
|
||||
const environment = compose.services.app?.environment ?? {};
|
||||
expect(environment).not.toHaveProperty('DATABASE_MIGRATION_URL');
|
||||
expect(environment).not.toHaveProperty('POSTGRES_MIGRATION_PASSWORD');
|
||||
expect(environment).not.toHaveProperty('POSTGRES_APP_PASSWORD');
|
||||
});
|
||||
|
||||
it('provisions the app role and migrates before app startup', () => {
|
||||
expect(compose.services['db-role']).toBeDefined();
|
||||
expect(compose.services.migrate?.depends_on).toHaveProperty('db-role');
|
||||
expect(compose.services.app?.depends_on).toHaveProperty('migrate');
|
||||
});
|
||||
});
|
||||
27
tests/unit/migration-checksum.test.ts
Normal file
27
tests/unit/migration-checksum.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
assertAppliedMigrationsPresent,
|
||||
assertMigrationChecksum,
|
||||
migrationChecksum,
|
||||
} from '../../scripts/migration-checksum.mjs';
|
||||
|
||||
describe('migration checksums', () => {
|
||||
it('is stable for the exact migration bytes', () => {
|
||||
const content = Buffer.from('SELECT 1;\n', 'utf8');
|
||||
expect(migrationChecksum(content)).toBe(migrationChecksum(content));
|
||||
});
|
||||
|
||||
it('rejects a modified applied migration', () => {
|
||||
const expected = migrationChecksum(Buffer.from('SELECT 1;\n', 'utf8'));
|
||||
const actual = migrationChecksum(Buffer.from('SELECT 2;\n', 'utf8'));
|
||||
expect(() => assertMigrationChecksum('0001', expected, actual)).toThrow(
|
||||
/Applied migration 0001 was modified/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an applied migration removed from the repository', () => {
|
||||
expect(() => assertAppliedMigrationsPresent(['0001', '0002'], ['0002'])).toThrow(
|
||||
/Applied migration 0001 is missing/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue