diff --git a/.gitignore b/.gitignore index 9b68f68..187e198 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,6 @@ docker-compose.override.yaml /phpcs.xml ###< squizlabs/php_codesniffer ### -.idea/ \ No newline at end of file +.idea/ + +garmin_credentials.json diff --git a/composer.json b/composer.json index b301c0c..c637995 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "ext-ctype": "*", "ext-iconv": "*", "ext-json": "*", - "dawguk/php-garmin-connect": "^1.7", + "ext-openssl": "*", "doctrine/collections": "^1.6", "league/csv": "^9.6", "symfony/console": "6.4.*", diff --git a/config/services.yaml b/config/services.yaml index c34e723..28f587b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -12,8 +12,6 @@ services: autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. bind: - $garminUsername: '%env(GARMIN_USERNAME)%' - $garminPassword: '%env(GARMIN_PASSWORD)%' _instanceof: App\Library\Handler\HandlerInterface: diff --git a/src/Http/GarminClient/ClientException.php b/src/Http/GarminClient/ClientException.php new file mode 100644 index 0000000..8dd4e2d --- /dev/null +++ b/src/Http/GarminClient/ClientException.php @@ -0,0 +1,10 @@ + 'gauth-widget', + 'embedWidget' => 'true', + ]; + + public const GARMIN_EMBED_PARAMETERS = [ + ...self::GARMIN_DEFAULT_PARAMETERS, + 'gauthHost' => self::GARMIN_SSO_URL, + ]; + + public const GARMIN_SIGN_IN_PARAMETERS = [ + ...self::GARMIN_DEFAULT_PARAMETERS, + 'gauthHost' => self::GARMIN_SSO_EMBED_URL, + 'service' => self::GARMIN_SSO_EMBED_URL, + 'source' => self::GARMIN_SSO_EMBED_URL, + 'redirectAfterAccountLoginUrl' => self::GARMIN_SSO_EMBED_URL, + 'redirectAfterAccountCreationUrl' => self::GARMIN_SSO_EMBED_URL, + ]; + + protected string $consumerKey = ''; + protected string $consumerSecret = ''; + + public function __construct( + private HttpClientInterface $httpClient, + #[Autowire(env: '%env(GARMIN_USERNAME)%')] + private string $garminUsername, + #[Autowire(env: '%env(GARMIN_PASSWORD)%')] + private string $garminPassword, + #[Autowire('%kernel.project_dir%')] + private readonly string $projectDirectory + ) { + } + + public function authenticate() + { + $filePath = $this->projectDirectory . DIRECTORY_SEPARATOR . self::GARMIN_AUTHENTICATION_FILE; + + // Load file from path if it exists + if (file_exists($filePath)) { + $json = file_get_contents($filePath); + $oauthData = json_decode($json, true); + + if ($oauthData['expires_at'] > time()) { + // Great the access token isn't expired so lets reuse it from the file + return $oauthData['access_token']; + } + // Access token must be expired so lets refresh it + if ($oauthData['refresh_token_expires_at'] < time()) { + $oauthData = $this->exchangeOauth1TokenForOauth2Token($oauthData['token'], $oauthData['token_secret']); + + $this->refreshFile($filePath, $oauthData); + return $oauthData['access_token']; + } + } + + // Run through the login process which is ridiculous + $this->fetchConsumerCredentials(); + $this->initializeCookies(); + $csrfToken = $this->fetchCSRFToken(); + $ticket = $this->submitLoginRequest($csrfToken); + $oauth1Token = $this->getOauthToken($ticket); + $oauthToken = $oauth1Token['oauth_token']; + $oauthTokenSecret = $oauth1Token['oauth_token_secret']; + $oauthData = $this->exchangeOauth1TokenForOauth2Token($oauthToken, $oauthTokenSecret); + $oauthData['token'] = $oauthToken; + $oauthData['token_secret'] = $oauthTokenSecret; + + $this->refreshFile($filePath, $oauthData); + return $oauthData['access_token']; + } + + protected function refreshFile(string $filePath, array $oauthData): void + { + file_put_contents($filePath, json_encode($oauthData)); + } + + protected function fetchConsumerCredentials(): void + { + $response = $this->httpClient->request('GET', self::GARTH_SSO_TOKENS); + $oauth = $response->toArray(); + $this->consumerKey = $oauth['consumer_key']; + $this->consumerSecret = $oauth['consumer_secret']; + } + + protected function initializeCookies(): void + { + $this->httpClient->request('GET', self::GARMIN_SSO_EMBED_URL, [ + 'query' => self::GARMIN_EMBED_PARAMETERS, + ]); + } + + protected function fetchCSRFToken(): string + { + $response = $this->httpClient->request('GET', self::GARMIN_SSO_SIGN_IN_URL, [ + 'query' => self::GARMIN_SIGN_IN_PARAMETERS, + ]); + + $responseBody = $response->getContent(); + + preg_match('/name="_csrf"\s+value="(.+?)"/', $responseBody, $csrfTokens); + + if (! isset($csrfTokens[1])) { + throw new ClientException('CSRF token is missing.'); + } + + return $csrfTokens[1]; + } + + protected function submitLoginRequest(string $csrfToken) + { + $response = $this->httpClient->request('POST', self::GARMIN_SSO_SIGN_IN_URL, [ + 'query' => self::GARMIN_SIGN_IN_PARAMETERS, + 'headers' => [ + 'referer' => self::GARMIN_SSO_SIGN_IN_URL, + ], + 'body' => [ + 'username' => $this->garminUsername, + 'password' => $this->garminPassword, + 'embed' => true, + '_csrf' => $csrfToken, + ] + ]); + + + $responseBody = $response->getContent(false); + + preg_match('/(.+?)<\/title>/', $responseBody, $titles); + + if (! isset($titles[1])) { + throw new ClientException('TITLE is missing.'); + } + + $title = $titles[1]; + + // YA!!!!! we got into Garmin + if ($title === 'Success') { + preg_match('/embed\?ticket=([^"]+)"/', $responseBody, $tokens); + + if (isset($tokens[1])) { + return $tokens[1]; + } + } + + throw new ClientException('Invalid title!'); + } + + public function getOauthToken(string $ticket): array + { + $oauthRequest = new OauthHttpDecorator($this->consumerKey, $this->consumerSecret); + + $response = $oauthRequest->request( + $this->httpClient, + 'GET', + GarminClient::GARMIN_API_URL . '/oauth-service/oauth/preauthorized', + [ + 'query' => [ + 'ticket' => $ticket, + 'login-url' => self::GARMIN_SSO_EMBED_URL, + 'accepts-mfa-tokens' => 'true', + ] + ] + ); + + $oauth1Body = $response->getContent(); + + parse_str($oauth1Body, $oauthResponseBody); + + return $oauthResponseBody; + } + + public function exchangeOauth1TokenForOauth2Token( + string $oauthToken, + string $oauthTokenSecret, + ) { + $oauthRequest = new OauthHttpDecorator( + $this->consumerKey, + $this->consumerSecret, + $oauthToken, + $oauthTokenSecret + ); + + $oauth2Response = $oauthRequest->request( + $this->httpClient, + 'POST', + GarminClient::GARMIN_API_URL . '/oauth-service/oauth/exchange/user/2.0', + [ + 'headers' => [ + 'User-Agent' => GarminClient::USER_AGENT, + 'Content-Type' => 'application/x-www-form-urlencoded' + ] + ] + ); + + $oauth2Data = $oauth2Response->toArray(); + + $oauth2Data['expires_at'] = $oauth2Data['expires_in'] + time(); + $oauth2Data['refresh_token_expires_at'] = $oauth2Data['refresh_token_expires_in'] + time(); + + return $oauth2Data; + } + + public function setGarminUsername(string $garminUsername): void + { + $this->garminUsername = $garminUsername; + } + + public function setGarminPassword(string $garminPassword): void + { + $this->garminPassword = $garminPassword; + } +} diff --git a/src/Http/GarminClient/GarminClient.php b/src/Http/GarminClient/GarminClient.php new file mode 100644 index 0000000..fdf7277 --- /dev/null +++ b/src/Http/GarminClient/GarminClient.php @@ -0,0 +1,182 @@ +<?php + +namespace App\Http\GarminClient; + +use App\Http\GarminClient\GarminAuthenticator; +use Symfony\Component\HttpClient\HttpOptions; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class GarminClient +{ + public const GARMIN_API_URL = 'https://connectapi.garmin.com'; + public const USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; + + public function __construct( + private HttpClientInterface $client, + private GarminAuthenticator $garminAuthenticator + ) { + $this->setup(); + } + + public function addCredentials(string $username, string $password): void + { + $this->garminAuthenticator->setGarminUsername($username); + $this->garminAuthenticator->setGarminPassword($password); + } + + public function setup(): void + { + $accessToken = $this->garminAuthenticator->authenticate(); + + $this->client = $this->client->withOptions( + (new HttpOptions()) + ->setHeaders([ + 'User-Agent' => self::USER_AGENT, + 'Authorization' => 'Bearer ' . $accessToken, + ]) + ->toArray() + ); + } + + public function fetchPersonalInformation(): array + { + $response = $this->client->request('GET', self::GARMIN_API_URL . '/userprofile-service/userprofile/personal-information'); + + return $response->toArray(); + } + + public function getWorkoutList($intStart = 0, $intLimit = 10, $myWorkoutsOnly = true, $sharedWorkoutsOnly = false) + { + $queryParameters = array( + 'start' => $intStart, + 'limit' => $intLimit, + 'myWorkoutsOnly' => $myWorkoutsOnly, + 'sharedWorkoutsOnly' => $sharedWorkoutsOnly + ); + + $client = $this->client->withOptions( + (new HttpOptions()) + ->setQuery($queryParameters) + ->toArray() + ); + + $response = $client->request( + 'GET', + self::GARMIN_API_URL . '/workout-service/workouts' + ); + + if ($response->getStatusCode() != 200) { + throw new ClientException('Response code - ' . $response->getStatusCode()); + } + + $objResponse = json_decode($response->getContent()); + return $objResponse; + } + + public function createWorkout($data) + { + if (empty($data)) { + throw new DataException('Data must be supplied to create a new workout.'); + } + + $headers = [ + 'NK: NT', + 'Content-Type: application/json' + ]; + + $client = $this->client->withOptions( + (new HttpOptions()) + ->setJson($data) + ->setHeaders($headers) + ->toArray() + ); + + $response = $client->request( + 'POST', + self::GARMIN_API_URL . '/workout-service/workout' + ); + + if ($response->getStatusCode() != 200) { + throw new ClientException('Response code - ' . $response->getStatusCode()); + } + + $objResponse = json_decode($response->getContent()); + return $objResponse; + } + + /** + * Delete a workout based upon the workout ID + * + * @param $id + * @return mixed + * @throws ClientException + */ + public function deleteWorkout($id) + { + if (empty($id)) { + throw new DataException('Workout ID must be supplied to delete a workout.'); + } + + $headers = [ + 'NK: NT', + 'X-HTTP-Method-Override: DELETE' + ]; + + $client = $this->client->withOptions( + (new HttpOptions()) + ->setHeaders($headers) + ->toArray() + ); + + $response = $client->request( + 'POST', + self::GARMIN_API_URL . '/workout-service/workout/' . $id + ); + + if ($response->getStatusCode() != 204) { + throw new ClientException('Response code - ' . $response->getStatusCode()); + } + + $objResponse = json_decode($response->getContent()); + return $objResponse; + } + + /** + * Schedule a workout on the calendar + * + * @param $id + * @param $payload + * @return mixed + * @throws ClientException + */ + public function scheduleWorkout($id, $data) + { + $headers = [ + 'NK: NT', + 'Content-Type: application/json' + ]; + + if (empty($id)) { + throw new DataException('Workout ID must be supplied to delete a workout.'); + } + + $client = $this->client->withOptions( + (new HttpOptions()) + ->setJson($data) + ->setHeaders($headers) + ->toArray() + ); + + $response = $client->request( + 'POST', + self::GARMIN_API_URL . '/workout-service/schedule/' . $id + ); + + if ($response->getStatusCode() != 200) { + throw new ClientException('Response code - ' . $response->getStatusCode()); + } + + $objResponse = json_decode($response->getContent()); + return $objResponse; + } +} diff --git a/src/Http/OauthHttpDecorator.php b/src/Http/OauthHttpDecorator.php new file mode 100644 index 0000000..75d8511 --- /dev/null +++ b/src/Http/OauthHttpDecorator.php @@ -0,0 +1,59 @@ +<?php + +namespace App\Http; + +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class OauthHttpDecorator +{ + public function __construct( + private string $consumerKey, + private string $consumerSecret, + private string $token = '', + private string $tokenSecret = '', + ) { + } + + public function request( + HttpClientInterface $client, + string $method, + string $requestUri, + array $options = [], + ) { + if (! isset($options['query'])) { + $options['query'] = []; + } + + $options = $this->getQueryParameters($method, $requestUri, $options); + + return $client->request($method, $requestUri, $options); + } + + protected function getQueryParameters(string $method, string $requestUri, array $additionalParameters): array + { + $queryParameters = array_merge( + $additionalParameters['query'], + [ + 'oauth_consumer_key' => $this->consumerKey, + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_timestamp' => date('U'), + 'oauth_nonce' => OauthUtility::getNonce(), + 'oauth_version' => '1.0' + ] + ); + + if (! empty($this->token)) { + $queryParameters['oauth_token'] = $this->token; + } + + ksort($queryParameters); + $baseString = OauthUtility::getBaseString($method, $requestUri, $queryParameters); + $signingKey = OauthUtility::generateSigningKey($this->consumerSecret, $this->tokenSecret); + + $queryParameters['oauth_signature'] = OauthUtility::getSignature($baseString, $signingKey); + + $additionalParameters['query'] = $queryParameters; + + return $additionalParameters; + } +} diff --git a/src/Http/OauthUtility.php b/src/Http/OauthUtility.php new file mode 100644 index 0000000..6ee0ec1 --- /dev/null +++ b/src/Http/OauthUtility.php @@ -0,0 +1,36 @@ +<?php + +namespace App\Http; + +class OauthUtility +{ + public static function generateSigningKey(string $consumerSecret, ?string $tokenSecret): string + { + $consumerSecret = rawurlencode($consumerSecret); + if (isset($tokenSecret)) { + $tokenSecret = rawurlencode($tokenSecret); + return sprintf('%s&%s', $consumerSecret, $tokenSecret); + } + + return sprintf('%s&', $consumerSecret); + } + + public static function getBaseString(string $method, string $requestUri, array $queryParameters): string + { + $params = http_build_query($queryParameters, null, '&', PHP_QUERY_RFC3986); + return sprintf('%s&%s&%s', $method, rawurlencode($requestUri), rawurlencode($params)); + } + + public static function getSignature(string $baseString, string $key): string + { + return base64_encode(hash_hmac('sha1', $baseString, $key, true)); + } + + public static function getNonce(): string + { + $data = openssl_random_pseudo_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } +} diff --git a/src/Library/Handler/AbstractHandler.php b/src/Library/Handler/AbstractHandler.php index 42af5e3..388336c 100644 --- a/src/Library/Handler/AbstractHandler.php +++ b/src/Library/Handler/AbstractHandler.php @@ -135,7 +135,7 @@ public function createGarminWorkouts($handlerOptions, $workouts) $this->dispatcher->dispatch($event, HandlerEvents::CREATED_WORKOUTS_STARTED); $this->garminHelper->createWorkouts($workouts); - $this->garminHelper->attachNotes($workouts); +// $this->garminHelper->attachNotes($workouts); $debugMessages = $this->garminHelper->getDebugMessages(); diff --git a/src/Library/Parser/Model/Step/AbstractStep.php b/src/Library/Parser/Model/Step/AbstractStep.php index 492422c..7db7bf9 100644 --- a/src/Library/Parser/Model/Step/AbstractStep.php +++ b/src/Library/Parser/Model/Step/AbstractStep.php @@ -31,7 +31,7 @@ abstract class AbstractStep implements \JsonSerializable protected $notes; /** - * @var float|null + * @var int|null */ protected $garminID; @@ -128,7 +128,7 @@ public function jsonSerialize(): array 'stepId' => null, 'stepOrder' => $this->order, 'childStepId' => null, - 'description' => null, + 'description' => $this->notes, 'stepType' => [ 'stepTypeId' => $this->getStepTypeId(), 'stepTypeKey' => $this->getStepTypeKey() @@ -139,18 +139,18 @@ public function jsonSerialize(): array } /** - * @return float|null + * @return int|null */ - public function getGarminID(): ?float + public function getGarminID(): ?int { return $this->garminID; } /** - * @param float|null $garminID + * @param int|null $garminID * @return AbstractStep */ - public function setGarminID(?float $garminID): AbstractStep + public function setGarminID(?int $garminID): AbstractStep { $this->garminID = $garminID; return $this; diff --git a/src/Library/Parser/Model/Step/RepeaterStep.php b/src/Library/Parser/Model/Step/RepeaterStep.php index cc48dac..4bd0296 100644 --- a/src/Library/Parser/Model/Step/RepeaterStep.php +++ b/src/Library/Parser/Model/Step/RepeaterStep.php @@ -16,7 +16,7 @@ class RepeaterStep implements \JsonSerializable protected $order; /** - * @var float|null + * @var int|null */ protected $garminID; @@ -107,18 +107,18 @@ public function jsonSerialize(): array } /** - * @return float|null + * @return int|null */ - public function getGarminID(): ?float + public function getGarminID(): ?int { return $this->garminID; } /** - * @param float|null $garminID + * @param int|null $garminID * @return RepeaterStep */ - public function setGarminID(?float $garminID): RepeaterStep + public function setGarminID(?int $garminID): RepeaterStep { $this->garminID = $garminID; return $this; diff --git a/src/Library/Parser/Model/Workout/AbstractWorkout.php b/src/Library/Parser/Model/Workout/AbstractWorkout.php index 122bbb2..25ff8e1 100644 --- a/src/Library/Parser/Model/Workout/AbstractWorkout.php +++ b/src/Library/Parser/Model/Workout/AbstractWorkout.php @@ -25,7 +25,7 @@ abstract class AbstractWorkout implements \JsonSerializable protected $prefix; /** - * @var float|null + * @var int|null */ protected $garminID; @@ -149,7 +149,7 @@ public function jsonSerialize(): array ] ]; } - + $workout = [ 'sportType' => [ 'sportTypeId' => $this->getSportTypeId(), @@ -165,7 +165,7 @@ public function jsonSerialize(): array 'workoutSteps' => $this->steps->toArray() ]] ]; - + return array_merge($workout, $swimming); } @@ -206,9 +206,9 @@ public function setName(?string $name): AbstractWorkout } /** - * @return float|null + * @return int|null */ - public function getGarminID(): ?float + public function getGarminID(): ?int { return $this->garminID; } @@ -232,10 +232,10 @@ public function setPrefix(?string $prefix): AbstractWorkout } /** - * @param float|null $garminID + * @param int|null $garminID * @return AbstractWorkout */ - public function setGarminID(?float $garminID): AbstractWorkout + public function setGarminID(?int $garminID): AbstractWorkout { $this->garminID = $garminID; return $this; diff --git a/src/Service/GarminHelper.php b/src/Service/GarminHelper.php index eaf6354..dd9bb01 100644 --- a/src/Service/GarminHelper.php +++ b/src/Service/GarminHelper.php @@ -2,52 +2,27 @@ namespace App\Service; +use App\Http\GarminClient\GarminClient; use App\Library\Parser\Model\Day; use App\Library\Parser\Model\Step\AbstractStep; use App\Library\Parser\Model\Workout\AbstractWorkout; use App\Model\DebugMessages; -use dawguk\GarminConnect; class GarminHelper { use DebugMessages; - /** - * @var GarminConnect $client - */ - protected $client; - - /** - * @var string - */ - protected $username; - - /** - * @var string - */ - protected $password; - - public function __construct($garminUsername, $garminPassword) - { - $this->username = $garminUsername; - $this->password = $garminPassword; + public function __construct( + private GarminClient $client + ) { } - public function createGarminClient($username, $password) + public function createGarminClient($username, $password): void { - $credentials = [ - 'username' => $username, - 'password' => $password, - ]; - - if (empty($username) && empty($password)) { - $credentials = [ - 'username' => $this->username, - 'password' => $this->password, - ]; + if (! empty($username) && ! empty($password)) { + // Set user credentials based on what is added as an argument to command prompt + $this->client->addCredentials($username, $password); } - - $this->client = new GarminConnect($credentials); } public function createWorkouts(array $workouts) @@ -65,7 +40,7 @@ public function createWorkouts(array $workouts) $workout->setGarminID($workoutID); $debugMessages[] = 'Workout - ' . $workoutName . ' was previously created on the Garmin website with the id ' . $workoutID; } else { - $response = $this->client->createWorkout(json_encode($workout)); + $response = $this->client->createWorkout($workout); if (! isset($response, $response->workoutId)) { $debugMessages[] = 'Workout - ' . $workoutName . ' failed to create'; continue; @@ -154,13 +129,13 @@ public function scheduleWorkouts(array $days) foreach ($day->getWorkouts() as $workoutKey => $workout) { if ($day->getDate()) { $formattedDate = $day->getDate()->format('Y-m-d'); - $data = json_encode(['date' => $formattedDate]); + $data = ['date' => $formattedDate]; $messageID = ' is going to be scheduled on '; if ($workout->getGarminID()) { $this->client->scheduleWorkout($workout->getGarminID(), $data); - $messageID = ' with id ' . $workout->getGarminID() .' was scheduled on the Garmin website for '; + $messageID = ' with id ' . $workout->getGarminID() . ' was scheduled on the Garmin website for '; } $debugMessages[] = 'Workout - ' . $workout->getName() . $messageID . $formattedDate; } @@ -183,4 +158,4 @@ public function findWorkoutSteps($steps, $workoutSteps = []) return $workoutSteps; } -} \ No newline at end of file +} diff --git a/symfony.lock b/symfony.lock index 5aed624..7821857 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,7 +1,4 @@ { - "dawguk/php-garmin-connect": { - "version": "v1.3.1" - }, "doctrine/collections": { "version": "1.6.4" },