90 lines
4 KiB
C#
90 lines
4 KiB
C#
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());
|
||
}
|