From 9763534d28c03febbd58c7e60fb93ff0a310995a Mon Sep 17 00:00:00 2001 From: Bailey Date: Sat, 17 Aug 2024 14:17:54 -0500 Subject: [PATCH] [585] feat: add background sync support for garmin mfa users (#622) * [585] feat: add background sync support for garmin mfa users * clean up some dependencies * testing and hardening, also add mfa prompt when sync toggled from ui * some cleanup * rebase * uplifting to latest flurl, still need to work through serialization issues * fix happy path upload and conflict upload * mfa appears to be working with saved tokens * clean up some things * fix unit tests * update version and release notes * documentation updates * fix some startup logging * couple tweaks * bump some dependencies --- images/P2G_diagram.drawio | 686 ++++++++---------- mkdocs/docs/configuration/index.md | 4 +- mkdocs/docs/faq.md | 14 +- src/Api.Contract/SyncContracts.cs | 11 +- src/Api.Service/Api.Service.csproj | 2 +- src/Api.Service/ApiStartupServices.cs | 9 +- src/Api.Service/BackgroundSyncJob.cs | 60 +- src/Api.Service/SettingsUpdaterService.cs | 25 +- src/Api.Service/Validators/SyncValidators.cs | 7 +- .../GarminAuthenticationController.cs | 12 +- src/Api/Controllers/SettingsController.cs | 5 +- src/Api/Controllers/SyncController.cs | 15 +- src/Api/Program.cs | 2 - src/ClientUI/ServiceClient.cs | 24 +- src/Common/Common.csproj | 2 +- src/Common/Constants.cs | 2 +- src/Common/Database/DbBase.cs | 2 +- src/Common/Database/DbMigrations.cs | 13 +- src/Common/Database/Extensions.cs | 22 + src/Common/Database/SettingsDb.cs | 2 +- src/Common/Dto/Peloton/UserData.cs | 2 +- src/Common/Dto/ServiceResult.cs | 4 +- src/Common/FileHandling.cs | 25 +- src/Common/Helpers/Encryption.cs | 4 +- src/Common/Http/FlurlConfiguration.cs | 46 +- src/Common/Http/PollyHttpClientFactory.cs | 18 - src/Common/ObservabilityStartup.cs | 10 +- .../Service/FileBasedSettingsService.cs | 21 - src/Common/Service/ISettingsService.cs | 4 - src/Common/Service/SettingsService.cs | 38 - .../Stateful/GarminApiAuthentication.cs | 37 - src/Common/Stateful/IApiAuthentication.cs | 12 - .../Stateful/PelotonApiAuthentication.cs | 2 +- src/ConsoleClient/ConsoleClient.csproj | 5 +- src/ConsoleClient/Program.cs | 3 + src/ConsoleClient/Startup.cs | 95 ++- src/Garmin/ApiClient.cs | 37 +- .../Auth/GarminAuthenticationService.cs | 174 +++-- src/Garmin/Database/GarminDb.cs | 195 +++++ src/Garmin/Defaults.cs | 6 + src/Garmin/Dto/GarminApiAuthentication.cs | 21 + src/Garmin/Dto/OAuth1Token.cs | 7 + .../Stateful => Garmin/Dto}/OAuth2Token.cs | 10 +- src/Garmin/Dto/P2GGarminData.cs | 23 + src/Garmin/Dto/UploadResponse.cs | 21 +- src/Garmin/GarminUploader.cs | 16 +- src/Peloton/ApiClient.cs | 12 +- src/Peloton/Peloton.csproj | 2 +- src/Peloton/PelotonService.cs | 35 +- src/SharedUI/Pages/Index.razor | 22 +- src/SharedUI/Shared/AppSettingsForm.razor | 17 + src/SharedUI/Shared/GarminMfaModal.razor | 26 +- src/{Common => Sync}/Database/SyncStatusDb.cs | 155 ++-- .../Dto}/SyncServiceStatus.cs | 6 +- src/Sync/SyncService.cs | 24 +- src/UnitTests/AdHocTests.cs | 22 +- .../SettingsUpdaterServiceTests.cs | 84 ++- .../Api/Controllers/SyncControllerTests.cs | 24 +- .../Common/Database/DbMigrationsTests.cs | 3 +- .../Common/Helpers/WorkoutHelperTests.cs | 1 + src/UnitTests/Conversion/FitConverterTests.cs | 2 +- src/UnitTests/Peloton/PelotonServiceTests.cs | 20 +- src/UnitTests/Sync/SyncServiceTests.cs | 3 +- src/UnitTests/UnitTests.csproj | 10 +- src/WebUI/WebUI.csproj | 4 +- vNextReleaseNotes.md | 10 +- 66 files changed, 1218 insertions(+), 1019 deletions(-) create mode 100644 src/Common/Database/Extensions.cs delete mode 100644 src/Common/Stateful/GarminApiAuthentication.cs delete mode 100644 src/Common/Stateful/IApiAuthentication.cs create mode 100644 src/Garmin/Database/GarminDb.cs create mode 100644 src/Garmin/Defaults.cs create mode 100644 src/Garmin/Dto/GarminApiAuthentication.cs create mode 100644 src/Garmin/Dto/OAuth1Token.cs rename src/{Common/Stateful => Garmin/Dto}/OAuth2Token.cs (63%) create mode 100644 src/Garmin/Dto/P2GGarminData.cs rename src/{Common => Sync}/Database/SyncStatusDb.cs (67%) rename src/{Common/Database => Sync/Dto}/SyncServiceStatus.cs (74%) diff --git a/images/P2G_diagram.drawio b/images/P2G_diagram.drawio index 4b178b1ca..281676ce1 100644 --- a/images/P2G_diagram.drawio +++ b/images/P2G_diagram.drawio @@ -1,372 +1,314 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/docs/configuration/index.md b/mkdocs/docs/configuration/index.md index 9e51b94d6..609e5867a 100644 --- a/mkdocs/docs/configuration/index.md +++ b/mkdocs/docs/configuration/index.md @@ -9,7 +9,7 @@ ## Web UI Configuration -Most of the most common settings can be configured via the UI itself. Additional lower level settings can be provided via config file. +The most common settings can be configured via the UI itself. Additional lower level settings can be provided via config file. 1. Settings 1. [App Settings](app.md) @@ -22,7 +22,7 @@ Most of the most common settings can be configured via the UI itself. Additiona ## Windows UI Configuration -Most of the most common settings can be configured via the UI itself. +The most common settings can be configured via the UI itself. 1. Settings 1. [App Settings](app.md) diff --git a/mkdocs/docs/faq.md b/mkdocs/docs/faq.md index 02c588047..a35bd05a1 100644 --- a/mkdocs/docs/faq.md +++ b/mkdocs/docs/faq.md @@ -2,18 +2,10 @@ Below are a list of commonly asked questions. For even more help head on over to the [discussion forum](https://github.com/philosowaffle/peloton-to-garmin/discussions). -## VO2 Max and TSS +## VO2 Max, TE, TSS and more... -Garmin _unlocks_ certain workout metrics and fields based on the Garmin device you personally own, one of those metrics is VO2 Max. This means that if your personal device supports VO2 Max calucations, then your Peloton workouts will also generate VO2 Max when using the default P2G device settings. If your personal device does not support VO2 Max calculations, then unfortunately your Peloton workouts will also not generate any VO2 Max data. - -You can check the [Owners Manual](https://support.garmin.com/en-US/ql/?focus=manuals) for your personal device to see if it already supports the VO2 max field. - -Garmin will only generate a VO2 max for your workouts if all of the following criteria are met: - -1. Your personal Garmin device already supports VO2 Max Calculations -1. The workout is associated with an [allowed device](https://support.garmin.com/en-US/?faq=lWqSVlq3w76z5WoihLy5f8) -1. You have met all of [Garmin's VO2 requirements](https://support.garmin.com/en-US/?faq=lWqSVlq3w76z5WoihLy5f8) for your workout type +Checkout the dedicated [Category](https://github.com/philosowaffle/peloton-to-garmin/discussions/categories/te-tss-vo2-intensity-minutes) in the Discussion forum, I recommend starting with [this post](https://github.com/philosowaffle/peloton-to-garmin/discussions/654). ## Garmin Two Step Verification -Only some [install options have support](install/index.md) for Garmin Two Step Verification. In all cases, automatic-syncing is never supported when your Garmin account is protected by two step verification. +Yes, P2G supports Garmin's Multi-factor Authentication option! However,oOnly some [install options have support](install/index.md) so you'll be limited to one of these options. diff --git a/src/Api.Contract/SyncContracts.cs b/src/Api.Contract/SyncContracts.cs index f9a108a07..e49d8352c 100644 --- a/src/Api.Contract/SyncContracts.cs +++ b/src/Api.Contract/SyncContracts.cs @@ -1,6 +1,4 @@ -using Common.Database; - -namespace Api.Contract; +namespace Api.Contract; public record SyncGetResponse { @@ -25,6 +23,13 @@ public string AutoSyncHealthString public DateTime? NextSyncTime { get; init; } } +public enum Status : byte +{ + NotRunning = 0, + Running = 1, + UnHealthy = 2, + Dead = 3 +} public record SyncPostRequest { public SyncPostRequest() diff --git a/src/Api.Service/Api.Service.csproj b/src/Api.Service/Api.Service.csproj index 21a087bfb..2ee7d3c42 100644 --- a/src/Api.Service/Api.Service.csproj +++ b/src/Api.Service/Api.Service.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Api.Service/ApiStartupServices.cs b/src/Api.Service/ApiStartupServices.cs index d30e37d5c..cc88279d4 100644 --- a/src/Api.Service/ApiStartupServices.cs +++ b/src/Api.Service/ApiStartupServices.cs @@ -12,6 +12,8 @@ using Api.Services; using Garmin.Auth; using Api.Service; +using Garmin.Database; +using Sync.Database; namespace SharedStartup; @@ -33,9 +35,10 @@ public static void ConfigureP2GApiServices(this IServiceCollection services) // GARMIN services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - - // IO + services.AddSingleton(); + services.AddSingleton(); + + // IO services.AddSingleton(); // MIGRATIONS diff --git a/src/Api.Service/BackgroundSyncJob.cs b/src/Api.Service/BackgroundSyncJob.cs index d968fadec..7ba955c55 100644 --- a/src/Api.Service/BackgroundSyncJob.cs +++ b/src/Api.Service/BackgroundSyncJob.cs @@ -1,16 +1,18 @@ -using Common.Database; -using Common.Dto; +using Common.Dto; using Common.Observe; using Common.Service; using Common.Stateful; +using Garmin.Auth; using Microsoft.Extensions.Hosting; using Prometheus; using Sync; +using Sync.Database; +using Sync.Dto; using static Common.Observe.Metrics; using ILogger = Serilog.ILogger; using PromMetrics = Prometheus.Metrics; -namespace Api.Services; +namespace Api.Service; public class BackgroundSyncJob : BackgroundService { @@ -23,12 +25,13 @@ public class BackgroundSyncJob : BackgroundService private readonly ISettingsService _settingsService; private readonly ISyncStatusDb _syncStatusDb; private readonly ISyncService _syncService; - + private readonly IGarminAuthenticationService _garminAuthService; + private bool? _previousPollingState; private Settings _config; - public BackgroundSyncJob(ISettingsService settingsService,ISyncStatusDb syncStatusDb, ISyncService syncService) + public BackgroundSyncJob(ISettingsService settingsService, ISyncStatusDb syncStatusDb, ISyncService syncService, IGarminAuthenticationService garminAuthService) { _settingsService = settingsService; _syncStatusDb = syncStatusDb; @@ -37,6 +40,7 @@ public BackgroundSyncJob(ISettingsService settingsService,ISyncStatusDb syncStat _syncService = syncService; _config = new Settings(); + _garminAuthService = garminAuthService; } protected override Task ExecuteAsync(CancellationToken stoppingToken) @@ -49,13 +53,6 @@ private async Task RunAsync(CancellationToken stoppingToken) { _config = await _settingsService.GetSettingsAsync(); - if (_config.Garmin.Upload && _config.Garmin.TwoStepVerificationEnabled && _config.App.EnablePolling) - { - _logger.Error("Background Sync cannot be enabled when Garmin TwoStepVerification is enabled."); - _logger.Information("Sync Service stopped."); - return; - } - SyncServiceState.Enabled = _config.App.EnablePolling; SyncServiceState.PollingIntervalSeconds = _config.App.PollingIntervalSeconds; @@ -63,8 +60,15 @@ private async Task RunAsync(CancellationToken stoppingToken) { int stepIntervalSeconds = 5; - if (await NotPollingAsync()) + if (await PollingDisabled()) + { + Thread.Sleep(stepIntervalSeconds * 1000); + continue; + } + + if (await NeedToWaitForMFAToBeCompletedAsync()) { + _logger.Information("Can't start background syncing until MFA flow is completed for the first time."); Thread.Sleep(stepIntervalSeconds * 1000); continue; } @@ -72,8 +76,8 @@ private async Task RunAsync(CancellationToken stoppingToken) await SyncAsync(); _logger.Information("Sleeping for {@Seconds} seconds...", SyncServiceState.PollingIntervalSeconds); - - for (int i = 1; i < SyncServiceState.PollingIntervalSeconds; i+=stepIntervalSeconds) + + for (int i = 1; i < SyncServiceState.PollingIntervalSeconds; i += stepIntervalSeconds) { Thread.Sleep(stepIntervalSeconds * 1000); if (await StateChangedAsync()) break; @@ -92,9 +96,9 @@ private async Task StateChangedAsync() return _previousPollingState != SyncServiceState.Enabled; } - private async Task NotPollingAsync() + private async Task PollingDisabled() { - using var tracing = Tracing.Trace($"{nameof(BackgroundService)}.{nameof(NotPollingAsync)}"); + using var tracing = Tracing.Trace($"{nameof(BackgroundService)}.{nameof(PollingDisabled)}"); if (await StateChangedAsync()) { @@ -111,6 +115,17 @@ private async Task NotPollingAsync() return !SyncServiceState.Enabled; } + private async Task NeedToWaitForMFAToBeCompletedAsync() + { + _config = await _settingsService.GetSettingsAsync(); + if (_config.Garmin.TwoStepVerificationEnabled) + { + var alreadyHaveToken = await _garminAuthService.GarminAuthTokenExistsAndIsValidAsync(); + return !alreadyHaveToken; + } + return false; + } + private async Task SyncAsync() { using var tracing = Tracing.Trace($"{nameof(BackgroundService)}.{nameof(SyncAsync)}"); @@ -118,19 +133,22 @@ private async Task SyncAsync() try { var result = await _syncService.SyncAsync(_config.Peloton.NumWorkoutsToDownload); - if(result.SyncSuccess) + if (result.SyncSuccess) { Health.Set(HealthStatus.Healthy); - } else + } + else { Health.Set(HealthStatus.UnHealthy); } - } catch (Exception e) + } + catch (Exception e) { _logger.Error(e, "Uncaught Exception."); - } finally + } + finally { var now = DateTime.UtcNow; var nextRunTime = now.AddSeconds(_config.App.PollingIntervalSeconds); diff --git a/src/Api.Service/SettingsUpdaterService.cs b/src/Api.Service/SettingsUpdaterService.cs index f8169f1fe..a8731ddd9 100644 --- a/src/Api.Service/SettingsUpdaterService.cs +++ b/src/Api.Service/SettingsUpdaterService.cs @@ -2,7 +2,7 @@ using Common; using Common.Dto; using Common.Service; -using Common.Stateful; +using Garmin.Auth; namespace Api.Service; @@ -17,11 +17,13 @@ public class SettingsUpdaterService : ISettingsUpdaterService { private readonly IFileHandling _fileHandler; private readonly ISettingsService _settingsService; + private readonly IGarminAuthenticationService _garminAuthService; - public SettingsUpdaterService(IFileHandling fileHandler, ISettingsService settingsService) + public SettingsUpdaterService(IFileHandling fileHandler, ISettingsService settingsService, IGarminAuthenticationService garminAuthService) { _fileHandler = fileHandler; _settingsService = settingsService; + _garminAuthService = garminAuthService; } public async Task> UpdateAppSettingsAsync(App updatedAppSettings) @@ -38,13 +40,6 @@ public async Task> UpdateAppSettingsAsync(App updatedAppSetti var settings = await _settingsService.GetSettingsAsync(); settings.App = updatedAppSettings; - if (settings.Garmin.TwoStepVerificationEnabled && settings.App.EnablePolling) - { - result.Successful = false; - result.Error = new ServiceError() { Message = "Automatic Syncing cannot be enabled when Garmin TwoStepVerification is enabled." }; - return result; - } - await _settingsService.UpdateSettingsAsync(settings); var updatedSettings = await _settingsService.GetSettingsAsync(); @@ -93,14 +88,12 @@ public async Task> UpdateGarminSettings } var settings = await _settingsService.GetSettingsAsync(); - settings.Garmin = updatedGarminSettings.Map(); - if (settings.Garmin.Upload && settings.Garmin.TwoStepVerificationEnabled && settings.App.EnablePolling) - { - result.Successful = false; - result.Error = new ServiceError() { Message = "Garmin TwoStepVerification cannot be enabled while Automatic Syncing is enabled. Please disable Automatic Syncing first." }; - return result; - } + if (settings.Garmin.Password != updatedGarminSettings.Password + || settings.Garmin.Email != updatedGarminSettings.Email) + await _garminAuthService.SignOutAsync(); + + settings.Garmin = updatedGarminSettings.Map(); await _settingsService.UpdateSettingsAsync(settings); var updatedSettings = await _settingsService.GetSettingsAsync(); diff --git a/src/Api.Service/Validators/SyncValidators.cs b/src/Api.Service/Validators/SyncValidators.cs index 6add5399c..f9b13ada9 100644 --- a/src/Api.Service/Validators/SyncValidators.cs +++ b/src/Api.Service/Validators/SyncValidators.cs @@ -1,8 +1,7 @@ using Api.Contract; using Api.Service.Helpers; -using Common; using Common.Dto; -using Common.Stateful; +using Garmin.Dto; using Microsoft.AspNetCore.Mvc; namespace Api.Service.Validators; @@ -16,7 +15,7 @@ public static (bool, ErrorResponse?) IsValid(this SyncPostRequest request, Setti if (settings.Garmin.Upload && settings.Garmin.TwoStepVerificationEnabled) { - if (garminAuth is null || !garminAuth.IsValid(settings)) + if (garminAuth is null || !garminAuth.IsValid()) { result = new ErrorResponse("Must initialize Garmin two factor auth token before sync can be preformed.", ErrorCode.NeedToInitGarminMFAAuth); return (false, result); @@ -38,7 +37,7 @@ public static (bool, ActionResult?) IsValidHttp(this SyncPostRequest request, Se if (settings.Garmin.Upload && settings.Garmin.TwoStepVerificationEnabled) { - if (garminAuth is null || !garminAuth.IsValid(settings)) + if (garminAuth is null || !garminAuth.IsValid()) { result = new UnauthorizedObjectResult(new ErrorResponse("Must initialize Garmin two factor auth token before sync can be preformed.", ErrorCode.NeedToInitGarminMFAAuth)); return (false, result); diff --git a/src/Api/Controllers/GarminAuthenticationController.cs b/src/Api/Controllers/GarminAuthenticationController.cs index 77bea7ff1..4492fc2f8 100644 --- a/src/Api/Controllers/GarminAuthenticationController.cs +++ b/src/Api/Controllers/GarminAuthenticationController.cs @@ -2,6 +2,7 @@ using Api.Service.Helpers; using Common.Service; using Garmin.Auth; +using Garmin.Dto; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers @@ -25,10 +26,9 @@ public GarminAuthenticationController(IGarminAuthenticationService garminAuthSer [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetAsync() { - var settings = await _settingsService.GetSettingsAsync(); - var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email); + var auth = await _garminAuthService.GetGarminAuthenticationAsync(); + var result = new GarminAuthenticationGetResponse() { IsAuthenticated = auth?.IsValid() ?? false }; - var result = new GarminAuthenticationGetResponse() { IsAuthenticated = auth?.IsValid(settings) ?? false }; return Ok(result); } @@ -54,14 +54,14 @@ public async Task SignInAsync() { if (!settings.Garmin.TwoStepVerificationEnabled) { - await _garminAuthService.RefreshGarminAuthenticationAsync(); + await _garminAuthService.SignInAsync(); return Created("api/garminauthentication", new GarminAuthenticationGetResponse() { IsAuthenticated = true }); } else { - var auth = await _garminAuthService.RefreshGarminAuthenticationAsync(); + var auth = await _garminAuthService.SignInAsync(); - if (auth.AuthStage == Common.Stateful.AuthStage.NeedMfaToken) + if (auth.AuthStage == AuthStage.NeedMfaToken) return Accepted(); return Created("api/garminauthentication", new GarminAuthenticationGetResponse() { IsAuthenticated = true }); diff --git a/src/Api/Controllers/SettingsController.cs b/src/Api/Controllers/SettingsController.cs index 217a39140..90910a075 100644 --- a/src/Api/Controllers/SettingsController.cs +++ b/src/Api/Controllers/SettingsController.cs @@ -1,7 +1,6 @@ using Api.Contract; using Api.Service; using Api.Service.Helpers; -using Common; using Common.Dto; using Common.Service; using Microsoft.AspNetCore.Mvc; @@ -15,13 +14,11 @@ namespace Api.Controllers; public class SettingsController : Controller { private readonly ISettingsService _settingsService; - private readonly IFileHandling _fileHandler; private readonly ISettingsUpdaterService _settingsUpdaterService; - public SettingsController(ISettingsService settingsService, IFileHandling fileHandler, ISettingsUpdaterService settingsUpdaterService) + public SettingsController(ISettingsService settingsService, ISettingsUpdaterService settingsUpdaterService) { _settingsService = settingsService; - _fileHandler = fileHandler; _settingsUpdaterService = settingsUpdaterService; } diff --git a/src/Api/Controllers/SyncController.cs b/src/Api/Controllers/SyncController.cs index 16af58c41..d2dd9618c 100644 --- a/src/Api/Controllers/SyncController.cs +++ b/src/Api/Controllers/SyncController.cs @@ -1,9 +1,10 @@ using Api.Contract; using Api.Service.Validators; -using Common.Database; using Common.Service; +using Garmin.Auth; using Microsoft.AspNetCore.Mvc; using Sync; +using Sync.Database; namespace Api.Controllers; @@ -13,15 +14,17 @@ namespace Api.Controllers; [Consumes("application/json")] public class SyncController : Controller { - private readonly ISettingsService _settingsService; + private readonly ISettingsService _settingsService; + private readonly IGarminAuthenticationService _garminAuthService; private readonly ISyncService _syncService; private readonly ISyncStatusDb _db; - public SyncController(ISettingsService settingsService, ISyncService syncService, ISyncStatusDb db) + public SyncController(ISettingsService settingsService, ISyncService syncService, ISyncStatusDb db, IGarminAuthenticationService authService) { _settingsService = settingsService; _syncService = syncService; - _db = db; + _db = db; + _garminAuthService = authService; } /// @@ -41,7 +44,7 @@ public SyncController(ISettingsService settingsService, ISyncService syncService public async Task> SyncAsync([FromBody] SyncPostRequest request) { var settings = await _settingsService.GetSettingsAsync(); - var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email); + var auth = await _garminAuthService.GetGarminAuthenticationAsync(); var (isValid, result) = request.IsValidHttp(settings, auth); if (!isValid) return result!; @@ -91,7 +94,7 @@ public async Task> GetAsync() var response = new SyncGetResponse() { SyncEnabled = settings.App.EnablePolling, - SyncStatus = syncTime.SyncStatus, + SyncStatus = (Status)syncTime.SyncStatus, LastSuccessfulSyncTime = syncTime.LastSuccessfulSyncTime, LastSyncTime = syncTime.LastSyncTime, NextSyncTime = syncTime.NextSyncTime diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 7372a644c..41e1f93c6 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -33,8 +33,6 @@ /////////////////////////////////////////////////////////// /// SERVICES /////////////////////////////////////////////////////////// - -builder.Services.AddHostedService(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); diff --git a/src/ClientUI/ServiceClient.cs b/src/ClientUI/ServiceClient.cs index 301fffd17..0219f9ed9 100644 --- a/src/ClientUI/ServiceClient.cs +++ b/src/ClientUI/ServiceClient.cs @@ -3,7 +3,6 @@ using Api.Service.Helpers; using Api.Service.Validators; using Api.Services; -using Common.Database; using Common.Dto.Peloton; using Common.Dto; using Common.Service; @@ -13,6 +12,8 @@ using Peloton.Dto; using SharedUI; using Sync; +using Garmin.Dto; +using Sync.Database; namespace ClientUI; @@ -58,10 +59,9 @@ public async Task GetAnnualProgressAsync() public async Task GetGarminAuthenticationAsync() { - var settings = await _settingsService.GetSettingsAsync(); - var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email); + var auth = await _garminAuthService.GetGarminAuthenticationAsync(); - var result = new GarminAuthenticationGetResponse() { IsAuthenticated = auth?.IsValid(settings) ?? false }; + var result = new GarminAuthenticationGetResponse() { IsAuthenticated = auth?.IsValid() ?? false }; return result; } @@ -258,17 +258,17 @@ public async Task SignInToGarminAsync() { if (!settings.Garmin.TwoStepVerificationEnabled) { - await _garminAuthService.RefreshGarminAuthenticationAsync(); - return new FlurlResponse(new HttpResponseMessage() { StatusCode = System.Net.HttpStatusCode.Created }); + await _garminAuthService.SignInAsync(); + return new FlurlResponse(new FlurlCall() { HttpResponseMessage = new HttpResponseMessage() { StatusCode = System.Net.HttpStatusCode.Created } }); } else { - var auth = await _garminAuthService.RefreshGarminAuthenticationAsync(); + var auth = await _garminAuthService.SignInAsync(); - if (auth.AuthStage == Common.Stateful.AuthStage.NeedMfaToken) - return new FlurlResponse(new HttpResponseMessage() { StatusCode = System.Net.HttpStatusCode.Accepted }); + if (auth.AuthStage == AuthStage.NeedMfaToken) + return new FlurlResponse(new FlurlCall() { HttpResponseMessage = new HttpResponseMessage() { StatusCode = System.Net.HttpStatusCode.Accepted } }); - return new FlurlResponse(new HttpResponseMessage() { StatusCode = System.Net.HttpStatusCode.Created }); + return new FlurlResponse(new FlurlCall() { HttpResponseMessage = new HttpResponseMessage() { StatusCode = System.Net.HttpStatusCode.Created } }); } } catch (GarminAuthenticationError gae) when (gae.Code == Code.UnexpectedMfa) @@ -298,7 +298,7 @@ public async Task SyncGetAsync() return new SyncGetResponse() { SyncEnabled = settings.App.EnablePolling, - SyncStatus = syncTime.SyncStatus, + SyncStatus = (Status)syncTime.SyncStatus, LastSuccessfulSyncTime = syncTime.LastSuccessfulSyncTime, LastSyncTime = syncTime.LastSyncTime, NextSyncTime = syncTime.NextSyncTime @@ -308,7 +308,7 @@ public async Task SyncGetAsync() public async Task SyncPostAsync(SyncPostRequest syncPostRequest) { var settings = await _settingsService.GetSettingsAsync(); - var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email); + var auth = await _garminAuthService.GetGarminAuthenticationAsync(); var (isValid, result) = syncPostRequest.IsValid(settings, auth); if (!isValid) diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index e25eebaa4..de1b42be4 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Common/Constants.cs b/src/Common/Constants.cs index 5242950fc..de2ff39fd 100644 --- a/src/Common/Constants.cs +++ b/src/Common/Constants.cs @@ -9,6 +9,6 @@ public static class Constants public const string WebUIName = "p2g_webui"; public const string ClientUIName = "p2g_clientui"; - public const string AppVersion = "4.2.0-rc"; + public const string AppVersion = "4.3.0-rc"; } } diff --git a/src/Common/Database/DbBase.cs b/src/Common/Database/DbBase.cs index 0f8b14937..10c9067b4 100644 --- a/src/Common/Database/DbBase.cs +++ b/src/Common/Database/DbBase.cs @@ -11,7 +11,7 @@ public abstract class DbBase { private static readonly ILogger _logger = LogContext.ForClass>(); - private IFileHandling _fileHandler; + protected IFileHandling _fileHandler; protected Lazy> _table; diff --git a/src/Common/Database/DbMigrations.cs b/src/Common/Database/DbMigrations.cs index 6acbaee5f..db65510be 100644 --- a/src/Common/Database/DbMigrations.cs +++ b/src/Common/Database/DbMigrations.cs @@ -21,14 +21,12 @@ public class DbMigrations : IDbMigrations private readonly ISettingsDb _settingsDb; private readonly IUsersDb _usersDb; - private readonly ISyncStatusDb _syncStatusDb; private readonly IFileHandling _fileHandler; - public DbMigrations(ISettingsDb settingsDb, IUsersDb usersDb, ISyncStatusDb syncStatusDb, IFileHandling fileHandler) + public DbMigrations(ISettingsDb settingsDb, IUsersDb usersDb, IFileHandling fileHandler) { _settingsDb = settingsDb; _usersDb = usersDb; - _syncStatusDb = syncStatusDb; _fileHandler = fileHandler; } @@ -71,15 +69,6 @@ public async Task MigrateToAdminUserAsync() { _logger.Error(e, "[MIGRATION] Failed to migrate existing data to Admin user."); } - - try - { - await _syncStatusDb!.DeleteLegacySyncStatusAsync(); - } - catch (Exception e) - { - _logger.Warning(e, "[MIGRATION Failed to delete LegacySyncStatus."); - } #pragma warning restore CS0612 // Type or member is obsolete } diff --git a/src/Common/Database/Extensions.cs b/src/Common/Database/Extensions.cs new file mode 100644 index 000000000..f342c2b73 --- /dev/null +++ b/src/Common/Database/Extensions.cs @@ -0,0 +1,22 @@ +using JsonFlatFileDataStore; +using System.Collections.Generic; + +namespace Common.Database; + +public static class Extensions +{ + public static bool TryGetItem(this DataStore db, int id, out T item) where T : class + { + item = null; + try + { + item = db.GetItem(id.ToString()); + return true; + + } + catch (KeyNotFoundException) + { + return false; + } + } +} diff --git a/src/Common/Database/SettingsDb.cs b/src/Common/Database/SettingsDb.cs index d7f601736..1b6a39cc8 100644 --- a/src/Common/Database/SettingsDb.cs +++ b/src/Common/Database/SettingsDb.cs @@ -89,7 +89,7 @@ public Task GetSettingsAsync(int userId) } catch (Exception e) { - _logger.Error(e, $"Failed to upsert settings to db for user {userId}"); + _logger.Error(e, $"Failed to get settings to db for user {userId}"); throw; } } diff --git a/src/Common/Dto/Peloton/UserData.cs b/src/Common/Dto/Peloton/UserData.cs index a0ee46278..d4aa94767 100644 --- a/src/Common/Dto/Peloton/UserData.cs +++ b/src/Common/Dto/Peloton/UserData.cs @@ -28,7 +28,7 @@ public record UserData public uint Default_Max_Heart_Rate { get; init; } public uint Customized_Max_Heart_Rate { get; init; } - public uint Weight { get; init; } + public double Weight { get; init; } } public enum CyclingFtpSource : byte diff --git a/src/Common/Dto/ServiceResult.cs b/src/Common/Dto/ServiceResult.cs index fd0f59da0..659275a20 100644 --- a/src/Common/Dto/ServiceResult.cs +++ b/src/Common/Dto/ServiceResult.cs @@ -1,6 +1,4 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using System; +using System; namespace Common.Dto; diff --git a/src/Common/FileHandling.cs b/src/Common/FileHandling.cs index e4b05cf95..cea849c77 100644 --- a/src/Common/FileHandling.cs +++ b/src/Common/FileHandling.cs @@ -2,6 +2,7 @@ using Serilog; using System; using System.IO; +using System.Reflection.PortableExecutable; using System.Text.Json; using System.Xml.Serialization; @@ -14,7 +15,8 @@ public interface IFileHandling bool FileExists(string path); string[] GetFiles(string path); - T DeserializeJson(string file); + T DeserializeJson(string file) where T : class; + string SerializeToJson(object data); bool TryDeserializeXml(string file, out T result) where T : new(); void MoveFailedFile(string fromPath, string toPath); void Copy(string from, string to, bool overwrite); @@ -65,9 +67,19 @@ public string[] GetFiles(string path) return files; } - public T DeserializeJson(string file) + public T DeserializeJson(string content) where T : class { - using var trace1 = Tracing.Trace(nameof(DeserializeJson), "io") + using var trace1 = Tracing.Trace(nameof(DeserializeJson), "io"); + + if (string.IsNullOrWhiteSpace(content)) return (T)null; + + return JsonSerializer.Deserialize(content, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }); + + } + + public T DeserializeJsonFile(string file) + { + using var trace1 = Tracing.Trace(nameof(DeserializeJsonFile), "io") .WithTag("path", file); using (var reader = new StreamReader(file)) @@ -76,6 +88,13 @@ public T DeserializeJson(string file) } } + public string SerializeToJson(object data) + { + using var trace1 = Tracing.Trace(nameof(SerializeToJson), "io"); + + return JsonSerializer.Serialize(data, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }); + } + public bool TryDeserializeXml(string file, out T result) where T : new() { result = default; diff --git a/src/Common/Helpers/Encryption.cs b/src/Common/Helpers/Encryption.cs index de1ba3dae..cc6d64e67 100644 --- a/src/Common/Helpers/Encryption.cs +++ b/src/Common/Helpers/Encryption.cs @@ -39,7 +39,9 @@ public static string Encrypt(this string text, byte[] key, byte[] iv) public static string Decrypt(this string cipherText) => DecryptString(cipherText, Key_V1, IV_V1); public static string DecryptString(this string cipherText, byte[] key, byte[] iv) - { + { + if (string.IsNullOrWhiteSpace(cipherText)) return cipherText; + var encrypted = Convert.FromBase64String(cipherText); var encryptedByteArray = encrypted.ToArray(); diff --git a/src/Common/Http/FlurlConfiguration.cs b/src/Common/Http/FlurlConfiguration.cs index 9684931af..f7739f95b 100644 --- a/src/Common/Http/FlurlConfiguration.cs +++ b/src/Common/Http/FlurlConfiguration.cs @@ -56,20 +56,23 @@ public static void Configure(Observability config, int defaultTimeoutSeconds = 1 LogError(call, request, response); }; - FlurlHttp.Configure(settings => + FlurlHttp.Clients.WithDefaults(builder => { - settings.Timeout = new TimeSpan(0, 0, defaultTimeoutSeconds); - settings.BeforeCallAsync = beforeCallAsync; - settings.AfterCallAsync = afterCallAsync; - settings.OnErrorAsync = onErrorAsync; - settings.Redirects.ForwardHeaders = true; - }); + builder.WithTimeout(new TimeSpan(0, 0, defaultTimeoutSeconds)) + .BeforeCall(beforeCallAsync) + .AfterCall(afterCallAsync) + .OnError(onErrorAsync) + .WithAutoRedirect(true); - FlurlHttp.ConfigureClient("https://api.onepeloton.com", client => - { - var policies = Policy.WrapAsync(PollyPolicies.Retry, PollyPolicies.NoOp); - client.Settings.HttpClientFactory = new PollyHttpClientFactory(policies); + builder.Settings.Redirects.ForwardAuthorizationHeader = true; }); + + FlurlHttp.ConfigureClientForUrl("https://api.onepeloton.com") + .AddMiddleware(() => + { + var policies = Policy.WrapAsync(PollyPolicies.Retry, PollyPolicies.NoOp); + return new PolicyHandler(policies); + }); } public static void LogError(FlurlCall call, string requestPayload, string responsePayload) @@ -213,10 +216,8 @@ private static void TrackMetrics(FlurlCall call, string path, string query) public static IFlurlRequest StripSensitiveDataFromLogging(this IFlurlRequest request, string sensitiveField, string sensitiveField2 = null) { - return request.ConfigureRequest((c) => - { - c.BeforeCallAsync = null; - c.BeforeCallAsync = (call) => + return request + .BeforeCall((call) => { if (Log.IsEnabled(LogEventLevel.Verbose)) { @@ -227,10 +228,8 @@ public static IFlurlRequest StripSensitiveDataFromLogging(this IFlurlRequest req LogRequest(call, content); } return Task.CompletedTask; - }; - - c.AfterCallAsync = null; - c.AfterCallAsync = async (call) => + }) + .AfterCall(async (call) => { if (Log.IsEnabled(LogEventLevel.Verbose)) { @@ -242,10 +241,8 @@ public static IFlurlRequest StripSensitiveDataFromLogging(this IFlurlRequest req } TrackMetrics(call); - }; - - c.OnErrorAsync = null; - c.OnErrorAsync = async (call) => + }) + .OnError(async (call) => { var requestContent = call.GetRawRequestBody() ?.ToString() @@ -257,8 +254,7 @@ public static IFlurlRequest StripSensitiveDataFromLogging(this IFlurlRequest req ?.Replace(sensitiveField2, "") ?? string.Empty; LogError(call, requestContent, responseContent); - }; - }); + }); } private static string GetRawRequestBody(this FlurlCall call) diff --git a/src/Common/Http/PollyHttpClientFactory.cs b/src/Common/Http/PollyHttpClientFactory.cs index d8eb3b115..659ec5fd7 100644 --- a/src/Common/Http/PollyHttpClientFactory.cs +++ b/src/Common/Http/PollyHttpClientFactory.cs @@ -6,24 +6,6 @@ namespace Common.Http; -public class PollyHttpClientFactory : DefaultHttpClientFactory -{ - private readonly AsyncPolicyWrap _policies; - - public PollyHttpClientFactory(AsyncPolicyWrap policies) - { - _policies = policies; - } - - public override HttpMessageHandler CreateMessageHandler() - { - return new PolicyHandler(_policies) - { - InnerHandler = base.CreateMessageHandler() - }; - } -} - public class PolicyHandler : DelegatingHandler { private readonly AsyncPolicyWrap _policy; diff --git a/src/Common/ObservabilityStartup.cs b/src/Common/ObservabilityStartup.cs index 8b3ce49ed..5530fa2ad 100644 --- a/src/Common/ObservabilityStartup.cs +++ b/src/Common/ObservabilityStartup.cs @@ -3,7 +3,6 @@ using Common.Stateful; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Serilog; using Serilog.Enrichers.Span; using Serilog.Settings.Configuration; @@ -15,8 +14,9 @@ public static class ObservabilityStartup { public static void ConfigureClientUI(IServiceCollection services, ConfigurationManager configManager, AppConfiguration config) { - FlurlConfiguration.Configure(config.Observability); ConfigureLogging(configManager); + FlurlConfiguration.Configure(config.Observability); + Tracing.EnableWebUITracing(services, config.Observability.Jaeger); // Setup initial Tracing Source Tracing.Source = new(Statics.TracingService); @@ -24,9 +24,9 @@ public static void ConfigureClientUI(IServiceCollection services, ConfigurationM public static void ConfigureApi(IServiceCollection services, ConfigurationManager configManager, AppConfiguration config) { + ConfigureLogging(configManager); FlurlConfiguration.Configure(config.Observability); Tracing.EnableApiTracing(services, config.Observability.Jaeger); - ConfigureLogging(configManager); // Setup initial Tracing Source Tracing.Source = new(Statics.TracingService); @@ -34,9 +34,9 @@ public static void ConfigureApi(IServiceCollection services, ConfigurationManage public static void ConfigureWebUI(IServiceCollection services, ConfigurationManager configManager, AppConfiguration config) { - FlurlConfiguration.Configure(config.Observability); - Tracing.EnableWebUITracing(services, config.Observability.Jaeger); ConfigureLogging(configManager); + FlurlConfiguration.Configure(config.Observability); + Tracing.EnableWebUITracing(services, config.Observability.Jaeger); // Setup initial Tracing Source Tracing.Source = new(Statics.TracingService); diff --git a/src/Common/Service/FileBasedSettingsService.cs b/src/Common/Service/FileBasedSettingsService.cs index 9f8e96672..6ee2af4b3 100644 --- a/src/Common/Service/FileBasedSettingsService.cs +++ b/src/Common/Service/FileBasedSettingsService.cs @@ -31,13 +31,6 @@ public FileBasedSettingsService(IConfiguration configurationLoader, ISettingsSer _fileHandler = fileHandler; } - public void ClearGarminAuthentication(string garminEmail) - { - using var tracing = Tracing.Trace($"{nameof(FileBasedSettingsService)}.{nameof(ClearGarminAuthentication)}"); - - _next.ClearGarminAuthentication(garminEmail); - } - public void ClearPelotonApiAuthentication(string pelotonEmail) { using var tracing = Tracing.Trace($"{nameof(FileBasedSettingsService)}.{nameof(ClearPelotonApiAuthentication)}"); @@ -45,13 +38,6 @@ public void ClearPelotonApiAuthentication(string pelotonEmail) _next.ClearPelotonApiAuthentication(pelotonEmail); } - public GarminApiAuthentication GetGarminAuthentication(string garminEmail) - { - using var tracing = Tracing.Trace($"{nameof(FileBasedSettingsService)}.{nameof(GetGarminAuthentication)}"); - - return _next.GetGarminAuthentication(garminEmail); - } - public PelotonApiAuthentication GetPelotonApiAuthentication(string pelotonEmail) { using var tracing = Tracing.Trace($"{nameof(FileBasedSettingsService)}.{nameof(GetPelotonApiAuthentication)}"); @@ -76,13 +62,6 @@ public Task GetSettingsAsync() return Task.FromResult(settings); } - public void SetGarminAuthentication(GarminApiAuthentication authentication) - { - using var tracing = Tracing.Trace($"{nameof(FileBasedSettingsService)}.{nameof(SetGarminAuthentication)}"); - - _next.SetGarminAuthentication(authentication); - } - public void SetPelotonApiAuthentication(PelotonApiAuthentication authentication) { using var tracing = Tracing.Trace($"{nameof(FileBasedSettingsService)}.{nameof(SetPelotonApiAuthentication)}"); diff --git a/src/Common/Service/ISettingsService.cs b/src/Common/Service/ISettingsService.cs index 234f01686..d66dcbc75 100644 --- a/src/Common/Service/ISettingsService.cs +++ b/src/Common/Service/ISettingsService.cs @@ -18,9 +18,5 @@ public interface ISettingsService PelotonApiAuthentication GetPelotonApiAuthentication(string pelotonEmail); void SetPelotonApiAuthentication(PelotonApiAuthentication authentication); void ClearPelotonApiAuthentication(string pelotonEmail); - - GarminApiAuthentication GetGarminAuthentication(string garminEmail); - void SetGarminAuthentication(GarminApiAuthentication authentication); - void ClearGarminAuthentication(string garminEmail); } } diff --git a/src/Common/Service/SettingsService.cs b/src/Common/Service/SettingsService.cs index 3b7259681..cf04d8f22 100644 --- a/src/Common/Service/SettingsService.cs +++ b/src/Common/Service/SettingsService.cs @@ -65,9 +65,6 @@ public async Task UpdateSettingsAsync(Settings updatedSettings) ClearPelotonApiAuthentication(originalSettings.Peloton.Email); ClearPelotonApiAuthentication(updatedSettings.Peloton.Email); - - ClearGarminAuthentication(originalSettings.Garmin.Email); - ClearGarminAuthentication(originalSettings.Garmin.Password); await _db.UpsertSettingsAsync(1, updatedSettings); // hardcode to admin user for now } @@ -105,41 +102,6 @@ public void ClearPelotonApiAuthentication(string pelotonEmail) } } - public GarminApiAuthentication GetGarminAuthentication(string garminEmail) - { - using var tracing = Tracing.Trace($"{nameof(SettingsService)}.{nameof(GetGarminAuthentication)}"); - - lock (_lock) - { - var key = $"{GarminApiAuthKey}:{garminEmail}"; - return _cache.Get(key); - } - } - - public void SetGarminAuthentication(GarminApiAuthentication authentication) - { - using var tracing = Tracing.Trace($"{nameof(SettingsService)}.{nameof(SetGarminAuthentication)}"); - - lock (_lock) - { - var key = $"{GarminApiAuthKey}:{authentication.Email}"; - var expiration = authentication.OAuth2Token?.Expires_In - (60 * 60) ?? 0; // expire an hour early - var finalExpiration = expiration <= 0 ? 45 * 60 : expiration; // default to 45min - _cache.Set(key, authentication, new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(finalExpiration) }); - } - } - - public void ClearGarminAuthentication(string garminEmail) - { - using var tracing = Tracing.Trace($"{nameof(SettingsService)}.{nameof(ClearGarminAuthentication)}"); - - lock (_lock) - { - var key = $"{GarminApiAuthKey}:{garminEmail}"; - _cache.Remove(key); - } - } - public Task GetAppConfigurationAsync() { var appConfiguration = new AppConfiguration(); diff --git a/src/Common/Stateful/GarminApiAuthentication.cs b/src/Common/Stateful/GarminApiAuthentication.cs deleted file mode 100644 index 19743577a..000000000 --- a/src/Common/Stateful/GarminApiAuthentication.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Common.Dto; -using Flurl.Http; - -namespace Common.Stateful; - -public class GarminApiAuthentication : IApiAuthentication -{ - public string Email { get; set; } - public string Password { get; set; } - public AuthStage AuthStage { get; set; } - public CookieJar CookieJar { get; set; } - public string UserAgent { get; set; } = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; - public string MFACsrfToken { get; set; } - public OAuth1Token OAuth1Token { get; set; } - public OAuth2Token OAuth2Token { get; set; } - - public bool IsValid(Settings settings) - { - return Email == settings.Garmin.Email - && Password == settings.Garmin.Password - && AuthStage == AuthStage.Completed - && !string.IsNullOrWhiteSpace(OAuth2Token?.Access_Token); - } -} - -public class OAuth1Token -{ - public string Token { get; set; } - public string TokenSecret { get; set; } -} - -public enum AuthStage : byte -{ - None = 0, - NeedMfaToken = 1, - Completed = 2, -} diff --git a/src/Common/Stateful/IApiAuthentication.cs b/src/Common/Stateful/IApiAuthentication.cs deleted file mode 100644 index d15bb6df9..000000000 --- a/src/Common/Stateful/IApiAuthentication.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Common.Dto; - -namespace Common.Stateful -{ - public interface IApiAuthentication - { - public string Email { get; set; } - public string Password { get; set; } - - bool IsValid(Settings settings); - } -} diff --git a/src/Common/Stateful/PelotonApiAuthentication.cs b/src/Common/Stateful/PelotonApiAuthentication.cs index 3d895d32c..4163a001c 100644 --- a/src/Common/Stateful/PelotonApiAuthentication.cs +++ b/src/Common/Stateful/PelotonApiAuthentication.cs @@ -2,7 +2,7 @@ namespace Common.Stateful { - public class PelotonApiAuthentication : IApiAuthentication + public class PelotonApiAuthentication { public string Email { get; set; } public string Password { get; set; } diff --git a/src/ConsoleClient/ConsoleClient.csproj b/src/ConsoleClient/ConsoleClient.csproj index 6bc25e5c4..f778d75ae 100644 --- a/src/ConsoleClient/ConsoleClient.csproj +++ b/src/ConsoleClient/ConsoleClient.csproj @@ -28,10 +28,10 @@ - + - + @@ -46,7 +46,6 @@ - diff --git a/src/ConsoleClient/Program.cs b/src/ConsoleClient/Program.cs index 1af035473..636cdeebc 100644 --- a/src/ConsoleClient/Program.cs +++ b/src/ConsoleClient/Program.cs @@ -20,6 +20,8 @@ using Garmin.Auth; using Serilog.Settings.Configuration; using Common.Observe; +using Garmin.Database; +using Sync.Database; Statics.AppType = Constants.ConsoleAppName; Statics.MetricPrefix = Constants.ConsoleAppName; @@ -89,6 +91,7 @@ static IHostBuilder CreateHostBuilder(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // RELEASE CHECKS services.AddGitHubReleaseChecker(); diff --git a/src/ConsoleClient/Startup.cs b/src/ConsoleClient/Startup.cs index 785d87c76..2049f1410 100644 --- a/src/ConsoleClient/Startup.cs +++ b/src/ConsoleClient/Startup.cs @@ -11,7 +11,6 @@ using Serilog; using Sync; using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using static Common.Observe.Metrics; @@ -107,69 +106,61 @@ private async Task RunAsync(CancellationToken cancelToken) settings.Peloton.NumWorkoutsToDownload = num; } - if (settings.App.EnablePolling) + if (settings.Garmin.Upload + && settings.Garmin.TwoStepVerificationEnabled + && !(await _garminAuthService.GarminAuthTokenExistsAndIsValidAsync())) { - if (settings.Garmin.Upload && settings.Garmin.TwoStepVerificationEnabled && settings.App.EnablePolling) - { - _logger.Error("Polling cannot be enabled when Garmin TwoStepVerification is enabled."); - _logger.Information("Sync Service stopped."); - return; - } + await _garminAuthService.SignInAsync(); - while (!cancelToken.IsCancellationRequested) + Console.WriteLine("Detected Garmin Two Factor Enabled. Please check your email or phone for the Security Passcode sent by Garmin."); + var mfaCode = string.Empty; + var retryCount = 5; + while (retryCount > 0 && string.IsNullOrWhiteSpace(mfaCode)) { - settings = await _settingsService.GetSettingsAsync(); - - if (settings.App.CheckForUpdates) - { - var latestReleaseInformation = await _githubService.GetLatestReleaseInformationAsync("philosowaffle", "peloton-to-garmin", Constants.AppVersion); - if (latestReleaseInformation.IsReleaseNewerThanInstalledVersion) - { - _logger.Information("*********************************************"); - _logger.Information("A new version is available: {@Version}", latestReleaseInformation.LatestVersion); - _logger.Information("Release Date: {@ReleaseDate}", latestReleaseInformation.ReleaseDate); - _logger.Information("Release Information: {@ReleaseUrl}", latestReleaseInformation.ReleaseUrl); - _logger.Information("*********************************************"); - - AppMetrics.SyncUpdateAvailableMetric(latestReleaseInformation.IsReleaseNewerThanInstalledVersion, latestReleaseInformation.LatestVersion); - } - } + Console.WriteLine("Enter Code: "); + mfaCode = Console.ReadLine(); + retryCount--; + } - var syncResult = await _syncService.SyncAsync(settings.Peloton.NumWorkoutsToDownload); - Health.Set(syncResult.SyncSuccess ? HealthStatus.Healthy : HealthStatus.UnHealthy); + await _garminAuthService.CompleteMFAAuthAsync(mfaCode); + } - Log.Information("Done"); - Log.Information("Sleeping for {@Seconds} seconds...", settings.App.PollingIntervalSeconds); + if (!settings.App.EnablePolling) + { + await _syncService.SyncAsync(settings.Peloton.NumWorkoutsToDownload); + return; + } - var now = DateTime.UtcNow; - var nextRunTime = now.AddSeconds(settings.App.PollingIntervalSeconds); - NextSyncTime.Set(new DateTimeOffset(nextRunTime).ToUnixTimeSeconds()); - Thread.Sleep(settings.App.PollingIntervalSeconds * 1000); - } - } - else + while (!cancelToken.IsCancellationRequested) { - if (settings.Garmin.Upload && settings.Garmin.TwoStepVerificationEnabled) + settings = await _settingsService.GetSettingsAsync(); + + if (settings.App.CheckForUpdates) { - await _garminAuthService.RefreshGarminAuthenticationAsync(); - - Console.WriteLine("Detected Garmin Two Factor Enabled. Please check your email or phone for the Security Passcode sent by Garmin."); - var mfaCode = string.Empty; - var retryCount = 5; - while (retryCount > 0 && string.IsNullOrWhiteSpace(mfaCode)) + var latestReleaseInformation = await _githubService.GetLatestReleaseInformationAsync("philosowaffle", "peloton-to-garmin", Constants.AppVersion); + if (latestReleaseInformation.IsReleaseNewerThanInstalledVersion) { - Console.Write("Enter Code: "); - mfaCode = Console.ReadLine(); - retryCount--; - } + _logger.Information("*********************************************"); + _logger.Information("A new version is available: {@Version}", latestReleaseInformation.LatestVersion); + _logger.Information("Release Date: {@ReleaseDate}", latestReleaseInformation.ReleaseDate); + _logger.Information("Release Information: {@ReleaseUrl}", latestReleaseInformation.ReleaseUrl); + _logger.Information("*********************************************"); - await _garminAuthService.CompleteMFAAuthAsync(mfaCode); + AppMetrics.SyncUpdateAvailableMetric(latestReleaseInformation.IsReleaseNewerThanInstalledVersion, latestReleaseInformation.LatestVersion); + } } - await _syncService.SyncAsync(settings.Peloton.NumWorkoutsToDownload); - } + var syncResult = await _syncService.SyncAsync(settings.Peloton.NumWorkoutsToDownload); + Health.Set(syncResult.SyncSuccess ? HealthStatus.Healthy : HealthStatus.UnHealthy); - _logger.Information("Done."); + Log.Information("Done"); + Log.Information("Sleeping for {@Seconds} seconds...", settings.App.PollingIntervalSeconds); + + var now = DateTime.UtcNow; + var nextRunTime = now.AddSeconds(settings.App.PollingIntervalSeconds); + NextSyncTime.Set(new DateTimeOffset(nextRunTime).ToUnixTimeSeconds()); + Thread.Sleep(settings.App.PollingIntervalSeconds * 1000); + } } catch (Exception ex) { @@ -179,7 +170,7 @@ private async Task RunAsync(CancellationToken cancelToken) } finally { - _logger.Verbose("Exit."); + _logger.Information("Done."); Console.ReadLine(); Environment.Exit(exitCode); } diff --git a/src/Garmin/ApiClient.cs b/src/Garmin/ApiClient.cs index 18d6f531e..7f9879d28 100644 --- a/src/Garmin/ApiClient.cs +++ b/src/Garmin/ApiClient.cs @@ -1,6 +1,5 @@ using Common.Http; using Common.Observe; -using Common.Stateful; using Flurl.Http; using Garmin.Auth; using Garmin.Dto; @@ -15,13 +14,13 @@ namespace Garmin public interface IGarminApiClient { Task InitCookieJarAsync(object queryParams, string userAgent, out CookieJar jar); - Task GetCsrfTokenAsync(GarminApiAuthentication auth, object queryParams, CookieJar jar); - Task SendCredentialsAsync(GarminApiAuthentication auth, object queryParams, object loginData, CookieJar jar); + Task GetCsrfTokenAsync(object queryParams, CookieJar jar, string userAgent); + Task SendCredentialsAsync(string email, string password, object queryParams, object loginData, string userAgent, CookieJar jar); Task SendMfaCodeAsync(string userAgent, object queryParams, object mfaData, CookieJar jar); - Task GetOAuth1TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials, string ticket); - Task GetOAuth2TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials); + Task GetOAuth1TokenAsync(ConsumerCredentials credentials, string ticket, string userAgent); + Task GetOAuth2TokenAsync(OAuth1Token oAuth1Token, ConsumerCredentials credentials, string userAgent); Task GetConsumerCredentialsAsync(); - Task UploadActivity(string filePath, string format, GarminApiAuthentication auth); + Task UploadActivity(string filePath, string format, GarminApiAuthentication auth, string userAgent); } public class ApiClient : IGarminApiClient @@ -52,17 +51,17 @@ public Task InitCookieJarAsync(object queryParams, string userAgent, out CookieJ .GetStringAsync(); } - public async Task SendCredentialsAsync(GarminApiAuthentication auth, object queryParams, object loginData, CookieJar jar) + public async Task SendCredentialsAsync(string email, string password, object queryParams, object loginData, string userAgent, CookieJar jar) { var result = new SendCredentialsResult(); result.RawResponseBody = await SSO_SIGNIN_URL - .WithHeader("User-Agent", auth.UserAgent) + .WithHeader("User-Agent", userAgent) .WithHeader("origin", ORIGIN) .WithHeader("referer", REFERER) .WithHeader("NK", "NT") .SetQueryParams(queryParams) .WithCookies(jar) - .StripSensitiveDataFromLogging(auth.Email, auth.Password) + .StripSensitiveDataFromLogging(email, password) .OnRedirect((r) => { result.WasRedirected = true; result.RedirectedTo = r.Redirect.Url; }) .PostUrlEncodedAsync(loginData) .ReceiveString(); @@ -70,15 +69,14 @@ public async Task SendCredentialsAsync(GarminApiAuthentic return result; } - public async Task GetCsrfTokenAsync(GarminApiAuthentication auth, object queryParams, CookieJar jar) + public async Task GetCsrfTokenAsync(object queryParams, CookieJar jar, string userAgent) { var result = new GarminResult(); result.RawResponseBody = await SSO_SIGNIN_URL - .WithHeader("User-Agent", auth.UserAgent) + .WithHeader("User-Agent", userAgent) .WithHeader("origin", ORIGIN) .SetQueryParams(queryParams) .WithCookies(jar) - .StripSensitiveDataFromLogging(auth.Email, auth.Password) .GetAsync() .ReceiveString(); @@ -97,36 +95,37 @@ public Task SendMfaCodeAsync(string userAgent, object queryParams, objec .ReceiveString(); } - public Task GetOAuth1TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials, string ticket) + public Task GetOAuth1TokenAsync(ConsumerCredentials credentials, string ticket, string userAgent) { OAuthRequest oauthClient = OAuthRequest.ForRequestToken(credentials.Consumer_Key, credentials.Consumer_Secret); oauthClient.RequestUrl = $"https://connectapi.garmin.com/oauth-service/oauth/preauthorized?ticket={ticket}&login-url=https://sso.garmin.com/sso/embed&accepts-mfa-tokens=true"; + return oauthClient.RequestUrl - .WithHeader("User-Agent", auth.UserAgent) + .WithHeader("User-Agent", userAgent) .WithHeader("Authorization", oauthClient.GetAuthorizationHeader()) .GetStringAsync(); } - public Task GetOAuth2TokenAsync(GarminApiAuthentication auth, ConsumerCredentials credentials) + public Task GetOAuth2TokenAsync(OAuth1Token oAuth1Token, ConsumerCredentials credentials, string userAgent) { - OAuthRequest oauthClient2 = OAuthRequest.ForProtectedResource("POST", credentials.Consumer_Key, credentials.Consumer_Secret, auth.OAuth1Token.Token, auth.OAuth1Token.TokenSecret); + OAuthRequest oauthClient2 = OAuthRequest.ForProtectedResource("POST", credentials.Consumer_Key, credentials.Consumer_Secret, oAuth1Token.Token, oAuth1Token.TokenSecret); oauthClient2.RequestUrl = "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0"; return oauthClient2.RequestUrl - .WithHeader("User-Agent", auth.UserAgent) + .WithHeader("User-Agent", userAgent) .WithHeader("Authorization", oauthClient2.GetAuthorizationHeader()) .WithHeader("Content-Type", "application/x-www-form-urlencoded") // this header is required, without it you get a 500 .PostUrlEncodedAsync(new object()) // hack: PostAsync() will drop the content-type header, by posting empty object we trick flurl into leaving the header .ReceiveJson(); } - public async Task UploadActivity(string filePath, string format, GarminApiAuthentication auth) + public async Task UploadActivity(string filePath, string format, GarminApiAuthentication auth, string userAgent) { var fileName = Path.GetFileName(filePath); var response = await $"{UPLOAD_URL}/{format}" .WithOAuthBearerToken(auth.OAuth2Token.Access_Token) .WithHeader("NK", "NT") .WithHeader("origin", ORIGIN) - .WithHeader("User-Agent", auth.UserAgent) + .WithHeader("User-Agent", userAgent) .AllowHttpStatus("2xx,409") .PostMultipartAsync((data) => { diff --git a/src/Garmin/Auth/GarminAuthenticationService.cs b/src/Garmin/Auth/GarminAuthenticationService.cs index 2a5a6ef2a..bfffd1a08 100644 --- a/src/Garmin/Auth/GarminAuthenticationService.cs +++ b/src/Garmin/Auth/GarminAuthenticationService.cs @@ -1,7 +1,9 @@ -using Common.Observe; +using Common.Dto; +using Common.Observe; using Common.Service; -using Common.Stateful; using Flurl.Http; +using Garmin.Database; +using Garmin.Dto; using Serilog; using System; using System.Collections.Generic; @@ -15,8 +17,10 @@ namespace Garmin.Auth; public interface IGarminAuthenticationService { Task GetGarminAuthenticationAsync(); - Task RefreshGarminAuthenticationAsync(); + Task GarminAuthTokenExistsAndIsValidAsync(); + Task SignInAsync(); Task CompleteMFAAuthAsync(string mfaCode); + Task SignOutAsync(); } public class GarminAuthenticationService : IGarminAuthenticationService @@ -35,53 +39,77 @@ public class GarminAuthenticationService : IGarminAuthenticationService private readonly ISettingsService _settingsService; private readonly IGarminApiClient _apiClient; + private readonly IGarminDb _garminDb; - public GarminAuthenticationService(ISettingsService settingsService, IGarminApiClient apiClient) + public GarminAuthenticationService(ISettingsService settingsService, IGarminApiClient apiClient, IGarminDb garminDb) { _settingsService = settingsService; _apiClient = apiClient; + _garminDb = garminDb; } - public async Task GetGarminAuthenticationAsync() + public async Task GarminAuthTokenExistsAndIsValidAsync() { - var settings = await _settingsService.GetSettingsAsync(); - settings.Garmin.EnsureGarminCredentialsAreProvided(); - - var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email); - if (auth is object && auth.IsValid(settings)) - return auth; + var oAuth2Token = await _garminDb.GetGarminOAuth2TokenAsync(1); + var oAuth1Token = await _garminDb.GetGarminOAuth1TokenAsync(1); - return await RefreshGarminAuthenticationAsync(); + // we either already have an oAuth2Token, or we think we are capable of getting one without + // user intervention + return (oAuth2Token is object && !oAuth2Token.IsExpired()) || (oAuth1Token is object); } - public async Task RefreshGarminAuthenticationAsync() + public async Task GetGarminAuthenticationAsync() { - ///////////////////////////////////////////////////////////////////////////// - // TODO: Implement refresh using OAuth tokens instead of re-using credentials - // Eventually remove need to store credentials locally - /////////////////////////////////////////////////////////////////////////////// + var oAuth2Token = await _garminDb.GetGarminOAuth2TokenAsync(1); + if (oAuth2Token is object && !oAuth2Token.IsExpired()) + return new GarminApiAuthentication() + { + AuthStage = AuthStage.Completed, + OAuth2Token = oAuth2Token, + }; + + var oAuth1Token = await _garminDb.GetGarminOAuth1TokenAsync(1); + if (oAuth1Token is object) + { + try + { + var consumerCredentials = await _apiClient.GetConsumerCredentialsAsync(); + var appConfig = await _settingsService.GetAppConfigurationAsync(); + var userAgent = Defaults.DefaultUserAgent; + if (!string.IsNullOrEmpty(appConfig.Developer.UserAgent)) + userAgent = appConfig.Developer.UserAgent; + + return await ExchangeOAuth1ForOAuth2Async(oAuth1Token, consumerCredentials, userAgent); + + } catch (Exception ex) + { + _logger.Debug("Failed to exchange OAuth1 token for OAuth 2, will try refreshing OAuth1 token.", ex); + } + } + return await SignInAsync(); + } + + public async Task SignInAsync() + { var settings = await _settingsService.GetSettingsAsync(); settings.Garmin.EnsureGarminCredentialsAreProvided(); - _settingsService.ClearGarminAuthentication(settings.Garmin.Email); + await SignOutAsync(); - var auth = new GarminApiAuthentication(); - auth.Email = settings.Garmin.Email; - auth.Password = settings.Garmin.Password; CookieJar jar = null; - auth.AuthStage = AuthStage.None; + var userAgent = Defaults.DefaultUserAgent; var appConfig = await _settingsService.GetAppConfigurationAsync(); if (!string.IsNullOrEmpty(appConfig.Developer.UserAgent)) - auth.UserAgent = appConfig.Developer.UserAgent; + userAgent = appConfig.Developer.UserAgent; ///////////////////////////////// // Init Auth Flow //////////////////////////////// try { - await _apiClient.InitCookieJarAsync(CommonQueryParams, auth.UserAgent, out jar); + await _apiClient.InitCookieJarAsync(CommonQueryParams, userAgent, out jar); } catch (FlurlHttpException e) { @@ -102,10 +130,10 @@ public async Task RefreshGarminAuthenticationAsync() redirectAfterAccountCreationUrl = "https://sso.garmin.com/sso/embed", }; - string csrfToken = string.Empty; + var csrfToken = string.Empty; try { - var tokenResult = await _apiClient.GetCsrfTokenAsync(auth, csrfRequest, jar); + var tokenResult = await _apiClient.GetCsrfTokenAsync(csrfRequest, jar, userAgent); csrfToken = FindCsrfToken(tokenResult.RawResponseBody, failureStepCode: Code.FailedPriorToCredentialsUsed); } catch (FlurlHttpException e) @@ -118,15 +146,15 @@ public async Task RefreshGarminAuthenticationAsync() //////////////////////////////// var sendCredentialsRequest = new { - username = auth.Email, - password = auth.Password, + username = settings.Garmin.Email, + password = settings.Garmin.Password, embed = "true", _csrf = csrfToken }; SendCredentialsResult sendCredentialsResult = null; try { - sendCredentialsResult = await _apiClient.SendCredentialsAsync(auth, csrfRequest, sendCredentialsRequest, jar); + sendCredentialsResult = await _apiClient.SendCredentialsAsync(settings.Garmin.Email, settings.Garmin.Password, csrfRequest, sendCredentialsRequest, userAgent, jar); } catch (FlurlHttpException e) when (e.StatusCode is (int)HttpStatusCode.Forbidden) { @@ -147,18 +175,25 @@ public async Task RefreshGarminAuthenticationAsync() throw new GarminAuthenticationError("Detected Garmin TwoFactorAuthentication but TwoFactorAuthenctication is not enabled in P2G settings. Please enable TwoFactorAuthentication in your P2G Garmin settings.") { Code = Code.UnexpectedMfa }; var mfaCsrfToken = FindCsrfToken(sendCredentialsResult.RawResponseBody, failureStepCode: Code.FailedPriorToMfaUsed); - auth.AuthStage = AuthStage.NeedMfaToken; - auth.MFACsrfToken = mfaCsrfToken; - auth.CookieJar = jar; - _settingsService.SetGarminAuthentication(auth); - return auth; + + var partialAuthentication = new StagedPartialGarminAuthentication() + { + ExpiresAt = DateTime.Now.AddMinutes(15), + AuthStage = AuthStage.NeedMfaToken, + MFACsrfToken = mfaCsrfToken, + CookieJarString = jar.ToString(), + UserAgent = userAgent, + }; + await _garminDb.UpsertPartialGarminAuthenticationAsync(1, partialAuthentication); + + return new GarminApiAuthentication() { AuthStage = AuthStage.NeedMfaToken }; } var loginResult = sendCredentialsResult?.RawResponseBody; - return await CompleteGarminAuthenticationAsync(loginResult, auth); + return await CompleteGarminAuthenticationAsync(loginResult, userAgent); } - private async Task CompleteGarminAuthenticationAsync(string loginResult, GarminApiAuthentication auth) + private async Task CompleteGarminAuthenticationAsync(string loginResult, string userAgent) { // Try to find the full post login ServiceTicket var ticketRegex = new Regex("embed\\?ticket=(?[^\"]+)\""); @@ -176,43 +211,64 @@ private async Task CompleteGarminAuthenticationAsync(st // Get OAuth1 Tokens /////////////////////////////////////////// var consumerCredentials = await _apiClient.GetConsumerCredentialsAsync(); - await GetOAuth1Async(ticket, auth, consumerCredentials); + var oAuth1Token = await GetOAuth1Async(ticket, consumerCredentials, userAgent); + await _garminDb.UpsertGarminOAuth1TokenAsync(1, oAuth1Token); //////////////////////////////////////////// // Exchange for OAuth2 Token /////////////////////////////////////////// + var result = await ExchangeOAuth1ForOAuth2Async(oAuth1Token, consumerCredentials, userAgent); + + // Clear partial data + await _garminDb.UpsertPartialGarminAuthenticationAsync(1, null); + + return result; + } + + private async Task ExchangeOAuth1ForOAuth2Async(OAuth1Token oAuth1Token, ConsumerCredentials consumerCredentials, string userAgent) + { + OAuth2Token oAuth2Token = null; try { - auth.OAuth2Token = await _apiClient.GetOAuth2TokenAsync(auth, consumerCredentials); + oAuth2Token = await _apiClient.GetOAuth2TokenAsync(oAuth1Token, consumerCredentials, userAgent); + oAuth2Token.ExpiresAt = DateTime.Now.AddSeconds(oAuth2Token.Expires_In); + await _garminDb.UpsertGarminOAuth2TokenAsync(1, oAuth2Token); + + //auth.OAuth2Token.Refresh_Token; // not used according to this thread: https://github.com/matin/garth/issues/21 + //auth.OAuth2Token.Expires_In; // ~24hrs + //auth.OAuth2Token.Refresh_Token_Expires_In; // ~30d } catch (Exception e) { - throw new GarminAuthenticationError("Auth appeared successful but failed to get the OAuth2 token.", e) { Code = Code.AuthAppearedSuccessful }; + throw new GarminAuthenticationError("Auth appeared successful but failed to get the OAuth2 token. If this persists, try clearing then re-entering your Garmin credentials in the Settings.", e) { Code = Code.AuthAppearedSuccessful }; } - auth.AuthStage = AuthStage.Completed; - auth.MFACsrfToken = string.Empty; - _settingsService.SetGarminAuthentication(auth); - return auth; + return new GarminApiAuthentication() + { + AuthStage = AuthStage.Completed, + OAuth2Token = oAuth2Token + }; } public async Task CompleteMFAAuthAsync(string mfaCode) { - var settings = await _settingsService.GetSettingsAsync(); - var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email); + var partialAuth = await _garminDb.GetStagedPartialGarminAuthenticationAsync(1); - if (auth is null || auth.AuthStage == AuthStage.None) + if (partialAuth is null || partialAuth.AuthStage == AuthStage.None) throw new ArgumentException("Garmin Auth has not been initialized, cannot provide MFA token yet."); - if (auth.AuthStage != AuthStage.NeedMfaToken) - return auth; + if (partialAuth.AuthStage != AuthStage.NeedMfaToken) + throw new ArgumentException($"We're in the wrong GarminAuthStage, expected NeedMfaToken but found {partialAuth.AuthStage}"); + + if (string.IsNullOrEmpty(partialAuth.UserAgent)) + partialAuth.UserAgent = Defaults.DefaultUserAgent; var mfaData = new List>() { new KeyValuePair("embed", "true"), new KeyValuePair("mfa-code", mfaCode), new KeyValuePair("fromPage", "setupEnterMfaCode"), - new KeyValuePair("_csrf", auth.MFACsrfToken) + new KeyValuePair("_csrf", partialAuth.MFACsrfToken) }; ///////////////////////////////// @@ -221,8 +277,9 @@ public async Task CompleteMFAAuthAsync(string mfaCode) try { SendMFAResult mfaResponse = new(); - mfaResponse.RawResponseBody = await _apiClient.SendMfaCodeAsync(auth.UserAgent, CommonQueryParams, mfaData, auth.CookieJar); - return await CompleteGarminAuthenticationAsync(mfaResponse.RawResponseBody, auth); + var jar = CookieJar.LoadFromString(partialAuth.CookieJarString); + mfaResponse.RawResponseBody = await _apiClient.SendMfaCodeAsync(partialAuth.UserAgent, CommonQueryParams, mfaData, jar); + return await CompleteGarminAuthenticationAsync(mfaResponse.RawResponseBody, partialAuth.UserAgent); } catch (FlurlHttpException e) when (e.StatusCode is (int)HttpStatusCode.Forbidden) { @@ -257,12 +314,12 @@ private string FindCsrfToken(string rawResponseBody, Code failureStepCode) } } - private async Task GetOAuth1Async(string ticket, GarminApiAuthentication auth, ConsumerCredentials credentials) + private async Task GetOAuth1Async(string ticket, ConsumerCredentials credentials, string userAgent) { string oauth1Response = null; try { - oauth1Response = await _apiClient.GetOAuth1TokenAsync(auth, credentials, ticket); + oauth1Response = await _apiClient.GetOAuth1TokenAsync(credentials, ticket, userAgent); } catch (Exception e) { throw new GarminAuthenticationError("Auth appeared successful but failed to get the OAuth1 token.", e) { Code = Code.AuthAppearedSuccessful }; @@ -282,10 +339,19 @@ private async Task GetOAuth1Async(string ticket, GarminApiAuthentication auth, C if (string.IsNullOrWhiteSpace(oAuthTokenSecret)) throw new GarminAuthenticationError($"Auth appeared successful but returned OAuth1 token secret is null. oauth1Response: {oauth1Response}") { Code = Code.AuthAppearedSuccessful }; - auth.OAuth1Token = new OAuth1Token() + return new OAuth1Token() { Token = oAuthToken, TokenSecret = oAuthTokenSecret }; } + + public async Task SignOutAsync() + { + await _garminDb.UpsertPartialGarminAuthenticationAsync(1, null); + await _garminDb.UpsertGarminOAuth1TokenAsync(1, null); + await _garminDb.UpsertGarminOAuth2TokenAsync(1, null); + + return true; + } } diff --git a/src/Garmin/Database/GarminDb.cs b/src/Garmin/Database/GarminDb.cs new file mode 100644 index 000000000..7cb8b048d --- /dev/null +++ b/src/Garmin/Database/GarminDb.cs @@ -0,0 +1,195 @@ +using Common; +using Common.Database; +using Common.Helpers; +using Common.Observe; +using Garmin.Dto; +using JsonFlatFileDataStore; +using Prometheus; +using Serilog; +using System; +using System.Threading.Tasks; + +namespace Garmin.Database; + +public interface IGarminDb +{ + Task GetGarminOAuth1TokenAsync(int userId); + Task UpsertGarminOAuth1TokenAsync(int userId, OAuth1Token token); + + Task GetGarminOAuth2TokenAsync(int userId); + Task UpsertGarminOAuth2TokenAsync(int userId, OAuth2Token token); + + Task GetStagedPartialGarminAuthenticationAsync(int userId); + Task UpsertPartialGarminAuthenticationAsync(int userId, StagedPartialGarminAuthentication partialGarminAuthentication); +} + +public class GarminDb : DbBase, IGarminDb +{ + private static readonly ILogger _logger = LogContext.ForClass(); + private static readonly P2GGarminData _defaultData = new P2GGarminData(); + + private readonly DataStore _db; + + public GarminDb(IFileHandling fileHandling) : base("GarminDb", fileHandling) + { + _db = new DataStore(DbPath); + } + + public Task GetStagedPartialGarminAuthenticationAsync(int userId) + { + using var metrics = DbMetrics.DbActionDuration + .WithLabels("getStagedPartialGarminAuthentication", DbName) + .NewTimer(); + using var tracing = Tracing.Trace($"{nameof(GarminDb)}.{nameof(GetStagedPartialGarminAuthenticationAsync)}", TagValue.Db) + .WithTable(DbName); + + try + { + _db.TryGetItem(userId, out var data); + + if (data is null + || data.PartialGarminAuthentication is null + || data.PartialGarminAuthentication.ExpiresAt < DateTime.Now) return Task.FromResult((StagedPartialGarminAuthentication)null); + + return Task.FromResult(data.PartialGarminAuthentication); + } + catch (Exception e) + { + _logger.Error(e, "Failed to get garmin cookie jar from db"); + throw; + } + } + + public Task GetGarminOAuth1TokenAsync(int userId) + { + using var metrics = DbMetrics.DbActionDuration + .WithLabels("getOAuth1", DbName) + .NewTimer(); + using var tracing = Tracing.Trace($"{nameof(GarminDb)}.{nameof(GetGarminOAuth1TokenAsync)}", TagValue.Db) + .WithTable(DbName); + + try + { + _db.TryGetItem(userId, out var data); + + if (string.IsNullOrWhiteSpace(data?.OAuth1Token)) return Task.FromResult((OAuth1Token)null); + + var decrytedTokenString = data.OAuth1Token.Decrypt(); + var token = _fileHandler.DeserializeJson(decrytedTokenString); + return Task.FromResult(token); + } + catch (Exception e) + { + _logger.Error(e, "Failed to get oauth1 from db"); + throw; + } + } + + public Task GetGarminOAuth2TokenAsync(int userId) + { + using var metrics = DbMetrics.DbActionDuration + .WithLabels("getOAuth2", DbName) + .NewTimer(); + using var tracing = Tracing.Trace($"{nameof(GarminDb)}.{nameof(GetGarminOAuth2TokenAsync)}", TagValue.Db) + .WithTable(DbName); + + try + { + _db.TryGetItem(userId, out var data); + + if (string.IsNullOrWhiteSpace(data?.OAuth1Token)) return Task.FromResult((OAuth2Token)null); + + var decrytedTokenString = data.OAuth2Token.Decrypt(); + var token = _fileHandler.DeserializeJson(decrytedTokenString); + return Task.FromResult(token); + } + catch (Exception e) + { + _logger.Error(e, "Failed to get oauth2 from db"); + throw; + } + } + + public Task UpsertPartialGarminAuthenticationAsync(int userId, StagedPartialGarminAuthentication partialGarminAuthentication) + { + using var metrics = DbMetrics.DbActionDuration + .WithLabels("upsertPartialGarminAuthentication", DbName) + .NewTimer(); + using var tracing = Tracing.Trace($"{nameof(GarminDb)}.{nameof(UpsertPartialGarminAuthenticationAsync)}", TagValue.Db) + .WithTable(DbName); + try + { + _db.TryGetItem(userId, out var data); + + if (data is null) + data = new P2GGarminData(); + + data.PartialGarminAuthentication = partialGarminAuthentication; + + return _db.ReplaceItemAsync(userId.ToString(), data, upsert: true); + } + catch (Exception e) + { + _logger.Error(e, "Failed to upsert garmin oAuth1 token"); + return Task.FromResult(false); + } + } + + public Task UpsertGarminOAuth1TokenAsync(int userId, OAuth1Token token) + { + using var metrics = DbMetrics.DbActionDuration + .WithLabels("upsertOAuth1", DbName) + .NewTimer(); + using var tracing = Tracing.Trace($"{nameof(GarminDb)}.{nameof(UpsertGarminOAuth1TokenAsync)}", TagValue.Db) + .WithTable(DbName); + try + { + _db.TryGetItem(userId, out var data); + + if (data is null) + data = new P2GGarminData(); + + var serialized = _fileHandler.SerializeToJson(token); + var encrypted = serialized.Encrypt(); + + data.EncryptionVersion = EncryptionVersion.V1; + data.OAuth1Token = encrypted; + + return _db.ReplaceItemAsync(userId.ToString(), data, upsert: true); + } + catch (Exception e) + { + _logger.Error(e, "Failed to upsert garmin oAuth1 token"); + return Task.FromResult(false); + } + } + + public Task UpsertGarminOAuth2TokenAsync(int userId, OAuth2Token token) + { + using var metrics = DbMetrics.DbActionDuration + .WithLabels("upsertOAuth2", DbName) + .NewTimer(); + using var tracing = Tracing.Trace($"{nameof(GarminDb)}.{nameof(UpsertGarminOAuth2TokenAsync)}", TagValue.Db) + .WithTable(DbName); + try + { + _db.TryGetItem(userId, out var data); + + if (data is null) + data = new P2GGarminData(); + + var serialized = _fileHandler.SerializeToJson(token); + var encrypted = serialized.Encrypt(); + + data.EncryptionVersion = EncryptionVersion.V1; + data.OAuth2Token = encrypted; + + return _db.ReplaceItemAsync(userId.ToString(), data, upsert: true); + } + catch (Exception e) + { + _logger.Error(e, "Failed to upsert garmin oAuth1 token"); + return Task.FromResult(false); + } + } +} diff --git a/src/Garmin/Defaults.cs b/src/Garmin/Defaults.cs new file mode 100644 index 000000000..5a9d06133 --- /dev/null +++ b/src/Garmin/Defaults.cs @@ -0,0 +1,6 @@ +namespace Garmin; + +public static class Defaults +{ + public const string DefaultUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; +} diff --git a/src/Garmin/Dto/GarminApiAuthentication.cs b/src/Garmin/Dto/GarminApiAuthentication.cs new file mode 100644 index 000000000..4f6a93d1a --- /dev/null +++ b/src/Garmin/Dto/GarminApiAuthentication.cs @@ -0,0 +1,21 @@ +namespace Garmin.Dto; + +public class GarminApiAuthentication +{ + public AuthStage AuthStage { get; set; } + public OAuth2Token OAuth2Token { get; set; } + + public bool IsValid() + { + return AuthStage == AuthStage.Completed + && OAuth2Token is object + && !OAuth2Token.IsExpired(); + } +} + +public enum AuthStage : byte +{ + None = 0, + NeedMfaToken = 1, + Completed = 2, +} diff --git a/src/Garmin/Dto/OAuth1Token.cs b/src/Garmin/Dto/OAuth1Token.cs new file mode 100644 index 000000000..9fea987e6 --- /dev/null +++ b/src/Garmin/Dto/OAuth1Token.cs @@ -0,0 +1,7 @@ +namespace Garmin.Dto; + +public record OAuth1Token +{ + public string Token { get; set; } + public string TokenSecret { get; set; } +} diff --git a/src/Common/Stateful/OAuth2Token.cs b/src/Garmin/Dto/OAuth2Token.cs similarity index 63% rename from src/Common/Stateful/OAuth2Token.cs rename to src/Garmin/Dto/OAuth2Token.cs index 3f934968f..72c2336fd 100644 --- a/src/Common/Stateful/OAuth2Token.cs +++ b/src/Garmin/Dto/OAuth2Token.cs @@ -1,4 +1,6 @@ -namespace Common.Stateful; +using System; + +namespace Garmin.Dto; public record OAuth2Token { @@ -8,5 +10,11 @@ public record OAuth2Token public string Token_Type { get; set; } public string Refresh_Token { get; set; } public int Expires_In { get; set; } + public DateTime ExpiresAt { get; set; } public int Refresh_Token_Expires_In { get; set; } + + public bool IsExpired() + { + return ExpiresAt < DateTime.Now.AddHours(1); // pad the time a bit + } } diff --git a/src/Garmin/Dto/P2GGarminData.cs b/src/Garmin/Dto/P2GGarminData.cs new file mode 100644 index 000000000..6c721bbc3 --- /dev/null +++ b/src/Garmin/Dto/P2GGarminData.cs @@ -0,0 +1,23 @@ +using Common; +using Flurl.Http; +using System; + +namespace Garmin.Dto; + +public record P2GGarminData +{ + public EncryptionVersion EncryptionVersion { get; set; } + public string OAuth1Token { get; set; } + public string OAuth2Token { get; set; } + public StagedPartialGarminAuthentication PartialGarminAuthentication { get; set; } + +} + +public record StagedPartialGarminAuthentication +{ + public DateTime ExpiresAt { get; set; } + public AuthStage AuthStage { get; set; } + public string CookieJarString { get; set; } + public string UserAgent { get; set; } + public string MFACsrfToken { get; set; } +} \ No newline at end of file diff --git a/src/Garmin/Dto/UploadResponse.cs b/src/Garmin/Dto/UploadResponse.cs index 4c2f3f85d..84f6fbe2f 100644 --- a/src/Garmin/Dto/UploadResponse.cs +++ b/src/Garmin/Dto/UploadResponse.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; namespace Garmin.Dto { @@ -10,12 +9,18 @@ public class UploadResponse public class DetailedImportResult { - public DateTime CreationDate { get; set; } + public string CreationDate { get; set; } public string FileName { get; set; } - public int FileSize { get; set; } + public int? FileSize { get; set; } public string IpAddress { get; set; } - public int ProcessingTime { get; set; } - public string UploadId { get; set; } + public int? ProcessingTime { get; set; } + + /// + /// On Error, the type is string, empty string is returned. + /// On Success, the type is int, a number is returned. + /// + //public int UploadId { get; set; } + public ICollection Failures { get; set; } public ICollection Successes { get; set; } } @@ -23,14 +28,14 @@ public class DetailedImportResult public class Success { public string ExternalId { get; set; } - public string InternalId { get; set; } + //public int? InternalId { get; set; } public ICollection Messages { get; set; } } public class Failure { public string ExternalId { get; set; } - public string InternalId { get; set; } + //public int? InternalId { get; set; } public ICollection Messages { get; set; } } diff --git a/src/Garmin/GarminUploader.cs b/src/Garmin/GarminUploader.cs index fd9d6f8f9..fd30b72ef 100644 --- a/src/Garmin/GarminUploader.cs +++ b/src/Garmin/GarminUploader.cs @@ -1,5 +1,4 @@ -using Common; -using Common.Dto; +using Common.Dto; using Common.Observe; using Common.Service; using Common.Stateful; @@ -84,12 +83,23 @@ private async Task UploadAsync(string[] files, Settings settings) var auth = await _authService.GetGarminAuthenticationAsync(); + if (auth.AuthStage == Dto.AuthStage.NeedMfaToken) + throw new GarminUploadException("User needs to go through MFA flow to re-authenticate with Garmin. AuthStage: NeedMfaToken", -2); + + if (auth.AuthStage == Dto.AuthStage.None) + throw new GarminUploadException("Expected user to be authenticated with Garmin at this point, but they are not. AuthStage: None.", -3); + + var userAgent = Defaults.DefaultUserAgent; + var appConfig = await _settingsService.GetAppConfigurationAsync(); + if (!string.IsNullOrEmpty(appConfig.Developer.UserAgent)) + userAgent = appConfig.Developer.UserAgent; + foreach (var file in files) { try { _logger.Information("Uploading to Garmin: {@file}", file); - await _api.UploadActivity(file, settings.Format.Fit ? ".fit" : ".tcx", auth); + await _api.UploadActivity(file, settings.Format.Fit ? ".fit" : ".tcx", auth, userAgent); await RateLimit(); } catch (Exception e) { diff --git a/src/Peloton/ApiClient.cs b/src/Peloton/ApiClient.cs index 3719d837b..4eafe9652 100644 --- a/src/Peloton/ApiClient.cs +++ b/src/Peloton/ApiClient.cs @@ -19,8 +19,8 @@ public interface IPelotonApi { Task> GetWorkoutsAsync(int pageSize, int page); Task> GetWorkoutsAsync(DateTime fromUtc, DateTime toUtc); - Task GetWorkoutByIdAsync(string id); - Task GetWorkoutSamplesByIdAsync(string id); + Task GetWorkoutByIdAsync(string id); + Task GetWorkoutSamplesByIdAsync(string id); Task GetUserDataAsync(); Task GetJoinedChallengesAsync(int userId); Task GetUserChallengeDetailsAsync(int userId, string challengeId); @@ -152,7 +152,7 @@ public async Task GetUserDataAsync() .GetJsonAsync(); } - public async Task GetWorkoutByIdAsync(string id) + public async Task GetWorkoutByIdAsync(string id) { var auth = await GetAuthAsync(); return await $"{BaseUrl}/workout/{id}" @@ -163,10 +163,10 @@ public async Task GetWorkoutByIdAsync(string id) joins = "ride,ride.instructor" }) .StripSensitiveDataFromLogging(auth.Email, auth.Password) - .GetJsonAsync(); + .GetJsonAsync(); } - public async Task GetWorkoutSamplesByIdAsync(string id) + public async Task GetWorkoutSamplesByIdAsync(string id) { var auth = await GetAuthAsync(); return await $"{BaseUrl}/workout/{id}/performance_graph" @@ -177,7 +177,7 @@ public async Task GetWorkoutSamplesByIdAsync(string id) every_n=1 }) .StripSensitiveDataFromLogging(auth.Email, auth.Password) - .GetJsonAsync(); + .GetJsonAsync(); } public async Task GetJoinedChallengesAsync(int userId) diff --git a/src/Peloton/Peloton.csproj b/src/Peloton/Peloton.csproj index 25110eccb..d75c831a1 100644 --- a/src/Peloton/Peloton.csproj +++ b/src/Peloton/Peloton.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Peloton/PelotonService.cs b/src/Peloton/PelotonService.cs index ca6c83813..20cd9ff3d 100644 --- a/src/Peloton/PelotonService.cs +++ b/src/Peloton/PelotonService.cs @@ -11,6 +11,7 @@ using Serilog; using System; using System.Collections.Generic; +using System.Dynamic; using System.IO; using System.Linq; using System.Text.Json; @@ -273,7 +274,11 @@ public async Task GetWorkoutDetailsAsync(string workoutId) var workout = await workoutTask; var workoutSamples = await workoutSamplesTask; - var p2gWorkoutData = await BuildP2GWorkoutAsync(workoutId, workout, workoutSamples); + var p2gWorkoutData = new P2GWorkout() + { + Workout = workout, + WorkoutSamples = workoutSamples, + }; var classId = p2gWorkoutData?.Workout?.Ride?.Id; if (!string.IsNullOrWhiteSpace(classId) @@ -295,34 +300,6 @@ public async Task GetWorkoutDetailsAsync(string workoutId) return p2gWorkoutData; } - private async Task BuildP2GWorkoutAsync(string workoutId, JObject workout, JObject workoutSamples) - { - using var tracing = Tracing.Trace($"{nameof(PelotonService)}.{nameof(BuildP2GWorkoutAsync)}") - .WithWorkoutId(workoutId); - - dynamic data = new JObject(); - data.Workout = workout; - data.WorkoutSamples = workoutSamples; - - P2GWorkout deSerializedData = null; - try - { - deSerializedData = JsonSerializer.Deserialize(data.ToString(), new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }); - deSerializedData.Raw = data; - } - catch (Exception e) - { - _failedCount++; - - var title = "workout_failed_to_deserialize_" + workoutId; - await SaveRawDataAsync(data, title); - - _logger.Error("Failed to deserialize workout from Peloton. You can find the raw data from the workout here: {@FileName}", title, e); - } - - return deSerializedData; - } - private async Task SaveRawDataAsync(dynamic data, string workoutTitle) { using var tracing = Tracing.Trace($"{nameof(PelotonService)}.{nameof(SaveRawDataAsync)}") diff --git a/src/SharedUI/Pages/Index.razor b/src/SharedUI/Pages/Index.razor index 659cc5f1e..004264fcd 100644 --- a/src/SharedUI/Pages/Index.razor +++ b/src/SharedUI/Pages/Index.razor @@ -11,7 +11,7 @@
Periodic syncing is @syncStatus.AutoSyncHealthString - @if (syncStatus.SyncStatus != Common.Database.Status.NotRunning) + @if (syncStatus.SyncStatus != Api.Contract.Status.NotRunning) { Next Sync: @syncStatus.NextSyncTime @@ -211,34 +211,34 @@ } } - private ThemeColor GetPillThemeColor(Common.Database.Status status) + private ThemeColor GetPillThemeColor(Api.Contract.Status status) { switch (syncStatus.SyncStatus) { - case Common.Database.Status.Running: + case Api.Contract.Status.Running: return ThemeColor.Success; - case Common.Database.Status.NotRunning: + case Api.Contract.Status.NotRunning: return ThemeColor.Secondary; - case Common.Database.Status.Dead: + case Api.Contract.Status.Dead: return ThemeColor.Danger; - case Common.Database.Status.UnHealthy: + case Api.Contract.Status.UnHealthy: return ThemeColor.Warning; } return ThemeColor.Light; } - private ThemeColor GetPillTextThemeColor(Common.Database.Status status) + private ThemeColor GetPillTextThemeColor(Api.Contract.Status status) { switch (syncStatus.SyncStatus) { - case Common.Database.Status.Running: + case Api.Contract.Status.Running: return ThemeColor.Light; - case Common.Database.Status.NotRunning: + case Api.Contract.Status.NotRunning: return ThemeColor.Light; - case Common.Database.Status.Dead: + case Api.Contract.Status.Dead: return ThemeColor.Light; - case Common.Database.Status.UnHealthy: + case Api.Contract.Status.UnHealthy: return ThemeColor.Dark; } diff --git a/src/SharedUI/Shared/AppSettingsForm.razor b/src/SharedUI/Shared/AppSettingsForm.razor index d0ef9ceb8..f8648ee87 100644 --- a/src/SharedUI/Shared/AppSettingsForm.razor +++ b/src/SharedUI/Shared/AppSettingsForm.razor @@ -1,6 +1,8 @@ @inject IApiClient _apiClient @inject IHxMessengerService _toaster; + +
@@ -32,12 +34,15 @@ @code { private App appSettings; + private SettingsGarminGetResponse garminSettings; private string configDocumentation; + private GarminMfaModal? _garminMfaModal; public AppSettingsForm() { var settings = new SettingsGetResponse(); appSettings = settings.App; + garminSettings = settings.Garmin; } protected override Task OnInitializedAsync() @@ -63,6 +68,18 @@ _toaster.Clear(); + if (appSettings.EnablePolling) + { + // If polling is enabled, check if we need the user to initialize a token + await _garminMfaModal!.ShowAsync(ContinueSaveAsync); + return; + } + + await ContinueSaveAsync(); + } + + private async Task ContinueSaveAsync() + { try { appSettings = await _apiClient.SettingsAppPostAsync(appSettings); diff --git a/src/SharedUI/Shared/GarminMfaModal.razor b/src/SharedUI/Shared/GarminMfaModal.razor index 489b56edc..7511bcb4b 100644 --- a/src/SharedUI/Shared/GarminMfaModal.razor +++ b/src/SharedUI/Shared/GarminMfaModal.razor @@ -26,23 +26,12 @@ private bool _isErrored = false; private Func? _successCallBack; - protected override async Task OnInitializedAsync() - { - var settings = await _apiClient.SettingsGetAsync(); - if (settings.Garmin.Upload && settings.Garmin.TwoStepVerificationEnabled) - { - var garminAuthResult = await _apiClient.GetGarminAuthenticationAsync(); - _isAuthenticated = garminAuthResult?.IsAuthenticated ?? false; - _mfaEnabled = true; - } - - await base.OnInitializedAsync(); - } - public async Task ShowAsync(Func? successCallBack = null) { using var tracing = Tracing.ClientTrace($"{nameof(GarminMfaModal)}.{nameof(ShowAsync)}", kind: ActivityKind.Client); + await InitializeFieldsAsync(); + _isErrored = false; _successCallBack = null; if (_isAuthenticated || !_mfaEnabled) @@ -126,6 +115,17 @@ } } + private async Task InitializeFieldsAsync() + { + var settings = await _apiClient.SettingsGetAsync(); + if (settings.Garmin.Upload && settings.Garmin.TwoStepVerificationEnabled) + { + var garminAuthResult = await _apiClient.GetGarminAuthenticationAsync(); + _isAuthenticated = garminAuthResult?.IsAuthenticated ?? false; + _mfaEnabled = true; + } + } + protected void OnClosed() { _successCallBack = null; diff --git a/src/Common/Database/SyncStatusDb.cs b/src/Sync/Database/SyncStatusDb.cs similarity index 67% rename from src/Common/Database/SyncStatusDb.cs rename to src/Sync/Database/SyncStatusDb.cs index 9dc8fc5a9..87b1ead06 100644 --- a/src/Common/Database/SyncStatusDb.cs +++ b/src/Sync/Database/SyncStatusDb.cs @@ -1,29 +1,30 @@ -using Common.Observe; -using JsonFlatFileDataStore; -using Prometheus; -using Serilog; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Common.Database -{ - public interface ISyncStatusDb - { - Task GetSyncStatusAsync(); +using Common; +using Common.Database; +using Common.Observe; +using JsonFlatFileDataStore; +using Prometheus; +using Serilog; +using Sync.Dto; +using System; +using System.Threading.Tasks; + +namespace Sync.Database +{ + public interface ISyncStatusDb + { + Task GetSyncStatusAsync(); Task UpsertSyncStatusAsync(SyncServiceStatus status); - Task DeleteLegacySyncStatusAsync(); - } - - public class SyncStatusDb : DbBase, ISyncStatusDb - { - private static readonly ILogger _logger = LogContext.ForClass(); - - private readonly DataStore _db; - private readonly SyncServiceStatus _defaultSyncServiceStatus = new SyncServiceStatus(); - - public SyncStatusDb(IFileHandling fileHandling) : base("SyncStatus", fileHandling) - { + Task DeleteLegacySyncStatusAsync(); + } + + public class SyncStatusDb : DbBase, ISyncStatusDb + { + private static readonly ILogger _logger = LogContext.ForClass(); + + private readonly DataStore _db; + + public SyncStatusDb(IFileHandling fileHandling) : base("SyncStatus", fileHandling) + { _db = new DataStore(DbPath); Init(); } @@ -33,14 +34,16 @@ private void Init() try { var settings = _db.GetItem("1"); + + if (_db.TryGetItem(1, out var syncStatus)) + return; + + if (_db.InsertItem("1", new SyncServiceStatus())) + return; } - catch (KeyNotFoundException) + catch (Exception e) { - var success = _db.InsertItem("1", _defaultSyncServiceStatus); - if (!success) - { - _logger.Error($"Failed to init default Sync Status to Db for default user."); - } + _logger.Error($"Failed to init default Sync Status to Db for default user.", e); } } @@ -49,47 +52,49 @@ public Task DeleteLegacySyncStatusAsync() return _db.DeleteItemAsync("syncServiceStatus"); } - public Task GetSyncStatusAsync() - { - using var metrics = DbMetrics.DbActionDuration - .WithLabels("get", DbName) - .NewTimer(); - using var tracing = Tracing.Trace($"{nameof(SyncStatusDb)}.{nameof(GetSyncStatusAsync)}", TagValue.Db) - .WithTable(DbName); - - try - { - return Task.FromResult(_db.GetItem("1")); // hardcode to admin for now - } - catch(KeyNotFoundException k) - { - _logger.Verbose("syncServiceStatus key not found in DB for user 1.", k); - return Task.FromResult(new SyncServiceStatus()); - } - catch (Exception e) - { - _logger.Error(e, "Failed to get syncServiceStatus from db for user 1"); - return Task.FromResult(_defaultSyncServiceStatus); - } - } - - public Task UpsertSyncStatusAsync(SyncServiceStatus status) - { - using var metrics = DbMetrics.DbActionDuration - .WithLabels("upsert", DbName) - .NewTimer(); - using var tracing = Tracing.Trace($"{nameof(SyncStatusDb)}.{nameof(UpsertSyncStatusAsync)}", TagValue.Db) - .WithTable(DbName); - - try - { - return _db.ReplaceItemAsync("1", status, upsert: true); // hardcode to admin for now - } - catch (Exception e) - { - _logger.Error(e, "Failed to upsert syncServiceStatus to db for user 1"); - return Task.FromResult(false); - } - } - } -} + public async Task GetSyncStatusAsync() + { + using var metrics = DbMetrics.DbActionDuration + .WithLabels("get", DbName) + .NewTimer(); + using var tracing = Tracing.Trace($"{nameof(SyncStatusDb)}.{nameof(GetSyncStatusAsync)}", TagValue.Db) + .WithTable(DbName); + + try + { + if (_db.TryGetItem(1, out var syncStatus)) + return syncStatus; + + if (await _db.InsertItemAsync("1", new SyncServiceStatus())) + return new SyncServiceStatus(); + + _logger.Error("Failed to save default SyncServiceStatus to Sync DB."); + return new SyncServiceStatus(); + } + catch (Exception e) + { + _logger.Error(e, "Failed to get syncServiceStatus from db for user 1"); + return new SyncServiceStatus(); + } + } + + public Task UpsertSyncStatusAsync(SyncServiceStatus status) + { + using var metrics = DbMetrics.DbActionDuration + .WithLabels("upsert", DbName) + .NewTimer(); + using var tracing = Tracing.Trace($"{nameof(SyncStatusDb)}.{nameof(UpsertSyncStatusAsync)}", TagValue.Db) + .WithTable(DbName); + + try + { + return _db.ReplaceItemAsync("1", status, upsert: true); // hardcode to admin for now + } + catch (Exception e) + { + _logger.Error(e, "Failed to upsert syncServiceStatus to db for user 1"); + return Task.FromResult(false); + } + } + } +} diff --git a/src/Common/Database/SyncServiceStatus.cs b/src/Sync/Dto/SyncServiceStatus.cs similarity index 74% rename from src/Common/Database/SyncServiceStatus.cs rename to src/Sync/Dto/SyncServiceStatus.cs index 8930ba4fb..dd4621a06 100644 --- a/src/Common/Database/SyncServiceStatus.cs +++ b/src/Sync/Dto/SyncServiceStatus.cs @@ -1,6 +1,6 @@ using System; -namespace Common.Database +namespace Sync.Dto { public class SyncServiceStatus { @@ -8,10 +8,10 @@ public class SyncServiceStatus public DateTime? NextSyncTime { get; set; } public DateTime? LastSuccessfulSyncTime { get; set; } public Status SyncStatus { get; set; } - public string LastErrorMessage { get; set; } + public string LastErrorMessage { get; set; } = string.Empty; } - public enum Status + public enum Status : byte { NotRunning = 0, Running = 1, diff --git a/src/Sync/SyncService.cs b/src/Sync/SyncService.cs index 16431c45a..4b6b940c3 100644 --- a/src/Sync/SyncService.cs +++ b/src/Sync/SyncService.cs @@ -1,5 +1,4 @@ using Common; -using Common.Database; using Common.Dto; using Common.Dto.Peloton; using Common.Observe; @@ -11,6 +10,8 @@ using Peloton; using Prometheus; using Serilog; +using Sync.Database; +using Sync.Dto; using System; using System.Collections.Generic; using System.Linq; @@ -172,29 +173,38 @@ public async Task SyncAsync(IEnumerable workoutIds, ICollect } catch (ArgumentException ae) { - _logger.Error(ae, $"Failed to upload to Garmin Connect. {ae.Message}"); + _logger.Error(ae, $"Sync failed to upload to Garmin Connect. {ae.Message}"); response.SyncSuccess = false; response.UploadToGarminSuccess = false; - response.Errors.Add(new ServiceError() { Message = $"Failed to upload workouts to Garmin Connect. {ae.Message}" }); + response.Errors.Add(new ServiceError() { Message = $"Failed to upload workouts to Garmin Connect. {ae.Message}", Exception = ae }); return response; } catch (GarminAuthenticationError gae) { - _logger.Error(gae, $"Sync failed to authenticate with Garmin. {gae.Message}"); + _logger.Error(gae, $"Garmin Uploader failed to authenticate with Garmin. {gae.Message}"); response.SyncSuccess = false; response.UploadToGarminSuccess = false; - response.Errors.Add(new ServiceError() { Message = gae.Message }); + response.Errors.Add(new ServiceError() { Message = gae.Message, Exception = gae }); + return response; + } + catch (GarminUploadException gue) + { + _logger.Error(gue, $"Garmin Uploader failed to upload to Garmin Connect. {gue.Message}"); + + response.SyncSuccess = false; + response.UploadToGarminSuccess = false; + response.Errors.Add(new ServiceError() { Message = gue.Message, Exception = gue }); return response; } catch (Exception e) { - _logger.Error(e, "Failed to upload workouts to Garmin Connect. You can find the converted files at {@Path} \\n You can manually upload your files to Garmin Connect, or wait for P2G to try again on the next sync job.", settings.App.OutputDirectory); + _logger.Error(e, "Unexpected error. Failed to upload workouts to Garmin Connect. You can find the converted files at {@Path} \\n You can manually upload your files to Garmin Connect, or wait for P2G to try again on the next sync job.", settings.App.OutputDirectory); response.SyncSuccess = false; response.UploadToGarminSuccess = false; - response.Errors.Add(new ServiceError() { Message = $"Failed to upload workouts to Garmin Connect. {e.Message}" }); + response.Errors.Add(new ServiceError() { Message = $"Failed to upload workouts to Garmin Connect. {e.Message}", Exception = e }); return response; } finally diff --git a/src/UnitTests/AdHocTests.cs b/src/UnitTests/AdHocTests.cs index d96813f21..1dc5a0256 100644 --- a/src/UnitTests/AdHocTests.cs +++ b/src/UnitTests/AdHocTests.cs @@ -44,9 +44,12 @@ public void Setup() .CreateLogger(); // Allows using fiddler - FlurlHttp.Configure(cli => + FlurlHttp.Clients.WithDefaults(cli => { - cli.HttpClientFactory = new UntrustedCertClientFactory(); + cli.ConfigureInnerHandler(handler => + { + handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; + }); }); } @@ -201,7 +204,7 @@ public ConverterInstance(ISettingsService settings, IFileHandling fileHandler) : public async Task> ConvertForTest(string path, Settings settings) { - var workoutData = fileHandler.DeserializeJson(path); + var workoutData = fileHandler.DeserializeJsonFile(path); var converted = await this.ConvertInternalAsync(workoutData, settings); return converted.Item2; @@ -209,7 +212,7 @@ public async Task> ConvertForTest(string path, Settings settin public async Task>> Convert(string path, Settings settings) { - var workoutData = fileHandler.DeserializeJson(path); + var workoutData = fileHandler.DeserializeJsonFile(path); var converted = await this.ConvertInternalAsync(workoutData, settings); return converted; @@ -219,17 +222,6 @@ public async Task>> Convert(string path, Setting { base.Save(data, path); } - } - - private class UntrustedCertClientFactory : DefaultHttpClientFactory - { - public override HttpMessageHandler CreateMessageHandler() - { - return new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - } } } } diff --git a/src/UnitTests/Api.Service/SettingsUpdaterServiceTests.cs b/src/UnitTests/Api.Service/SettingsUpdaterServiceTests.cs index cc845daa9..06debffcf 100644 --- a/src/UnitTests/Api.Service/SettingsUpdaterServiceTests.cs +++ b/src/UnitTests/Api.Service/SettingsUpdaterServiceTests.cs @@ -5,6 +5,7 @@ using Common.Dto; using Common.Service; using FluentAssertions; +using Garmin.Auth; using Moq; using Moq.AutoMock; using NUnit.Framework; @@ -27,34 +28,6 @@ public async Task UpdateAppSettingsAsync_With_NullRequest_Returns400() response.Error.Message.Should().Be("Updated AppSettings must not be null or empty."); } - [Test] - public async Task UpdateAppSettingsAsync_With_EnablePollingWhenGarminMFAEnabled_Throws() - { - var autoMocker = new AutoMocker(); - var service = autoMocker.CreateInstance(); - var settingService = autoMocker.GetMock(); - var fileHandler = autoMocker.GetMock(); - - fileHandler - .Setup(f => f.DirExists("blah")) - .Returns(true) - .Verifiable(); - - settingService.SetupWithAny>(nameof(settingService.Object.GetSettingsAsync)) - .ReturnsAsync(new Settings() { Garmin = new() { TwoStepVerificationEnabled = true } }); - - var request = new App() - { - EnablePolling = true, - }; - - var response = await service.UpdateAppSettingsAsync(request); - - response.IsErrored().Should().BeTrue(); - response.Error.Should().NotBeNull(); - response.Error.Message.Should().Be("Automatic Syncing cannot be enabled when Garmin TwoStepVerification is enabled."); - } - [Test] public async Task UpdatePelotonSettingsAsync_With_NullRequest_ReturnsError() { @@ -168,25 +141,64 @@ public async Task GarminPost_With_NullRequest_Returns400() } [Test] - public async Task GarminPost_With_EnableGarminMFAWhenPollingEnabled_Throws() + public async Task GarminPost_With_EmailChange_Should_SignOut_of_Garmin() { var autoMocker = new AutoMocker(); var service = autoMocker.CreateInstance(); var settingService = autoMocker.GetMock(); - settingService.SetupWithAny>(nameof(settingService.Object.GetSettingsAsync)) - .ReturnsAsync(new Settings() { App = new() { EnablePolling = true } }); + settingService + .SetupWithAny>(nameof(settingService.Object.GetSettingsAsync)) + .ReturnsAsync(new Settings() + { + App = new() { EnablePolling = true }, + Garmin = new () { Email = "ogEmail", Password = "ogPassword" } + }); SettingsGarminPostRequest request = new() { - Upload = true, - TwoStepVerificationEnabled = true + Email = "newEmail", }; var response = await service.UpdateGarminSettingsAsync(request); - response.IsErrored().Should().BeTrue(); - response.Error.Should().NotBeNull(); - response.Error.Message.Should().Be("Garmin TwoStepVerification cannot be enabled while Automatic Syncing is enabled. Please disable Automatic Syncing first."); + autoMocker + .GetMock() + .Verify(x => x.SignOutAsync(), Times.Once); + + response.IsErrored().Should().BeFalse(); + response.Error.Should().BeNull(); + response.Successful.Should().BeTrue(); + } + + [Test] + public async Task GarminPost_With_PasswordChange_Should_SignOut_of_Garmin() + { + var autoMocker = new AutoMocker(); + var service = autoMocker.CreateInstance(); + var settingService = autoMocker.GetMock(); + + settingService + .SetupWithAny>(nameof(settingService.Object.GetSettingsAsync)) + .ReturnsAsync(new Settings() + { + App = new() { EnablePolling = true }, + Garmin = new() { Email = "ogEmail", Password = "ogPassword" } + }); + + SettingsGarminPostRequest request = new() + { + Password = "newPassword", + }; + + var response = await service.UpdateGarminSettingsAsync(request); + + autoMocker + .GetMock() + .Verify(x => x.SignOutAsync(), Times.Once); + + response.IsErrored().Should().BeFalse(); + response.Error.Should().BeNull(); + response.Successful.Should().BeTrue(); } } diff --git a/src/UnitTests/Api/Controllers/SyncControllerTests.cs b/src/UnitTests/Api/Controllers/SyncControllerTests.cs index 67e1a29a9..b70a42d89 100644 --- a/src/UnitTests/Api/Controllers/SyncControllerTests.cs +++ b/src/UnitTests/Api/Controllers/SyncControllerTests.cs @@ -1,10 +1,10 @@ using Api.Contract; using Api.Controllers; -using Common; using Common.Dto; using Common.Service; -using Common.Stateful; using FluentAssertions; +using Garmin.Auth; +using Garmin.Dto; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; @@ -31,8 +31,9 @@ public async Task SyncAsync_With_NullRequest_Returns400() settings.SetupWithAny>(nameof(settings.Object.GetSettingsAsync)) .ReturnsAsync(new Settings()); - settings.SetupWithAny(nameof(settings.Object.GetGarminAuthentication)) - .Returns((GarminApiAuthentication)null); + var garminAuthService = autoMocker.GetMock(); + garminAuthService.SetupWithAny>(nameof(garminAuthService.Object.GetGarminAuthenticationAsync)) + .ReturnsAsync((GarminApiAuthentication)null); var response = await controller.SyncAsync(null); @@ -52,8 +53,9 @@ public async Task SyncAsync_With_DefaultRequest_Returns400() settings.SetupWithAny>(nameof(settings.Object.GetSettingsAsync)) .ReturnsAsync(new Settings()); - settings.SetupWithAny(nameof(settings.Object.GetGarminAuthentication)) - .Returns((GarminApiAuthentication)null); + var garminAuthService = autoMocker.GetMock(); + garminAuthService.SetupWithAny>(nameof(garminAuthService.Object.GetGarminAuthenticationAsync)) + .ReturnsAsync((GarminApiAuthentication)null); var request = new SyncPostRequest(); @@ -75,8 +77,9 @@ public async Task SyncAsync_With_EmptyWorkoutIdsRequest_Returns400() settings.SetupWithAny>(nameof(settings.Object.GetSettingsAsync)) .ReturnsAsync(new Settings()); - settings.SetupWithAny(nameof(settings.Object.GetGarminAuthentication)) - .Returns((GarminApiAuthentication)null); + var garminAuthService = autoMocker.GetMock(); + garminAuthService.SetupWithAny>(nameof(garminAuthService.Object.GetGarminAuthenticationAsync)) + .ReturnsAsync((GarminApiAuthentication)null); var request = new SyncPostRequest() { WorkoutIds = new List() }; @@ -98,8 +101,9 @@ public async Task SyncAsync_WhenGarminMfaEnabled_AndNoAuthTokenYet_Returns401() settings.SetupWithAny>(nameof(settings.Object.GetSettingsAsync)) .ReturnsAsync(new Settings() { Garmin = new() { Upload = true, TwoStepVerificationEnabled = true } }); - settings.SetupWithAny(nameof(settings.Object.GetGarminAuthentication)) - .Returns((GarminApiAuthentication)null); + var garminAuthService = autoMocker.GetMock(); + garminAuthService.SetupWithAny>(nameof(garminAuthService.Object.GetGarminAuthenticationAsync)) + .ReturnsAsync((GarminApiAuthentication)null); var request = new SyncPostRequest() { WorkoutIds = new List() { "someId" } }; diff --git a/src/UnitTests/Common/Database/DbMigrationsTests.cs b/src/UnitTests/Common/Database/DbMigrationsTests.cs index 830cb5c5b..c77e6b108 100644 --- a/src/UnitTests/Common/Database/DbMigrationsTests.cs +++ b/src/UnitTests/Common/Database/DbMigrationsTests.cs @@ -7,6 +7,7 @@ using Moq; using Moq.AutoMock; using NUnit.Framework; +using Sync.Database; using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -64,7 +65,6 @@ public async Task MigrateToAdminUserAsync_When_LegacySettings_Migrates() settingsDb.Verify(); settingsDb.Verify(x => x.RemoveLegacySettingsAsync(), Times.Once); usersDb.Verify(x => x.GetUsersAsync(), Times.Once); - mocker.GetMock().Verify(x => x.DeleteLegacySyncStatusAsync(), Times.Once); } [Test] @@ -96,6 +96,5 @@ public async Task MigrateToAdminUserAsync_When_FailsToMigrate_DoesNotThrow() settingsDb.Verify(); settingsDb.Verify(x => x.RemoveLegacySettingsAsync(), Times.Never); usersDb.Verify(x => x.GetUsersAsync(), Times.Once); - mocker.GetMock().Verify(x => x.DeleteLegacySyncStatusAsync(), Times.Once); } } diff --git a/src/UnitTests/Common/Helpers/WorkoutHelperTests.cs b/src/UnitTests/Common/Helpers/WorkoutHelperTests.cs index f6e3501a9..bee2e6740 100644 --- a/src/UnitTests/Common/Helpers/WorkoutHelperTests.cs +++ b/src/UnitTests/Common/Helpers/WorkoutHelperTests.cs @@ -33,6 +33,7 @@ public string GetTitle_Should_ReplaceInvalidChars(string title, string instructo [TestCase("My Title", "é", ExpectedResult = "My_Title_with_é")] [TestCase("My Title", "ä", ExpectedResult = "My_Title_with_ä")] + [TestCase("My Title", "&", ExpectedResult = "My_Title_with_&")] public string GetTitle_Should_Handle_SpecialChars(string title, string instructor) { var workout = new Workout() diff --git a/src/UnitTests/Conversion/FitConverterTests.cs b/src/UnitTests/Conversion/FitConverterTests.cs index 12ee6050d..c2be1a7c4 100644 --- a/src/UnitTests/Conversion/FitConverterTests.cs +++ b/src/UnitTests/Conversion/FitConverterTests.cs @@ -180,7 +180,7 @@ public ConverterInstance(ISettingsService settings) : base(settings, null) { } public async Task> ConvertForTest(string path, Settings settings) { - var workoutData = fileHandler.DeserializeJson(path); + var workoutData = fileHandler.DeserializeJsonFile(path); var converted = await this.ConvertInternalAsync(workoutData, settings); return converted.Item2; diff --git a/src/UnitTests/Peloton/PelotonServiceTests.cs b/src/UnitTests/Peloton/PelotonServiceTests.cs index 9b2501f3f..5c77b7b68 100644 --- a/src/UnitTests/Peloton/PelotonServiceTests.cs +++ b/src/UnitTests/Peloton/PelotonServiceTests.cs @@ -36,30 +36,30 @@ public async Task GetWorkoutDetailsAsync_Should_EnrichAllWorkouts() var pelotonApi = autoMocker.GetMock(); pelotonApi.Setup(x => x.GetWorkoutByIdAsync("1")) - .ReturnsAsync(JObject.FromObject(new Workout() { Ride = new Ride() { Id = "12" } })) + .ReturnsAsync(new Workout() { Ride = new Ride() { Id = "12" } }) .Verifiable(); pelotonApi.Setup(x => x.GetWorkoutSamplesByIdAsync("1")) - .ReturnsAsync(new JObject()) + .ReturnsAsync(new WorkoutSamples()) .Verifiable(); pelotonApi.Setup(x => x.GetWorkoutByIdAsync("2")) - .ReturnsAsync(JObject.FromObject(new Workout() { Ride = new Ride() { Id = "22" } })) + .ReturnsAsync(new Workout() { Ride = new Ride() { Id = "22" } }) .Verifiable(); pelotonApi.Setup(x => x.GetWorkoutSamplesByIdAsync("2")) - .ReturnsAsync(new JObject()) + .ReturnsAsync(new WorkoutSamples()) .Verifiable(); pelotonApi.Setup(x => x.GetWorkoutByIdAsync("3")) - .ReturnsAsync(JObject.FromObject(new Workout() { Ride = new Ride() { Id = "32" } })) + .ReturnsAsync(new Workout() { Ride = new Ride() { Id = "32" } }) .Verifiable(); pelotonApi.Setup(x => x.GetWorkoutSamplesByIdAsync("3")) - .ReturnsAsync(new JObject()) - .Verifiable(); - - // ACT + .ReturnsAsync(new WorkoutSamples()) + .Verifiable(); + + // ACT await pelotonService.GetWorkoutDetailsAsync(new List() { new Workout() { Status = "COMPLETE", Id = "1" }, @@ -122,7 +122,7 @@ public async Task GetWorkoutDetailsAsync_Should_Only_Enrich_ValidRideIds(string var pelotonApi = autoMocker.GetMock(); pelotonApi.Setup(x => x.GetWorkoutByIdAsync("someWorkoutId")) - .ReturnsAsync(JObject.FromObject(new Workout() { Ride = new Ride() { Id = rideId } })); + .ReturnsAsync(new Workout() { Ride = new Ride() { Id = rideId } }); var workouts = await pelotonService.GetWorkoutDetailsAsync("someWorkoutId"); diff --git a/src/UnitTests/Sync/SyncServiceTests.cs b/src/UnitTests/Sync/SyncServiceTests.cs index 2004f6190..90f4288ee 100644 --- a/src/UnitTests/Sync/SyncServiceTests.cs +++ b/src/UnitTests/Sync/SyncServiceTests.cs @@ -1,5 +1,4 @@ using Common; -using Common.Database; using Common.Dto; using Common.Dto.Peloton; using Common.Service; @@ -13,6 +12,8 @@ using Peloton; using Philosowaffle.Capability.ReleaseChecks.Model; using Sync; +using Sync.Database; +using Sync.Dto; using System; using System.Collections.Generic; using System.Threading.Tasks; diff --git a/src/UnitTests/UnitTests.csproj b/src/UnitTests/UnitTests.csproj index be96e9894..b382f2677 100644 --- a/src/UnitTests/UnitTests.csproj +++ b/src/UnitTests/UnitTests.csproj @@ -8,12 +8,12 @@ - - + + - - - + + + diff --git a/src/WebUI/WebUI.csproj b/src/WebUI/WebUI.csproj index 5e13f8642..77e69bb1c 100644 --- a/src/WebUI/WebUI.csproj +++ b/src/WebUI/WebUI.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/vNextReleaseNotes.md b/vNextReleaseNotes.md index 155b8e472..49ebb3752 100644 --- a/vNextReleaseNotes.md +++ b/vNextReleaseNotes.md @@ -3,7 +3,9 @@ ## Features -- +- [#585] + - Garmin Authentication now saves and refreshes tokens. Users using MFA will now only need provide their MFA code once. + - For those running via Docker, automatic syncing now works for MFA users after you have entered your code the first time. ## Fixes @@ -14,15 +16,15 @@ - Console - `console-stable` - `console-latest` - - `console-v4.X.0` + - `console-v4.3.0` - `console-v4` - Api - `api-stable` - `api-latest` - - `api-v4.X.0` + - `api-v4.3.0` - `api-v4` - WebUI - `webui-stable` - `webui-latest` - - `webui-v4.X.0` + - `webui-v4.3.0` - `webui-v4`