using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Server.WatchBody.Fetch;
/// Неуспех SSO-логина (шаги 1–5; MFA/блокировка). Текст без
/// пароля (🛡4).
public class GarminAuthException(string message) : Exception(message);
/// Одна активность из списка Garmin Connect (spec 01 §1).
public sealed record GarminActivity(long ActivityId, DateTime StartTimeLocal, string ActivityType);
///
/// Неофициальный клиент Garmin Connect — по контракту семьи библиотек
/// `garminconnect` (tech-req разд. 5): SSO-логин → OAuth1 → Bearer;
/// список активностей и скачивание .fit. HttpMessageHandler инъецируется
/// (тесты — FakeGarminHandler). 🛡 пароль/токены не логируются.
///
public class GarminClient
{
// consumer-пара семьи garminconnect (прототип; при блокировке —
// решение ОВ этапа 7, fallback — папка импорта)
public const string ConsumerKey = "C2JDomY7p9eCA2R5OsBTb3tS6aIn9k2";
public const string ConsumerSecret = "O45MeVZGRDTv6qnKZaZjfMndAQ7Qu9FTH7IHZP1YmBySpTOd";
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
private readonly string _login, _password, _base;
private readonly HttpClient _http;
private readonly CookieContainer _cookies;
private string? _bearer;
public GarminClient(string login, string password, string baseUrl,
HttpMessageHandler? handler = null)
{
_login = login;
_password = password;
_base = baseUrl.TrimEnd('/');
_cookies = new CookieContainer();
_http = new HttpClient(handler ?? new HttpClientHandler
{
CookieContainer = _cookies,
AllowAutoRedirect = false,
})
{ Timeout = Timeout };
if (handler == null) return;
// фейковый handler ведёт куки сам — отдадим ему контейнер через свойства
if (handler is FakeGarminHandler fake) fake.Cookies = _cookies;
}
/// SSO-последовательность (spec 01 §2, шаги 1–5).
public void Login(CancellationToken ct = default)
{
Get($"{_base}/sso/embed", ct); // 1
var signinHtml = Get($"{_base}/sso/signin", ct); // 2
var csrf = Regex.Match(signinHtml, "name=\"_csrf\"\\s+value=\"([^\"]+)\"").Groups[1].Value
?? throw new GarminAuthException("signin: csrf не найден");
var (status, body) = PostForm($"{_base}/sso/signin", new Dictionary
{
["username"] = _login,
["password"] = _password,
["embed"] = "true",
["_csrf"] = csrf,
}, ct); // 3
var ticket = Regex.Match(body, "[?&]ticket=([^&\"']+)", RegexOptions.IgnoreCase).Groups[1].Value;
if (status != HttpStatusCode.OK && status != HttpStatusCode.Found || ticket.Length == 0)
throw new GarminAuthException($"signin не удался (HTTP {(int)status}; возможны MFA/блокировка)");
Get($"{_base}/modern/?ticket={Uri.EscapeDataString(ticket)}", ct); // 4
// 5: OAuth1-обмен → Bearer
var oauth = OAuth1Header("POST", $"{_base}/sso/api/2.0/oauth/exchange/service/user/2.0");
var (exStatus, exBody) = PostAuthorized(
$"{_base}/sso/api/2.0/oauth/exchange/service/user/2.0", oauth, ct);
if (exStatus != HttpStatusCode.OK)
throw new GarminAuthException($"oauth exchange не удался (HTTP {(int)exStatus})");
using var json = JsonDocument.Parse(exBody);
_bearer = json.RootElement.TryGetProperty("access_token", out var token)
? token.GetString()
: throw new GarminAuthException("oauth exchange: access_token отсутствует");
}
/// Список активностей (шаг 6).
public List ListActivities(int limit, CancellationToken ct = default)
{
var list = Get($"{_base}/activitylist-service/activities/search/activities" +
$"?limit={limit}&start=0", ct, authorized: true);
using var json = JsonDocument.Parse(list);
return json.RootElement.EnumerateArray()
.Where(a => a.TryGetProperty("activityId", out _))
.Select(a => new GarminActivity(
a.GetProperty("activityId").GetInt64(),
a.TryGetProperty("startTimeLocal", out var t) &&
DateTime.TryParse(t.GetString(), CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed) ? parsed : default,
a.TryGetProperty("activityType", out var type) &&
type.TryGetProperty("typeKey", out var key)
? key.GetString() ?? "" : ""))
.ToList();
}
/// Байты .fit активности (шаг 7).
public byte[] DownloadFit(long activityId, CancellationToken ct = default) =>
_http.GetByteArrayAsync(
$"{_base}/download-service/files/activity/{activityId}", ct)
.GetAwaiter().GetResult();
// ---------- низкоуровневые ----------
private string Get(string url, CancellationToken ct, bool authorized = false)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (authorized) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _bearer);
var response = Send(request, ct);
return response.Content.ReadAsStringAsync(ct).GetAwaiter().GetResult();
}
private (HttpStatusCode Status, string Body) PostForm(
string url, Dictionary fields, CancellationToken ct)
{
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new FormUrlEncodedContent(fields),
};
var response = Send(request, ct);
return (response.StatusCode,
response.Content.ReadAsStringAsync(ct).GetAwaiter().GetResult());
}
private (HttpStatusCode Status, string Body) PostAuthorized(
string url, string oauthHeader, CancellationToken ct)
{
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.TryAddWithoutValidation("Authorization", oauthHeader);
var response = Send(request, ct);
return (response.StatusCode,
response.Content.ReadAsStringAsync(ct).GetAwaiter().GetResult());
}
private HttpResponseMessage Send(HttpRequestMessage request, CancellationToken ct)
{
var response = _http.SendAsync(request, ct).GetAwaiter().GetResult();
foreach (var cookie in response.Headers
.Where(h => h.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase))
.SelectMany(h => h.Value))
SaveCookie(request.RequestUri!, cookie);
return response;
}
private void SaveCookie(Uri uri, string setCookie)
{
var parts = setCookie.Split(';', 2)[0].Split('=', 2);
if (parts.Length == 2)
_cookies.Add(uri, new Cookie(parts[0].Trim(), parts[1].Trim()));
}
/// OAuth1 Authorization-заголовок (HMAC-SHA1) для обменного
/// шага (по контракту garminconnect).
private string OAuth1Header(string method, string url)
{
var nonce = Guid.NewGuid().ToString("N");
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
.ToString(CultureInfo.InvariantCulture);
var parameters = new SortedDictionary
{
["oauth_consumer_key"] = ConsumerKey,
["oauth_nonce"] = nonce,
["oauth_signature_method"] = "HMAC-SHA1",
["oauth_timestamp"] = timestamp,
["oauth_version"] = "1.0",
};
var baseString = $"{method}&{Uri.EscapeDataString(url)}&" +
Uri.EscapeDataString(string.Join("&",
parameters.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")));
var key = $"{Uri.EscapeDataString(ConsumerSecret)}&";
string signature;
using (var hmac = new HMACSHA1(Encoding.ASCII.GetBytes(key)))
signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.ASCII.GetBytes(baseString)));
return "OAuth " + string.Join(", ",
parameters.Select(p => $"{p.Key}=\"{Uri.EscapeDataString(p.Value)}\"")) +
$", oauth_signature=\"{Uri.EscapeDataString(signature)}\"";
}
}
/// Фейковый Garmin для тестов (spec 01 §3): маршруты spec 01 §2
/// на подменяемом base; список/байты задаёт тест.
public class FakeGarminHandler : HttpMessageHandler
{
public CookieContainer? Cookies { get; set; }
public bool FailLogin { get; set; }
public List Activities { get; set; } = [];
public Dictionary FitFiles { get; set; } = [];
public int LoginCalls, ListCalls, DownloadCalls;
protected override Task SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
var response = new HttpResponseMessage(HttpStatusCode.OK);
if (url.EndsWith("/sso/embed")) { /* шаг 1 — cookies выставит Send-обвязка */ }
else if (url.EndsWith("/sso/signin") && request.Method == HttpMethod.Get)
response.Content = new StringContent(
"");
else if (url.EndsWith("/sso/signin"))
{
LoginCalls++;
if (FailLogin)
{
response.StatusCode = HttpStatusCode.Unauthorized;
response.Content = new StringContent("auth failed");
}
else response.Content = new StringContent(
"…?ticket=ST-123456-fake&…");
}
else if (url.Contains("/modern/?ticket=")) { /* шаг 4 */ }
else if (url.Contains("/oauth/exchange"))
response.Content = new StringContent(
"""{"access_token":"fake-bearer-token","expires_in":3599}""");
else if (url.Contains("/activitylist-service"))
{
ListCalls++;
response.Content = new StringContent(
JsonSerializer.Serialize(Activities.Select(a => new Dictionary
{
["activityId"] = a.ActivityId,
["startTimeLocal"] = a.StartTimeLocal.ToString(
"yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["activityType"] = new Dictionary
{ ["typeKey"] = a.ActivityType },
})));
}
else if (url.Contains("/download-service/files/activity/"))
{
DownloadCalls++;
var id = long.Parse(url.Split('/').Last());
response.Content = new ByteArrayContent(FitFiles[id]);
}
else response.StatusCode = HttpStatusCode.NotFound;
return Task.FromResult(response);
}
}