VEU.WathBody/3-app/2-cli/Server.WatchBody.Fetch/GarminClient.cs

246 lines
12 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 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;
/// <summary>Неуспех SSO-логина (шаги 1–5; MFA/блокировка). Текст без
/// пароля (🛡4).</summary>
public class GarminAuthException(string message) : Exception(message);
/// <summary>Одна активность из списка Garmin Connect (spec 01 §1).</summary>
public sealed record GarminActivity(long ActivityId, DateTime StartTimeLocal, string ActivityType);
/// <summary>
/// Неофициальный клиент Garmin Connect — по контракту семьи библиотек
/// `garminconnect` (tech-req разд. 5): SSO-логин → OAuth1 → Bearer;
/// список активностей и скачивание .fit. HttpMessageHandler инъецируется
/// (тесты — FakeGarminHandler). 🛡 пароль/токены не логируются.
/// </summary>
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;
}
/// <summary>SSO-последовательность (spec 01 §2, шаги 1–5).</summary>
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<string, string>
{
["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 отсутствует");
}
/// <summary>Список активностей (шаг 6).</summary>
public List<GarminActivity> 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();
}
/// <summary>Байты .fit активности (шаг 7).</summary>
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<string, string> 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()));
}
/// <summary>OAuth1 Authorization-заголовок (HMAC-SHA1) для обменного
/// шага (по контракту garminconnect).</summary>
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<string, string>
{
["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)}\"";
}
}
/// <summary>Фейковый Garmin для тестов (spec 01 §3): маршруты spec 01 §2
/// на подменяемом base; список/байты задаёт тест.</summary>
public class FakeGarminHandler : HttpMessageHandler
{
public CookieContainer? Cookies { get; set; }
public bool FailLogin { get; set; }
public List<GarminActivity> Activities { get; set; } = [];
public Dictionary<long, byte[]> FitFiles { get; set; } = [];
public int LoginCalls, ListCalls, DownloadCalls;
protected override Task<HttpResponseMessage> 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(
"<input name=\"_csrf\" value=\"csrf-token-1\" type=\"hidden\"/>");
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<string, object?>
{
["activityId"] = a.ActivityId,
["startTimeLocal"] = a.StartTimeLocal.ToString(
"yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["activityType"] = new Dictionary<string, object?>
{ ["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);
}
}