using KronaStaffTerminal.Models; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; namespace KronaStaffTerminal.Services; public sealed class AttendanceSyncService { private const string PilotHost = "192.168.1.81"; private const int PilotPort = 8453; public async Task SyncPendingAsync(TerminalState state, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(state.Settings.StaffServerToken) || string.IsNullOrWhiteSpace(state.Settings.StaffServerCertificateSha256)) { return; } var endpoint = BuildEndpoint(state.Settings.StaffServerUrl); var expectedCertificate = NormalizeFingerprint(state.Settings.StaffServerCertificateSha256); using var handler = new HttpClientHandler { AllowAutoRedirect = false }; handler.ServerCertificateCustomValidationCallback = (_, certificate, _, _) => CertificateMatches(certificate, expectedCertificate); using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(12) }; foreach (var record in state.Attendance .Where(item => !item.ServerSynced && item.EventType is "Приход" or "Уход") .OrderBy(item => item.OccurredAtUtc)) { try { using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", state.Settings.StaffServerToken); request.Content = JsonContent.Create(new { event_id = record.Id, employee_id = record.EmployeeId, event_type = record.EventType == "Приход" ? "check_in" : "check_out", occurred_at = record.OccurredAtUtc, source_event_id = record.Id, device_id = state.Settings.TerminalDeviceId }); using var response = await client.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { record.ServerSyncError = $"HTTP {(int)response.StatusCode}"; continue; } record.ServerSynced = true; record.ServerSyncError = null; } catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException) { record.ServerSyncError = "Нет защищённой связи с локальным сервером"; } } } private static Uri BuildEndpoint(string serverUrl) { if (!Uri.TryCreate(serverUrl.Trim().TrimEnd('/'), UriKind.Absolute, out var root) || root.Scheme != Uri.UriSchemeHttps || !string.Equals(root.Host, PilotHost, StringComparison.Ordinal) || root.Port != PilotPort || !string.IsNullOrEmpty(root.Query) || !string.IsNullOrEmpty(root.Fragment)) { throw new InvalidOperationException( $"Пилот разрешает только https://{PilotHost}:{PilotPort} без перенаправлений."); } return new Uri(root, "/api/v1/staff/attendance/events"); } private static bool CertificateMatches(X509Certificate2? certificate, string expectedFingerprint) { if (certificate is null || expectedFingerprint.Length != 64) return false; var actual = Convert.ToHexString(SHA256.HashData(certificate.RawData)); return CryptographicOperations.FixedTimeEquals( Convert.FromHexString(actual), Convert.FromHexString(expectedFingerprint)); } private static string NormalizeFingerprint(string value) => new(value.Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray()); }