krona-staff/apps/KronaStaffTerminal/Services/AttendanceSyncService.cs
Ochenstarik 3464998168
Some checks are pending
ci / verify (push) Waiting to run
feat: add secure attendance portal and payroll foundation
2026-09-24 15:59:38 +07:00

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