From 5d98de1f57df4b55ddaec6c2cb206a6302375fac Mon Sep 17 00:00:00 2001 From: zhitao Date: Wed, 27 Sep 2023 23:04:09 +0800 Subject: [PATCH 01/33] feat: Garmin signin with Oauth (initial version). --- examples/example.js | 22 +- package-lock.json | 160 +++++++- package.json | 8 +- src/common/HttpClient.ts | 19 + src/garmin/GarminConnect.ts | 710 ++++++++---------------------------- src/garmin/UrlClass.ts | 45 +++ src/garmin/types.ts | 1 + 7 files changed, 388 insertions(+), 577 deletions(-) create mode 100644 src/common/HttpClient.ts create mode 100644 src/garmin/UrlClass.ts diff --git a/examples/example.js b/examples/example.js index 6fc3c5f..89bb211 100644 --- a/examples/example.js +++ b/examples/example.js @@ -1,18 +1,28 @@ -const { GarminConnect } = require('garmin-connect'); +const { GarminConnect } = require('../dist/index'); // Has to be run in an async function to be able to use the await keyword const main = async () => { // Create a new Garmin Connect Client - const GCClient = new GarminConnect(); + const GCClient = new GarminConnect({ + username: 'your-email', + password: 'your-password' + }); + + // TODO: Test China Domain + // China Domain + // const GCClient = new GarminConnect({ + // username: 'your-email', + // password: 'your-password' + // }, 'garmin.cn'); // Uses credentials from garmin.config.json or uses supplied params - await GCClient.login('my.email@example.com', 'MySecretPassword'); + await GCClient.login(); - // Get user info - const info = await GCClient.getUserInfo(); + // // Get user info + // const info = await GCClient.getUserInfo(); // Log info to make sure signin was successful - console.log(info); + // console.log(info); }; // Run the code diff --git a/package-lock.json b/package-lock.json index 519055e..a90a1a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,12 @@ "license": "MIT", "dependencies": { "app-root-path": "^3.1.0", + "axios": "^1.5.1", "cloudscraper": "^4.6.0", - "qs": "^6.11.0", + "crypto": "^1.0.1", + "form-data": "^4.0.0", + "oauth-1.0a": "^2.2.6", + "qs": "^6.11.2", "request": "^2.88.2" }, "devDependencies": { @@ -202,12 +206,42 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" }, + "node_modules/axios": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", + "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "peer": true + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -231,6 +265,15 @@ "concat-map": "0.0.1" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "peer": true, + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -338,6 +381,12 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in." + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -421,6 +470,25 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, + "node_modules/follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -429,6 +497,19 @@ "node": "*" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -716,6 +797,11 @@ "node": ">=8" } }, + "node_modules/oauth-1.0a": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", + "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -978,6 +1064,11 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -1008,9 +1099,9 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", "dependencies": { "side-channel": "^1.0.4" }, @@ -1523,12 +1614,28 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" }, + "axios": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", + "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", + "requires": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "peer": true + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1552,6 +1659,15 @@ "concat-map": "0.0.1" } }, + "brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "peer": true, + "requires": { + "base64-js": "^1.1.2" + } + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1637,6 +1753,11 @@ "which": "^2.0.1" } }, + "crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==" + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -1705,11 +1826,26 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, + "follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==" + }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -1929,6 +2065,11 @@ "path-key": "^3.0.0" } }, + "oauth-1.0a": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", + "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -2125,6 +2266,11 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -2152,9 +2298,9 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", "requires": { "side-channel": "^1.0.4" } diff --git a/package.json b/package.json index 85deb84..9b9f471 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "types": "./dist/index.d.ts", "scripts": { "build": "tsc --build --clean && find ./dist -type d -empty -delete; tsc", + "build:windows": "tsc --build --clean && tsc", "build:watch": "npm run build -- --watch", + "build:watch-windows": "npm run build:windows -- --watch", "prettier:all": "prettier --write .", "pretty": "pretty-quick --staged", "prepack": "npm run build" @@ -50,8 +52,12 @@ "runkitExampleFilename": "./examples/example.js", "dependencies": { "app-root-path": "^3.1.0", + "axios": "^1.5.1", "cloudscraper": "^4.6.0", - "qs": "^6.11.0", + "crypto": "^1.0.1", + "form-data": "^4.0.0", + "oauth-1.0a": "^2.2.6", + "qs": "^6.11.2", "request": "^2.88.2" }, "pre-commit": "pretty" diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts new file mode 100644 index 0000000..1f7ecbc --- /dev/null +++ b/src/common/HttpClient.ts @@ -0,0 +1,19 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; + +export class HttpClient { + client: AxiosInstance; + + constructor() { + this.client = axios.create(); + } + + async get(url: string, config?: AxiosRequestConfig) { + const response = await this.client.get(url, config); + return response?.data; + } + + async post(url: string, data: any, config?: AxiosRequestConfig) { + const response = await this.client.post(url, data, config); + return response?.data; + } +} diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index 03c052a..b4b33a4 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -1,24 +1,22 @@ import appRoot from 'app-root-path'; -import CFClient from '../common/CFClient'; -import { toDateString } from '../common/DateUtils'; -import * as urls from './Urls'; -import { ExportFileType, UploadFileType } from './Urls'; -import { CookieJar } from 'tough-cookie'; -import { - GCActivityId, - GCBadgeId, - GCUserHash, - Gear, - IActivity, - IActivityDetails, - IBadge, - ISocialConnections, - ISocialProfile, - IUserInfo -} from './types'; -import Running from './workouts/Running'; -import path from 'path'; -import fs from 'fs'; +import FormData from 'form-data'; +import qs from 'qs'; +const OAuth = require('oauth-1.0a'); +const crypto = require('crypto'); +import { HttpClient } from '../common/HttpClient'; +import { UrlClass } from './UrlClass'; +import { GCUserHash, GarminDomain } from './types'; + +const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); +const TICKET_RE = new RegExp('ticket=([^"]+)"'); +const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile'; +const USER_AGENT_BROWSER = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'; + +const OAUTH_CONSUMER = { + key: 'REPLACE_ME', + secret: 'REPLACE_ME' +}; let config: GCCredentials | undefined = undefined; @@ -34,7 +32,6 @@ export interface GCCredentials { username: string; password: string; } - export interface Listeners { [event: string]: EventCallback[]; } @@ -43,124 +40,28 @@ export enum Event { sessionChange = 'sessionChange' } -export interface Session { - cookies: CookieJar.Serialized | undefined; - userHash: string | undefined; -} +export interface Session {} export default class GarminConnect { - private client: CFClient; + private client: HttpClient; private _userHash: GCUserHash | undefined; private credentials: GCCredentials; private listeners: Listeners; - - constructor(credentials: GCCredentials | undefined = config) { - const headers = { - origin: urls.GARMIN_SSO_ORIGIN, - nk: 'NT' - }; - this.client = new CFClient(headers); - this._userHash = undefined; + private url: UrlClass; + constructor( + credentials: GCCredentials | undefined = config, + domain: GarminDomain = 'garmin.com' + ) { if (!credentials) { throw new Error('Missing credentials'); } + this.url = new UrlClass(domain); + this.client = new HttpClient(); + this._userHash = undefined; this.credentials = credentials; this.listeners = {}; } - get userHash(): GCUserHash { - if (!this._userHash) { - throw new Error('User not logged in'); - } - return this._userHash; - } - - get sessionJson(): Session { - const cookies = this.client.serializeCookies(); - return { cookies, userHash: this._userHash }; - } - - set sessionJson(json: Session) { - const { cookies, userHash } = json || {}; - if (cookies && userHash) { - this._userHash = userHash; - this.client.importCookies(cookies); - } - } - - /** - * Add an event listener callback - * @param event - * @param callback - */ - on(event: Event, callback: EventCallback) { - if ( - event && - callback && - typeof event === 'string' && - typeof callback === 'function' - ) { - if (!this.listeners[event]) { - this.listeners[event] = []; - } - this.listeners[event].push(callback); - } - } - - /** - * Method for triggering any event - * @param event - * @param data - */ - triggerEvent(event: Event, data: T) { - const callbacks = this.listeners[event] || []; - callbacks.forEach((cb) => cb(data)); - } - - /** - * Add a callback to the 'sessionChange' event - * @param callback - */ - onSessionChange(callback: EventCallback) { - this.on(Event.sessionChange, callback); - } - - /** - * Restore an old session from storage and fallback to regular login - * @param json - * @param username - * @param password - * @returns {Promise} - */ - async restoreOrLogin(json: Session, username: string, password: string) { - return this.restore(json).catch((e) => { - console.warn(e); - return this.login(username, password); - }); - } - - /** - * Restore an old session from storage - * @param json - * @returns {Promise} - */ - async restore(json: Session) { - this.sessionJson = json; - try { - const info = await this.getUserInfo(); - const { displayName } = info || {}; - if (displayName && displayName === this.userHash) { - // Session restoration was successful - return this; - } - throw new Error( - 'Unable to restore session, user hash do not match' - ); - } catch (e) { - throw new Error(`Unable to restore session due to: ${e}`); - } - } - /** * Login to Garmin Connect * @param username @@ -172,456 +73,139 @@ export default class GarminConnect { this.credentials.username = username; this.credentials.password = password; } - let tempCredentials = { - ...this.credentials, - rememberme: 'on', - embed: 'false' - }; - await this.client.get(urls.SIGNIN_URL); - await this.client.post(urls.SIGNIN_URL, tempCredentials); - const userPreferences = await this.getUserInfo(); - const { displayName } = userPreferences; - this._userHash = displayName; - return this; - } - - // User info - /** - * Get basic user information - * @returns {Promise<*>} - */ - async getUserInfo(): Promise { - return this.get(urls.userInfo()); - } - - /** - * Get social user information - * @returns {Promise<*>} - */ - async getSocialProfile(): Promise { - return this.get(urls.socialProfile(this.userHash)); - } - - /** - * Get a list of all social connections - * @returns {Promise<*>} - */ - async getSocialConnections(): Promise { - return this.get( - urls.socialConnections(this.userHash) - ); - } - - // Devices - /** - * Get a list of all registered devices - * @returns {Promise<*>} - */ - async getDeviceInfo() { - return this.get(urls.deviceInfo(this.userHash)); - } - - // Sleep data - /** - * Get detailed sleep data for a specific date - * @param date - * @returns {Promise<*>} - */ - async getSleepData(date = new Date()) { - const dateString = toDateString(date); - return this.get(urls.dailySleepData(this.userHash), { - date: dateString - }); - } - - /** - * Get sleep data summary for a specific date - * @param date - * @returns {Promise<*>} - */ - async getSleep(date = new Date()) { - const dateString = toDateString(date); - return this.get(urls.dailySleep(), { date: dateString }); - } - - // Heart rate - /** - * Get heart rate measurements for a specific date - * @param date - * @returns {Promise<*>} - */ - async getHeartRate(date = new Date()) { - const dateString = toDateString(date); - return this.get(urls.dailyHeartRate(this.userHash), { - date: dateString - }); - } - - // Weight - /** - * Post a new body weight - * @param weight - * @returns {Promise<*>} - */ - async setBodyWeight(weight: number) { - if (weight) { - const roundWeight = Math.round(weight * 1000); - const data = { userData: { weight: roundWeight } }; - return this.put(urls.userSettings(), data); - } - return Promise.reject(); - } - - // Activites - /** - * Get list of activites - * @param start - * @param limit - * @returns {Promise<*>} - */ - async getActivities(start: number, limit: number): Promise { - return this.get(urls.activities(), { start, limit }); - } - - /** - * Get details about an activity - * @param activityId - * @returns {Promise} - */ - async getActivityDetails( - activityId: GCActivityId - ): Promise { - if (activityId) { - return this.get(urls.activity(activityId)); - } - return Promise.reject(); - } - - /** - * Get metrics details about an activity - * @param activity - * @param maxChartSize - * @param maxPolylineSize - * @returns {Promise<*>} - */ - async getActivity( - activity: { activityId: GCActivityId }, - maxChartSize: number, - maxPolylineSize: number - ) { - const { activityId } = activity || {}; - if (activityId) { - return this.get(urls.activityDetails(activityId), { - maxChartSize, - maxPolylineSize - }); - } - return Promise.reject(); - } - - /** - * Get weather data from an activity - * @param activity - * @returns {Promise<*>} - */ - async getActivityWeather(activity: { activityId: GCActivityId }) { - const { activityId } = activity || {}; - if (activityId) { - return this.get(urls.weather(activityId)); - } - return Promise.reject(); - } - /** - * Updates an activity - * @param activity - * @returns {Promise<*>} - */ - async updateActivity(activity: { activityId: GCActivityId }) { - return this.put(urls.activity(activity.activityId), activity); - } - - /** - * Deletes an activity - * @param activity - * @returns {Promise<*>} - */ - async deleteActivity(activity: { activityId: GCActivityId }) { - const { activityId } = activity || {}; - if (activityId) { - const headers = { 'x-http-method-override': 'DELETE' }; - return this.client.postJson( - urls.activity(activityId), - undefined, - headers - ); - } - return Promise.reject(); - } - - /** - * Get list of activities in your news feed - * @param start - * @param limit - * @returns {Promise<*>} - */ - async getNewsFeed(start: number, limit: number) { - return this.get(urls.newsFeed(), { start, limit }); - } - - // Steps - /** - * Get step count for a specific date - * @param date - * @returns {Promise<*>} - */ - async getSteps(date = new Date()) { - const dateString = toDateString(date); - return this.get(urls.dailySummaryChart(this.userHash), { - date: dateString + // Step1: Set cookie + const step1Params = { + clientId: 'GarminConnect', + locale: 'en', + service: this.url.GC_MODERN + }; + const step1Url = + this.url.GARMIN_SSO_EMBED + '?' + qs.stringify(step1Params); + console.log('login - step1Url:', step1Url); + await this.client.get(step1Url); + + // Step2 Get _csrf + const step2Params = { + id: 'gauth-widget', + embedWidget: true, + locale: 'en', + gauthHost: this.url.GARMIN_SSO_EMBED + }; + const step2Url = `${this.url.SIGNIN_URL}?${qs.stringify(step2Params)}`; + console.log('login - step2Url:', step2Url); + const step2Result = await this.client.get(step2Url); + // console.log('login - step2Result:', step2Result) + const csrfRegResult = CSRF_RE.exec(step2Result); + if (!csrfRegResult) { + throw new Error('login - csrf not found'); + } + const csrf_token = csrfRegResult[1]; + console.log('login - csrf:', csrf_token); + + // Step3 Get ticket + const signinParams = { + id: 'gauth-widget', + embedWidget: true, + clientId: 'GarminConnect', + locale: 'en', + gauthHost: this.url.GARMIN_SSO_EMBED, + service: this.url.GARMIN_SSO_EMBED, + source: this.url.GARMIN_SSO_EMBED, + redirectAfterAccountLoginUrl: this.url.GARMIN_SSO_EMBED, + redirectAfterAccountCreationUrl: this.url.GARMIN_SSO_EMBED + }; + const step3Url = `${this.url.SIGNIN_URL}?${qs.stringify(signinParams)}`; + console.log('login - step3Url:', step3Url); + const step3Form = new FormData(); + step3Form.append('username', this.credentials.username); + step3Form.append('password', this.credentials.password); + step3Form.append('embed', 'true'); + step3Form.append('_csrf', csrf_token); + const step3Result = await this.client.post(step3Url, step3Form, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Dnt: 1, + Origin: 'https://sso.garmin.com', + Referer: this.url.GARMIN_SSO, + 'User-Agent': USER_AGENT_BROWSER + } }); - } - - // Workouts - /** - * Get list of workouts - * @param start - * @param limit - * @returns {Promise<*>} - */ - async getWorkouts(start: number, limit: number) { - return this.get(urls.workouts(), { start, limit }); - } - - /** - * Download original activity data to disk as zip - * Resolves to absolute path for the downloaded file - * @param activity : any - * @param dir Will default to current working directory - * @param type : string - Will default to 'zip'. Other possible values are 'tcx', 'gpx' or 'kml'. - * @returns {Promise<*>} - */ - async downloadOriginalActivityData( - activity: { activityId: GCActivityId }, - dir: string, - type?: ExportFileType - ) { - const { activityId } = activity || {}; - if (activityId) { - const url = - !type || type === ExportFileType.zip - ? urls.originalFile(activityId) - : urls.exportFile(activityId, type); - return this.client.downloadBlob(dir, url); - } - return Promise.reject(); - } - /** - * Uploads an activity file ('gpx', 'tcx', or 'fit') - * @param file the file to upload - * @param format the format of the file. If undefined, the extension of the file will be used. - * @returns {Promise<*>} - */ - async uploadActivity(file: string, format: UploadFileType) { - const detectedFormat = (format || path.extname(file))?.toLowerCase(); - const filename = path.basename(file); - - if ((Object).values(UploadFileType).includes(detectedFormat)) { - return Promise.reject(); + const ticketRegResult = TICKET_RE.exec(step3Result); + if (!ticketRegResult) { + throw new Error('login - ticket not found'); } + const ticket = ticketRegResult[1]; + console.log('login - ticket:', ticket); - const fileBuffer = fs.readFileSync(file); - const response = this.client.post(urls.upload(format), { - userfile: { - value: fileBuffer, - options: { - filename - } + // Step4: Oauth1 + const step4Params = { + ticket, + 'login-url': this.url.GARMIN_SSO_EMBED, + 'accepts-mfa-tokens': true + }; + const step4Url = `${this.url.OAUTH_URL}/preauthorized?${qs.stringify( + step4Params + )}`; + + const step4Oauth = OAuth({ + consumer: OAUTH_CONSUMER, + signature_method: 'HMAC-SHA1', + hash_function(base_string: string, key: string) { + return crypto + .createHmac('sha1', key) + .update(base_string) + .digest('base64'); } }); - return response; - } - /** - * Adds a running workout with one step of completeing a set distance. - * @param name - * @param meters - * @param description - * @returns {Promise<*>} - */ - async addRunningWorkout(name: string, meters: number, description: string) { - const running = new Running(); - running.name = name; - running.distance = meters; - running.description = description; - return this.addWorkout(running); - } + const step4RequestData = { + url: step4Url, + method: 'GET' + }; + const step4Headers = step4Oauth.toHeader( + step4Oauth.authorize(step4RequestData) + ); + console.log('login - step4Headers:', step4Headers); - /** - * Add a new workout preset. - * @param workout - * @returns {Promise<*>} - */ - async addWorkout(workout: any) { - if (workout.isValid()) { - const data = { ...workout.toJson() }; - if (!data.description) { - data.description = 'Added by garmin-connect for Node.js'; + const step4Response = await this.client.get(step4Url, { + headers: { + ...step4Headers, + 'User-Agent': USER_AGENT_CONNECTMOBILE } - return this.post(urls.workout(), data); - } - return Promise.reject(); - } - - /** - * Add a workout to your workout calendar. - * @param workout - * @param date - * @returns {Promise<*>} - */ - async scheduleWorkout(workout: any, date: Date) { - const { workoutId } = workout || {}; - if (workoutId && date) { - const dateString = toDateString(date); - return this.post(urls.schedule(workoutId), { date: dateString }); - } - return Promise.reject(); - } - - /** - * Delete a workout based on a workout object. - * @param workout - * @returns {Promise<*>} - */ - async deleteWorkout(workout: any) { - const { workoutId } = workout || {}; - if (workoutId) { - const headers = { 'x-http-method-override': 'DELETE' }; - return this.client.postJson( - urls.workout(workoutId), - undefined, - headers - ); - } - return Promise.reject(); - } - - // Badges - /** - * Get list of earned badges - * @returns {Promise<*>} - */ - async getBadgesEarned(): Promise { - return this.get(urls.badgesEarned()); - } - - /** - * Get list of available badges - * @returns {Promise<*>} - */ - async getBadgesAvailable(): Promise { - return this.get(urls.badgesAvailable()); - } + }); + console.log('login - step4Response:', step4Response); + const step4Token = qs.parse(step4Response); + console.log('login - step4Token:', step4Token); + + // Step 5: Oauth2 + const step5Token = { + key: step4Token.oauth_token, + secret: step4Token.oauth_token_secret + }; - /** - * Get details about an badge - * @param badge - * @returns {Promise<*>} - */ - async getBadge(badge: { badgeId: GCBadgeId }) { - const { badgeId } = badge || {}; - if (badgeId) { - return this.get(urls.badgeDetail(badgeId)); - } - return Promise.reject(); - } + const step5BaseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`; + const step5RequestData = { + url: step5BaseUrl, + method: 'POST', + data: null + }; - /** - * Uploads an image to an activity - * @param activity - * @param file the file to upload - * @returns {Promise<*>} - */ - async uploadImage(activity: { activityId: GCActivityId }, file: string) { - return this.client.post(urls.image(activity.activityId), { - file: { - value: fs.readFileSync(file), - options: { - filename: path.basename(file) - } + const step5AuthData = step4Oauth.authorize( + step5RequestData, + step5Token + ); + console.log('login - step5AuthData:', step5AuthData); + const step5Url = step5BaseUrl + '?' + qs.stringify(step5AuthData); + const step5Response = await this.client.post(step5Url, null, { + headers: { + 'User-Agent': USER_AGENT_CONNECTMOBILE, + 'Content-Type': 'application/x-www-form-urlencoded' } }); - } - - /** - * Delete an image from an activity - * @param activity - * @param imageId, can be found in `activityImages` array of the activity - * @returns {Promise} - */ - async deleteImage( - activity: { activityId: GCActivityId }, - imageId: string - ): Promise { - return this.client.delete( - urls.imageDelete(activity.activityId, imageId) - ); - } + console.log('login - step5Response:', step5Response); - /** - * List the gear available at a certain date - * @param userProfilePk, user profile private key (can be found in user or activity details) - * @param availableGearDate, list gear available at this date only - * @returns {Promise} - */ - async listGear( - userProfilePk: number, - availableGearDate?: Date - ): Promise { - return this.client.get(urls.listGear(userProfilePk, availableGearDate)); - } - - /** - * Link gear to activity - * @param activityId, Activity ID - * @param gearUuid, UUID of the gear - * @returns {Promise} - */ - async linkGear(activityId: GCActivityId, gearUuid: string): Promise { - return this.put(urls.linkGear(activityId, gearUuid), {}); - } - - /** - * Unlink gear to activity - * @param activityId, Activity ID - * @param gearUuid, UUID of the gear - * @returns {Promise} - */ - async unlinkGear( - activityId: GCActivityId, - gearUuid: string - ): Promise { - return this.put(urls.unlinkGear(activityId, gearUuid), {}); - } - - // General methods - - async get(url: string, data?: any) { - const response = await this.client.get(url, data); - this.triggerEvent(Event.sessionChange, this.sessionJson); - return response as T; - } - - async post(url: string, data: any) { - const response = await this.client.postJson(url, data, {}); - this.triggerEvent(Event.sessionChange, this.sessionJson); - return response as T; - } - - async put(url: string, data: any) { - const response = await this.client.putJson(url, data); - this.triggerEvent(Event.sessionChange, this.sessionJson); - return response as T; + return this; } } diff --git a/src/garmin/UrlClass.ts b/src/garmin/UrlClass.ts new file mode 100644 index 0000000..b24c7be --- /dev/null +++ b/src/garmin/UrlClass.ts @@ -0,0 +1,45 @@ +import { GarminDomain } from './types'; + +export class UrlClass { + private domain: GarminDomain; + GC_MODERN: string; + GARMIN_SSO_ORIGIN: string; + GC_API: string; + constructor(domain: GarminDomain = 'garmin.com') { + this.domain = domain; + this.GC_MODERN = `https://connect.${this.domain}/modern`; + this.GARMIN_SSO_ORIGIN = `https://sso.${this.domain}`; + this.GC_API = `https://connectapi.${this.domain}`; + } + get GARMIN_SSO() { + return `${this.GARMIN_SSO_ORIGIN}/sso`; + } + get GARMIN_SSO_EMBED() { + return `${this.GARMIN_SSO_ORIGIN}/sso/embed`; + } + get BASE_URL() { + return `${this.GC_MODERN}/proxy`; + } + get SIGNIN_URL() { + return `${this.GARMIN_SSO}/signin`; + } + get LOGIN_URL() { + return `${this.GARMIN_SSO}/login`; + } + get OAUTH_URL() { + return `${this.GC_API}/oauth-service/oauth`; + } +} + +export enum ExportFileType { + tcx = 'tcx', + gpx = 'gpx', + kml = 'kml', + zip = 'zip' +} + +export enum UploadFileType { + tcx = 'tcx', + gpx = 'gpx', + fit = 'fit' +} diff --git a/src/garmin/types.ts b/src/garmin/types.ts index 9897410..1583871 100644 --- a/src/garmin/types.ts +++ b/src/garmin/types.ts @@ -3,6 +3,7 @@ export type GCUserHash = string; export type GCActivityId = number; export type GCWorkoutId = string; export type GCBadgeId = number; +export type GarminDomain = 'garmin.com' | 'garmin.cn'; export interface IUserInfo { userProfileId: GCUserProfileId; From 55203db5d3c9ad7017de4a4c33306c87e772989c Mon Sep 17 00:00:00 2001 From: gooin Date: Thu, 28 Sep 2023 09:45:09 +0800 Subject: [PATCH 02/33] feat: handling errors --- src/common/HttpClient.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index 1f7ecbc..b874dc6 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -1,10 +1,37 @@ -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import axios, { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + AxiosResponse +} from 'axios'; export class HttpClient { client: AxiosInstance; constructor() { this.client = axios.create(); + this.client.interceptors.response.use( + (response) => response, + (error) => { + if (axios.isAxiosError(error)) { + if (error?.response) this.handleError(error?.response); + } + throw error; + } + ); + } + + handleError(response: AxiosResponse): void { + this.handleHttpError(response); + } + + handleHttpError(response: AxiosResponse): void { + const { status, statusText, data } = response; + const msg = `ERROR: (${status}), ${statusText}, ${JSON.stringify( + data + )}`; + console.error(msg); + throw new Error(msg); } async get(url: string, config?: AxiosRequestConfig) { From a18ff735dd074491d7eb7e5e8da36834c8ededef Mon Sep 17 00:00:00 2001 From: gooin Date: Thu, 28 Sep 2023 09:51:10 +0800 Subject: [PATCH 03/33] refactor: oauth1 & oauth2 exchange --- src/garmin/GarminConnect.ts | 78 ++++++++++++++++++++----------------- src/garmin/types.ts | 10 +++++ 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index b4b33a4..85e66c5 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -1,11 +1,11 @@ import appRoot from 'app-root-path'; import FormData from 'form-data'; import qs from 'qs'; -const OAuth = require('oauth-1.0a'); const crypto = require('crypto'); import { HttpClient } from '../common/HttpClient'; import { UrlClass } from './UrlClass'; -import { GCUserHash, GarminDomain } from './types'; +import { GCUserHash, GarminDomain, IOauth1, IOauth1Token } from './types'; +import OAuth from 'oauth-1.0a'; const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); const TICKET_RE = new RegExp('ticket=([^"]+)"'); @@ -48,6 +48,7 @@ export default class GarminConnect { private credentials: GCCredentials; private listeners: Listeners; private url: UrlClass; + // private oauth1: OAuth; constructor( credentials: GCCredentials | undefined = config, domain: GarminDomain = 'garmin.com' @@ -80,8 +81,9 @@ export default class GarminConnect { locale: 'en', service: this.url.GC_MODERN }; - const step1Url = - this.url.GARMIN_SSO_EMBED + '?' + qs.stringify(step1Params); + const step1Url = `${this.url.GARMIN_SSO_EMBED}?${qs.stringify( + step1Params + )}`; console.log('login - step1Url:', step1Url); await this.client.get(step1Url); @@ -126,8 +128,8 @@ export default class GarminConnect { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Dnt: 1, - Origin: 'https://sso.garmin.com', - Referer: this.url.GARMIN_SSO, + Origin: this.url.GARMIN_SSO_ORIGIN, + Referer: this.url.SIGNIN_URL, 'User-Agent': USER_AGENT_BROWSER } }); @@ -140,16 +142,25 @@ export default class GarminConnect { console.log('login - ticket:', ticket); // Step4: Oauth1 - const step4Params = { + const oauth1 = await this.getOauth1Token(ticket); + + // Step 5: Oauth2 + await this.exchange(oauth1); + + return this; + } + + async getOauth1Token(ticket: string): Promise { + const params = { ticket, 'login-url': this.url.GARMIN_SSO_EMBED, 'accepts-mfa-tokens': true }; - const step4Url = `${this.url.OAUTH_URL}/preauthorized?${qs.stringify( - step4Params + const url = `${this.url.OAUTH_URL}/preauthorized?${qs.stringify( + params )}`; - const step4Oauth = OAuth({ + const oauth = new OAuth({ consumer: OAUTH_CONSUMER, signature_method: 'HMAC-SHA1', hash_function(base_string: string, key: string) { @@ -161,51 +172,46 @@ export default class GarminConnect { }); const step4RequestData = { - url: step4Url, + url: url, method: 'GET' }; - const step4Headers = step4Oauth.toHeader( - step4Oauth.authorize(step4RequestData) - ); - console.log('login - step4Headers:', step4Headers); + const headers = oauth.toHeader(oauth.authorize(step4RequestData)); + console.log('getOauth1Token - headers:', headers); - const step4Response = await this.client.get(step4Url, { + const response = await this.client.get(url, { headers: { - ...step4Headers, + ...headers, 'User-Agent': USER_AGENT_CONNECTMOBILE } }); - console.log('login - step4Response:', step4Response); - const step4Token = qs.parse(step4Response); - console.log('login - step4Token:', step4Token); + console.log('getOauth1Token - response:', response); + const token = qs.parse(response) as unknown as IOauth1Token; + console.log('getOauth1Token - token:', token); + return { token, oauth }; + } - // Step 5: Oauth2 - const step5Token = { - key: step4Token.oauth_token, - secret: step4Token.oauth_token_secret + async exchange(oauth1: IOauth1) { + const token = { + key: oauth1.token.oauth_token, + secret: oauth1.token.oauth_token_secret }; - const step5BaseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`; - const step5RequestData = { - url: step5BaseUrl, + const baseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`; + const requestData = { + url: baseUrl, method: 'POST', data: null }; - const step5AuthData = step4Oauth.authorize( - step5RequestData, - step5Token - ); + const step5AuthData = oauth1.oauth.authorize(requestData, token); console.log('login - step5AuthData:', step5AuthData); - const step5Url = step5BaseUrl + '?' + qs.stringify(step5AuthData); - const step5Response = await this.client.post(step5Url, null, { + const url = `${baseUrl}?${qs.stringify(step5AuthData)}`; + const response = await this.client.post(url, null, { headers: { 'User-Agent': USER_AGENT_CONNECTMOBILE, 'Content-Type': 'application/x-www-form-urlencoded' } }); - console.log('login - step5Response:', step5Response); - - return this; + console.log('exchange - response:', response); } } diff --git a/src/garmin/types.ts b/src/garmin/types.ts index 1583871..badf782 100644 --- a/src/garmin/types.ts +++ b/src/garmin/types.ts @@ -589,3 +589,13 @@ export interface Gear { createDate: string; updateDate: string; } + +export interface IOauth1 { + token: IOauth1Token; + oauth: OAuth; +} + +export interface IOauth1Token { + oauth_token: string; + oauth_token_secret: string; +} From 34425d573fd78bc86df2631d1616631e31eb91a3 Mon Sep 17 00:00:00 2001 From: gooin Date: Thu, 28 Sep 2023 11:28:48 +0800 Subject: [PATCH 04/33] feat: get user settings by api --- examples/example.js | 5 +++++ package-lock.json | 27 +++++++++++++++++++++++++++ package.json | 2 ++ src/common/HttpClient.ts | 10 ++++++++++ src/garmin/GarminConnect.ts | 34 +++++++++++++++++++++++++++++++++- src/garmin/UrlClass.ts | 3 +++ src/garmin/types.ts | 14 ++++++++++++++ 7 files changed, 94 insertions(+), 1 deletion(-) diff --git a/examples/example.js b/examples/example.js index 89bb211..97b94cf 100644 --- a/examples/example.js +++ b/examples/example.js @@ -23,6 +23,11 @@ const main = async () => { // Log info to make sure signin was successful // console.log(info); + // // Get user settings + const settings = await GCClient.getUserSettings(); + + // Log info to make sure signin was successful + console.log(settings); }; // Run the code diff --git a/package-lock.json b/package-lock.json index a90a1a4..ffacb18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,14 @@ "cloudscraper": "^4.6.0", "crypto": "^1.0.1", "form-data": "^4.0.0", + "luxon": "^3.4.3", "oauth-1.0a": "^2.2.6", "qs": "^6.11.2", "request": "^2.88.2" }, "devDependencies": { "@types/app-root-path": "^1.2.4", + "@types/luxon": "^3.3.2", "@types/node": "^18.11.15", "@types/qs": "^6.9.7", "@types/request-promise": "^4.1.48", @@ -47,6 +49,12 @@ "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==", "dev": true }, + "node_modules/@types/luxon": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/@types/luxon/-/luxon-3.3.2.tgz", + "integrity": "sha512-l5cpE57br4BIjK+9BSkFBOsWtwv6J9bJpC7gdXIzZyI0vuKvNTk0wZZrkQxMGsUAuGW9+WMNWF2IJMD7br2yeQ==", + "dev": true + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -714,6 +722,14 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/luxon": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.4.3.tgz", + "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==", + "engines": { + "node": ">=12" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -1484,6 +1500,12 @@ "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==", "dev": true }, + "@types/luxon": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/@types/luxon/-/luxon-3.3.2.tgz", + "integrity": "sha512-l5cpE57br4BIjK+9BSkFBOsWtwv6J9bJpC7gdXIzZyI0vuKvNTk0wZZrkQxMGsUAuGW9+WMNWF2IJMD7br2yeQ==", + "dev": true + }, "@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -2003,6 +2025,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "luxon": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.4.3.tgz", + "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==" + }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index 9b9f471..51e7386 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ ], "devDependencies": { "@types/app-root-path": "^1.2.4", + "@types/luxon": "^3.3.2", "@types/node": "^18.11.15", "@types/qs": "^6.9.7", "@types/request-promise": "^4.1.48", @@ -56,6 +57,7 @@ "cloudscraper": "^4.6.0", "crypto": "^1.0.1", "form-data": "^4.0.0", + "luxon": "^3.4.3", "oauth-1.0a": "^2.2.6", "qs": "^6.11.2", "request": "^2.88.2" diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index b874dc6..b3838a1 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -4,9 +4,12 @@ import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { IOauth1Token, IOauth2Token } from '../garmin/types'; export class HttpClient { client: AxiosInstance; + oauth1Token: IOauth1Token | undefined; + oauth2Token: IOauth2Token | undefined; constructor() { this.client = axios.create(); @@ -21,6 +24,13 @@ export class HttpClient { ); } + setHeader(token: string, backendApi: string): void { + this.client.defaults.headers.common[ + 'Authorization' + ] = `Bearer ${token}`; + this.client.defaults.headers.common['Di-Backend'] = backendApi; + } + handleError(response: AxiosResponse): void { this.handleHttpError(response); } diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index 85e66c5..aed70d9 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -4,8 +4,16 @@ import qs from 'qs'; const crypto = require('crypto'); import { HttpClient } from '../common/HttpClient'; import { UrlClass } from './UrlClass'; -import { GCUserHash, GarminDomain, IOauth1, IOauth1Token } from './types'; +import { + GCUserHash, + GarminDomain, + IOauth1, + IOauth1Token, + IOauth2Token, + IUserInfo +} from './types'; import OAuth from 'oauth-1.0a'; +import { DateTime } from 'luxon'; const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); const TICKET_RE = new RegExp('ticket=([^"]+)"'); @@ -187,6 +195,7 @@ export default class GarminConnect { console.log('getOauth1Token - response:', response); const token = qs.parse(response) as unknown as IOauth1Token; console.log('getOauth1Token - token:', token); + this.client.oauth1Token = token; return { token, oauth }; } @@ -213,5 +222,28 @@ export default class GarminConnect { } }); console.log('exchange - response:', response); + this.client.oauth2Token = this.setOauth2TokenExpiresAt(response); + this.client.setHeader( + this.client.oauth2Token.access_token, + this.url.GC_API + ); + + console.log('exchange - oauth2Token:', this.client.oauth2Token); + } + + setOauth2TokenExpiresAt(token: IOauth2Token): IOauth2Token { + token['expires_at'] = DateTime.now().toSeconds() + token['expires_in']; + token['refresh_token_expires_at'] = + DateTime.now().toSeconds() + token['refresh_token_expires_in']; + return token; + } + + // User Settings + /** + * Get basic user information + * @returns {Promise<*>} + */ + async getUserSettings(): Promise { + return this.client.get(this.url.USER_SETTINGS); } } diff --git a/src/garmin/UrlClass.ts b/src/garmin/UrlClass.ts index b24c7be..6f8a569 100644 --- a/src/garmin/UrlClass.ts +++ b/src/garmin/UrlClass.ts @@ -29,6 +29,9 @@ export class UrlClass { get OAUTH_URL() { return `${this.GC_API}/oauth-service/oauth`; } + get USER_SETTINGS() { + return `${this.GC_API}/userprofile-service/userprofile/user-settings/`; + } } export enum ExportFileType { diff --git a/src/garmin/types.ts b/src/garmin/types.ts index badf782..a8eb6c0 100644 --- a/src/garmin/types.ts +++ b/src/garmin/types.ts @@ -599,3 +599,17 @@ export interface IOauth1Token { oauth_token: string; oauth_token_secret: string; } + +export interface IOauth2Token { + scope: string; + jti: string; + access_token: string; + token_type: string; + refresh_token: string; + expires_in: number; + refresh_token_expires_in: number; + + // added + expires_at?: number; + refresh_token_expires_at?: number; +} From ebb3862b2e77439e515dfde33361ab93ef43c22b Mon Sep 17 00:00:00 2001 From: zhitao Date: Thu, 28 Sep 2023 14:06:39 +0800 Subject: [PATCH 05/33] feat: http client common header --- package-lock.json | 14 ++++++++++++++ package.json | 2 ++ src/common/HttpClient.ts | 14 ++++++++------ src/garmin/GarminConnect.ts | 18 ++++++++++++++---- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index ffacb18..2da7148 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "cloudscraper": "^4.6.0", "crypto": "^1.0.1", "form-data": "^4.0.0", + "lodash": "^4.17.21", "luxon": "^3.4.3", "oauth-1.0a": "^2.2.6", "qs": "^6.11.2", @@ -21,6 +22,7 @@ }, "devDependencies": { "@types/app-root-path": "^1.2.4", + "@types/lodash": "^4.14.199", "@types/luxon": "^3.3.2", "@types/node": "^18.11.15", "@types/qs": "^6.9.7", @@ -49,6 +51,12 @@ "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.199", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.199.tgz", + "integrity": "sha512-Vrjz5N5Ia4SEzWWgIVwnHNEnb1UE1XMkvY5DGXrAeOGE9imk0hgTHh5GyDjLDJi9OTCn9oo9dXH1uToK1VRfrg==", + "dev": true + }, "node_modules/@types/luxon": { "version": "3.3.2", "resolved": "https://registry.npmmirror.com/@types/luxon/-/luxon-3.3.2.tgz", @@ -1500,6 +1508,12 @@ "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==", "dev": true }, + "@types/lodash": { + "version": "4.14.199", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.199.tgz", + "integrity": "sha512-Vrjz5N5Ia4SEzWWgIVwnHNEnb1UE1XMkvY5DGXrAeOGE9imk0hgTHh5GyDjLDJi9OTCn9oo9dXH1uToK1VRfrg==", + "dev": true + }, "@types/luxon": { "version": "3.3.2", "resolved": "https://registry.npmmirror.com/@types/luxon/-/luxon-3.3.2.tgz", diff --git a/package.json b/package.json index 51e7386..f9a4955 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ ], "devDependencies": { "@types/app-root-path": "^1.2.4", + "@types/lodash": "^4.14.199", "@types/luxon": "^3.3.2", "@types/node": "^18.11.15", "@types/qs": "^6.9.7", @@ -57,6 +58,7 @@ "cloudscraper": "^4.6.0", "crypto": "^1.0.1", "form-data": "^4.0.0", + "lodash": "^4.17.21", "luxon": "^3.4.3", "oauth-1.0a": "^2.2.6", "qs": "^6.11.2", diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index b3838a1..1f1e37d 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -1,10 +1,13 @@ import axios, { AxiosError, + AxiosHeaders, AxiosInstance, AxiosRequestConfig, - AxiosResponse + AxiosResponse, + RawAxiosRequestHeaders } from 'axios'; import { IOauth1Token, IOauth2Token } from '../garmin/types'; +import _ from 'lodash'; export class HttpClient { client: AxiosInstance; @@ -24,11 +27,10 @@ export class HttpClient { ); } - setHeader(token: string, backendApi: string): void { - this.client.defaults.headers.common[ - 'Authorization' - ] = `Bearer ${token}`; - this.client.defaults.headers.common['Di-Backend'] = backendApi; + setCommonHeader(headers: RawAxiosRequestHeaders): void { + _.each(headers, (headerValue, key) => { + this.client.defaults.headers.common[key] = headerValue; + }); } handleError(response: AxiosResponse): void { diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index aed70d9..83a09d2 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -14,6 +14,7 @@ import { } from './types'; import OAuth from 'oauth-1.0a'; import { DateTime } from 'luxon'; +import { RawAxiosRequestHeaders } from 'axios'; const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); const TICKET_RE = new RegExp('ticket=([^"]+)"'); @@ -223,10 +224,11 @@ export default class GarminConnect { }); console.log('exchange - response:', response); this.client.oauth2Token = this.setOauth2TokenExpiresAt(response); - this.client.setHeader( - this.client.oauth2Token.access_token, - this.url.GC_API - ); + + this.client.setCommonHeader({ + Authorization: 'Bearer ' + this.client.oauth2Token.access_token, + ...this.getGCCommonHeader() + }); console.log('exchange - oauth2Token:', this.client.oauth2Token); } @@ -238,6 +240,14 @@ export default class GarminConnect { return token; } + getGCCommonHeader(): RawAxiosRequestHeaders { + return { + 'Di-Backend': this.url.GC_API, + Nk: 'NT', + Dnt: 1 + }; + } + // User Settings /** * Get basic user information From 30413da5cc0236863ad64baa570d7894973dc0f2 Mon Sep 17 00:00:00 2001 From: gooin Date: Sun, 1 Oct 2023 17:15:38 +0800 Subject: [PATCH 06/33] feat: export, import token & auto refresh token --- README.md | 19 ++- src/common/HttpClient.ts | 294 ++++++++++++++++++++++++++++++++++-- src/garmin/GarminConnect.ts | 239 ++++++----------------------- src/garmin/types.ts | 10 +- src/utils.ts | 19 +++ 5 files changed, 376 insertions(+), 205 deletions(-) create mode 100644 src/utils.ts diff --git a/README.md b/README.md index 8a0d010..cb5d691 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,18 @@ # garmin-connect +## v1.6.0 refactor + +TODO: + +- [x] New HttpClient class +- [x] Login and get user token +- [x] Garmin URLs works with `garmin.cn` and `garmin.com` +- [ ] Refector GarminConnect.ts. Doing +- [ ] Handle MFA +- [x] Auto refresh Ouath2 token +- [x] Oauth1,Oauth2 token import and export. +- [ ] Replace new urls of Activity,Profile,Gear etc. + A powerful JavaScript library for connecting to Garmin Connect for sending and receiving health and workout data. It comes with some predefined methods to get and set different kinds of data for your Garmin account, but also have the possibility to make [custom requests](#custom-requests) `GET`, `POST` and `PUT` are currently supported. This makes it easy to implement whatever may be missing to suite your needs. ## Prerequisites @@ -24,7 +37,10 @@ $ npm install garmin-connect ```js const { GarminConnect } = require('garmin-connect'); // Create a new Garmin Connect Client -const GCClient = new GarminConnect({"username": "my.email@example.com", "password": "MySecretPassword"}); +const GCClient = new GarminConnect({ + username: 'my.email@example.com', + password: 'MySecretPassword' +}); // Uses credentials from garmin.config.json or uses supplied params await GCClient.login(); const userInfo = await GCClient.getUserInfo(); @@ -360,4 +376,3 @@ For now, this library only supports the following: - Get earned badges - Get available badges - Get details about one specific badge - diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index 1f1e37d..ed40ee0 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -6,25 +6,119 @@ import axios, { AxiosResponse, RawAxiosRequestHeaders } from 'axios'; -import { IOauth1Token, IOauth2Token } from '../garmin/types'; +import { + GarminDomain, + IOauth1, + IOauth1Consumer, + IOauth1Token, + IOauth2Token +} from '../garmin/types'; +import { UrlClass } from '../garmin/UrlClass'; import _ from 'lodash'; +import OAuth from 'oauth-1.0a'; +import FormData from 'form-data'; +import qs from 'qs'; +const crypto = require('crypto'); +import { DateTime } from 'luxon'; + +const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); +const TICKET_RE = new RegExp('ticket=([^"]+)"'); +const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile'; +const USER_AGENT_BROWSER = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'; + +const OAUTH_CONSUMER = { + key: 'REPLACE_ME', + secret: 'REPLACE_ME' +}; + +// refresh token +let isRefreshing = false; +let refreshSubscribers: ((token: string) => void)[] = []; export class HttpClient { client: AxiosInstance; + url: UrlClass; oauth1Token: IOauth1Token | undefined; oauth2Token: IOauth2Token | undefined; - constructor() { + constructor(url: UrlClass) { + this.url = url; this.client = axios.create(); this.client.interceptors.response.use( (response) => response, - (error) => { + async (error) => { + const originalRequest = error.config; + // Auto Refresh token + if (error.response.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + try { + const token = await new Promise( + (resolve) => { + refreshSubscribers.push((token) => { + resolve(token); + }); + } + ); + originalRequest.headers.Authorization = `Bearer ${token}`; + return this.client(originalRequest); + } catch (err) { + console.log('err:', err); + return Promise.reject(err); + } + } + + originalRequest._retry = true; + isRefreshing = true; + console.log('interceptors: refreshOauth2Toekn start'); + await this.refreshOauth2Toekn(); + console.log('interceptors: refreshOauth2Toekn end'); + isRefreshing = false; + refreshSubscribers.forEach((subscriber) => + subscriber(this.oauth2Token!.access_token) + ); + refreshSubscribers = []; + originalRequest.headers.Authorization = `Bearer ${ + this.oauth2Token!.access_token + }`; + return this.client(originalRequest); + } if (axios.isAxiosError(error)) { if (error?.response) this.handleError(error?.response); } throw error; } ); + this.client.interceptors.request.use(async (config) => { + if (this.oauth2Token) { + config.headers.Authorization = + 'Bearer ' + this.oauth2Token.access_token; + } + return config; + }); + } + + async checkTokenVaild() { + if (this.oauth2Token) { + if (this.oauth2Token.expires_at < DateTime.now().toSeconds()) { + console.error('Token expired!'); + await this.refreshOauth2Toekn(); + } + } + } + + async get(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.get(url, config); + return response?.data; + } + + async post( + url: string, + data: any, + config?: AxiosRequestConfig + ): Promise { + const response = await this.client.post(url, data, config); + return response?.data; } setCommonHeader(headers: RawAxiosRequestHeaders): void { @@ -46,13 +140,195 @@ export class HttpClient { throw new Error(msg); } - async get(url: string, config?: AxiosRequestConfig) { - const response = await this.client.get(url, config); - return response?.data; + /** + * Login to Garmin Connect + * @param username + * @param password + * @returns {Promise} + */ + async login(username: string, password: string): Promise { + // Step1-3: Get ticket from page. + const ticket = await this.getLoginTicket(username, password); + // Step4: Oauth1 + const oauth1 = await this.getOauth1Token(ticket); + // TODO: Handle MFA + + // Step 5: Oauth2 + await this.exchange(oauth1); + return this; } - async post(url: string, data: any, config?: AxiosRequestConfig) { - const response = await this.client.post(url, data, config); - return response?.data; + private async getLoginTicket( + username: string, + password: string + ): Promise { + // Step1: Set cookie + const step1Params = { + clientId: 'GarminConnect', + locale: 'en', + service: this.url.GC_MODERN + }; + const step1Url = `${this.url.GARMIN_SSO_EMBED}?${qs.stringify( + step1Params + )}`; + console.log('login - step1Url:', step1Url); + await this.client.get(step1Url); + + // Step2 Get _csrf + const step2Params = { + id: 'gauth-widget', + embedWidget: true, + locale: 'en', + gauthHost: this.url.GARMIN_SSO_EMBED + }; + const step2Url = `${this.url.SIGNIN_URL}?${qs.stringify(step2Params)}`; + console.log('login - step2Url:', step2Url); + const step2Result = await this.get(step2Url); + // console.log('login - step2Result:', step2Result) + const csrfRegResult = CSRF_RE.exec(step2Result); + if (!csrfRegResult) { + throw new Error('login - csrf not found'); + } + const csrf_token = csrfRegResult[1]; + console.log('login - csrf:', csrf_token); + + // Step3 Get ticket + const signinParams = { + id: 'gauth-widget', + embedWidget: true, + clientId: 'GarminConnect', + locale: 'en', + gauthHost: this.url.GARMIN_SSO_EMBED, + service: this.url.GARMIN_SSO_EMBED, + source: this.url.GARMIN_SSO_EMBED, + redirectAfterAccountLoginUrl: this.url.GARMIN_SSO_EMBED, + redirectAfterAccountCreationUrl: this.url.GARMIN_SSO_EMBED + }; + const step3Url = `${this.url.SIGNIN_URL}?${qs.stringify(signinParams)}`; + console.log('login - step3Url:', step3Url); + const step3Form = new FormData(); + step3Form.append('username', username); + step3Form.append('password', password); + step3Form.append('embed', 'true'); + step3Form.append('_csrf', csrf_token); + const step3Result = await this.post(step3Url, step3Form, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Dnt: 1, + Origin: this.url.GARMIN_SSO_ORIGIN, + Referer: this.url.SIGNIN_URL, + 'User-Agent': USER_AGENT_BROWSER + } + }); + + const ticketRegResult = TICKET_RE.exec(step3Result); + if (!ticketRegResult) { + throw new Error('login - ticket not found'); + } + const ticket = ticketRegResult[1]; + return ticket; + } + + async refreshOauth2Toekn() { + if (!this.oauth2Token || !this.oauth1Token) { + throw new Error('No Oauth2Token or Oauth1Token'); + } + const oauth1 = { + oauth: this.getOauthClient(OAUTH_CONSUMER), + token: this.oauth1Token + }; + await this.exchange(oauth1); + console.log('Oauth2 token refreshed!'); + } + + async getOauth1Token(ticket: string): Promise { + const params = { + ticket, + 'login-url': this.url.GARMIN_SSO_EMBED, + 'accepts-mfa-tokens': true + }; + const url = `${this.url.OAUTH_URL}/preauthorized?${qs.stringify( + params + )}`; + + const oauth = this.getOauthClient(OAUTH_CONSUMER); + + const step4RequestData = { + url: url, + method: 'GET' + }; + const headers = oauth.toHeader(oauth.authorize(step4RequestData)); + console.log('getOauth1Token - headers:', headers); + + const response = await this.get(url, { + headers: { + ...headers, + 'User-Agent': USER_AGENT_CONNECTMOBILE + } + }); + console.log('getOauth1Token - response:', response); + const token = qs.parse(response) as unknown as IOauth1Token; + console.log('getOauth1Token - token:', token); + this.oauth1Token = token; + return { token, oauth }; + } + + getOauthClient(consumer: IOauth1Consumer): OAuth { + const oauth = new OAuth({ + consumer: consumer, + signature_method: 'HMAC-SHA1', + hash_function(base_string: string, key: string) { + return crypto + .createHmac('sha1', key) + .update(base_string) + .digest('base64'); + } + }); + return oauth; + } + // + async exchange(oauth1: IOauth1) { + const token = { + key: oauth1.token.oauth_token, + secret: oauth1.token.oauth_token_secret + }; + console.log('exchange - token:', token); + + const baseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`; + const requestData = { + url: baseUrl, + method: 'POST', + data: null + }; + + const step5AuthData = oauth1.oauth.authorize(requestData, token); + console.log('login - step5AuthData:', step5AuthData); + const url = `${baseUrl}?${qs.stringify(step5AuthData)}`; + console.log('exchange - url:', url); + this.oauth2Token = undefined; + const response = await this.post(url, null, { + headers: { + 'User-Agent': USER_AGENT_CONNECTMOBILE, + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + console.log('exchange - response:', response); + this.oauth2Token = this.setOauth2TokenExpiresAt(response); + console.log('exchange - oauth2Token:', this.oauth2Token); + } + + setOauth2TokenExpiresAt(token: IOauth2Token): IOauth2Token { + // human readable date + token['last_update_date'] = DateTime.now().toLocal().toString(); + token['expires_date'] = DateTime.fromSeconds( + DateTime.now().toSeconds() + token['expires_in'] + ) + .toLocal() + .toString(); + // timestamp for check expired + token['expires_at'] = DateTime.now().toSeconds() + token['expires_in']; + token['refresh_token_expires_at'] = + DateTime.now().toSeconds() + token['refresh_token_expires_in']; + return token; } } diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index 83a09d2..551cd20 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -1,31 +1,11 @@ import appRoot from 'app-root-path'; -import FormData from 'form-data'; -import qs from 'qs'; -const crypto = require('crypto'); + +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { HttpClient } from '../common/HttpClient'; +import { checkIsDirectory, createDirectory, writeToFile } from '../utils'; import { UrlClass } from './UrlClass'; -import { - GCUserHash, - GarminDomain, - IOauth1, - IOauth1Token, - IOauth2Token, - IUserInfo -} from './types'; -import OAuth from 'oauth-1.0a'; -import { DateTime } from 'luxon'; -import { RawAxiosRequestHeaders } from 'axios'; - -const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); -const TICKET_RE = new RegExp('ticket=([^"]+)"'); -const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile'; -const USER_AGENT_BROWSER = - 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'; - -const OAUTH_CONSUMER = { - key: 'REPLACE_ME', - secret: 'REPLACE_ME' -}; +import { GCUserHash, GarminDomain, IOauth1Token, IOauth2Token } from './types'; let config: GCCredentials | undefined = undefined; @@ -65,187 +45,62 @@ export default class GarminConnect { if (!credentials) { throw new Error('Missing credentials'); } + this.credentials = credentials; this.url = new UrlClass(domain); - this.client = new HttpClient(); + this.client = new HttpClient(this.url); this._userHash = undefined; - this.credentials = credentials; this.listeners = {}; } - /** - * Login to Garmin Connect - * @param username - * @param password - * @returns {Promise<*>} - */ async login(username?: string, password?: string): Promise { if (username && password) { this.credentials.username = username; this.credentials.password = password; } - - // Step1: Set cookie - const step1Params = { - clientId: 'GarminConnect', - locale: 'en', - service: this.url.GC_MODERN - }; - const step1Url = `${this.url.GARMIN_SSO_EMBED}?${qs.stringify( - step1Params - )}`; - console.log('login - step1Url:', step1Url); - await this.client.get(step1Url); - - // Step2 Get _csrf - const step2Params = { - id: 'gauth-widget', - embedWidget: true, - locale: 'en', - gauthHost: this.url.GARMIN_SSO_EMBED - }; - const step2Url = `${this.url.SIGNIN_URL}?${qs.stringify(step2Params)}`; - console.log('login - step2Url:', step2Url); - const step2Result = await this.client.get(step2Url); - // console.log('login - step2Result:', step2Result) - const csrfRegResult = CSRF_RE.exec(step2Result); - if (!csrfRegResult) { - throw new Error('login - csrf not found'); - } - const csrf_token = csrfRegResult[1]; - console.log('login - csrf:', csrf_token); - - // Step3 Get ticket - const signinParams = { - id: 'gauth-widget', - embedWidget: true, - clientId: 'GarminConnect', - locale: 'en', - gauthHost: this.url.GARMIN_SSO_EMBED, - service: this.url.GARMIN_SSO_EMBED, - source: this.url.GARMIN_SSO_EMBED, - redirectAfterAccountLoginUrl: this.url.GARMIN_SSO_EMBED, - redirectAfterAccountCreationUrl: this.url.GARMIN_SSO_EMBED - }; - const step3Url = `${this.url.SIGNIN_URL}?${qs.stringify(signinParams)}`; - console.log('login - step3Url:', step3Url); - const step3Form = new FormData(); - step3Form.append('username', this.credentials.username); - step3Form.append('password', this.credentials.password); - step3Form.append('embed', 'true'); - step3Form.append('_csrf', csrf_token); - const step3Result = await this.client.post(step3Url, step3Form, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Dnt: 1, - Origin: this.url.GARMIN_SSO_ORIGIN, - Referer: this.url.SIGNIN_URL, - 'User-Agent': USER_AGENT_BROWSER - } - }); - - const ticketRegResult = TICKET_RE.exec(step3Result); - if (!ticketRegResult) { - throw new Error('login - ticket not found'); - } - const ticket = ticketRegResult[1]; - console.log('login - ticket:', ticket); - - // Step4: Oauth1 - const oauth1 = await this.getOauth1Token(ticket); - - // Step 5: Oauth2 - await this.exchange(oauth1); - + await this.client.login( + this.credentials.username, + this.credentials.password + ); return this; } - - async getOauth1Token(ticket: string): Promise { - const params = { - ticket, - 'login-url': this.url.GARMIN_SSO_EMBED, - 'accepts-mfa-tokens': true - }; - const url = `${this.url.OAUTH_URL}/preauthorized?${qs.stringify( - params - )}`; - - const oauth = new OAuth({ - consumer: OAUTH_CONSUMER, - signature_method: 'HMAC-SHA1', - hash_function(base_string: string, key: string) { - return crypto - .createHmac('sha1', key) - .update(base_string) - .digest('base64'); - } - }); - - const step4RequestData = { - url: url, - method: 'GET' - }; - const headers = oauth.toHeader(oauth.authorize(step4RequestData)); - console.log('getOauth1Token - headers:', headers); - - const response = await this.client.get(url, { - headers: { - ...headers, - 'User-Agent': USER_AGENT_CONNECTMOBILE - } - }); - console.log('getOauth1Token - response:', response); - const token = qs.parse(response) as unknown as IOauth1Token; - console.log('getOauth1Token - token:', token); - this.client.oauth1Token = token; - return { token, oauth }; - } - - async exchange(oauth1: IOauth1) { - const token = { - key: oauth1.token.oauth_token, - secret: oauth1.token.oauth_token_secret - }; - - const baseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`; - const requestData = { - url: baseUrl, - method: 'POST', - data: null - }; - - const step5AuthData = oauth1.oauth.authorize(requestData, token); - console.log('login - step5AuthData:', step5AuthData); - const url = `${baseUrl}?${qs.stringify(step5AuthData)}`; - const response = await this.client.post(url, null, { - headers: { - 'User-Agent': USER_AGENT_CONNECTMOBILE, - 'Content-Type': 'application/x-www-form-urlencoded' - } - }); - console.log('exchange - response:', response); - this.client.oauth2Token = this.setOauth2TokenExpiresAt(response); - - this.client.setCommonHeader({ - Authorization: 'Bearer ' + this.client.oauth2Token.access_token, - ...this.getGCCommonHeader() - }); - - console.log('exchange - oauth2Token:', this.client.oauth2Token); + exportTokenToFile(dirPath: string): void { + if (!checkIsDirectory(dirPath)) { + createDirectory(dirPath); + } + // save oauth1 to json + if (this.client.oauth1Token) { + writeToFile( + path.join(dirPath, 'oauth1_token.json'), + JSON.stringify(this.client.oauth1Token) + ); + } + if (this.client.oauth2Token) { + writeToFile( + path.join(dirPath, 'oauth2_token.json'), + JSON.stringify(this.client.oauth2Token) + ); + } } - - setOauth2TokenExpiresAt(token: IOauth2Token): IOauth2Token { - token['expires_at'] = DateTime.now().toSeconds() + token['expires_in']; - token['refresh_token_expires_at'] = - DateTime.now().toSeconds() + token['refresh_token_expires_in']; - return token; + loadTokenByFile(dirPath: string): void { + if (!checkIsDirectory(dirPath)) { + throw new Error('loadTokenByFile: Directory not found: ' + dirPath); + } + let oauth1Data = fs.readFileSync( + path.join(dirPath, 'oauth1_token.json') + ) as unknown as string; + const oauth1 = JSON.parse(oauth1Data); + this.client.oauth1Token = oauth1; + + let oauth2Data = fs.readFileSync( + path.join(dirPath, 'oauth2_token.json') + ) as unknown as string; + const oauth2 = JSON.parse(oauth2Data); + this.client.oauth2Token = oauth2; } - - getGCCommonHeader(): RawAxiosRequestHeaders { - return { - 'Di-Backend': this.url.GC_API, - Nk: 'NT', - Dnt: 1 - }; + // from db or localstorage etc + loadToken(oauth1: IOauth1Token, oauth2: IOauth2Token): void { + this.client.oauth1Token = oauth1; + this.client.oauth2Token = oauth2; } // User Settings diff --git a/src/garmin/types.ts b/src/garmin/types.ts index a8eb6c0..80491ad 100644 --- a/src/garmin/types.ts +++ b/src/garmin/types.ts @@ -590,6 +590,10 @@ export interface Gear { updateDate: string; } +export interface IOauth1Consumer { + key: string; + secret: string; +} export interface IOauth1 { token: IOauth1Token; oauth: OAuth; @@ -610,6 +614,8 @@ export interface IOauth2Token { refresh_token_expires_in: number; // added - expires_at?: number; - refresh_token_expires_at?: number; + expires_at: number; + refresh_token_expires_at: number; + last_update_date: string; + expires_date: string; } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..bbc7ba4 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,19 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export const checkIsDirectory = (filePath: string): boolean => { + return fs.existsSync(filePath) && fs.lstatSync(filePath).isDirectory(); +}; + +export const createDirectory = (directoryPath: string): void => { + fs.mkdirSync(directoryPath); +}; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const writeToFile = (filePath: string, data: any): void => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + fs.writeFileSync(filePath, data, (error) => { + if (error) throw error; + }); +}; From 964b89ab780782241c6575c396fbb858274fd6df Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 00:31:58 +0800 Subject: [PATCH 07/33] feat: Activity API --- README.md | 3 +- src/garmin/GarminConnect.ts | 125 +++++++++++++++++++++++++++++++++--- src/garmin/UrlClass.ts | 41 ++++++++---- src/garmin/types.ts | 104 ++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index cb5d691..dcc0ed5 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ TODO: - [x] New HttpClient class - [x] Login and get user token - [x] Garmin URLs works with `garmin.cn` and `garmin.com` -- [ ] Refector GarminConnect.ts. Doing - [ ] Handle MFA - [x] Auto refresh Ouath2 token - [x] Oauth1,Oauth2 token import and export. - [ ] Replace new urls of Activity,Profile,Gear etc. +- [x] Download Activity, countActivities, getActivities, getActivity, getUserProfile, getUserSettings +- [ ] Upload Activity (2023-10-02 Garmin upload service is down. See https://connect.garmin.com/status/) A powerful JavaScript library for connecting to Garmin Connect for sending and receiving health and workout data. It comes with some predefined methods to get and set different kinds of data for your Garmin account, but also have the possibility to make [custom requests](#custom-requests) `GET`, `POST` and `PUT` are currently supported. This makes it easy to implement whatever may be missing to suite your needs. diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index 551cd20..763db6e 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -1,11 +1,27 @@ import appRoot from 'app-root-path'; +import FormData from 'form-data'; +import { DateTime } from 'luxon'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { HttpClient } from '../common/HttpClient'; import { checkIsDirectory, createDirectory, writeToFile } from '../utils'; import { UrlClass } from './UrlClass'; -import { GCUserHash, GarminDomain, IOauth1Token, IOauth2Token } from './types'; +import { + ExportFileTypeValue, + GCActivityId, + GCUserHash, + GarminDomain, + IActivity, + ICountActivities, + IOauth1Token, + IOauth2Token, + ISocialProfile, + IUserSettings, + UploadFileType, + UploadFileTypeTypeValue +} from './types'; +import _ from 'lodash'; let config: GCCredentials | undefined = undefined; @@ -103,12 +119,105 @@ export default class GarminConnect { this.client.oauth2Token = oauth2; } - // User Settings - /** - * Get basic user information - * @returns {Promise<*>} - */ - async getUserSettings(): Promise { - return this.client.get(this.url.USER_SETTINGS); + async getUserSettings(): Promise { + return this.client.get(this.url.USER_SETTINGS); + } + + async getUserProfile(): Promise { + return this.client.get(this.url.USER_PROFILE); + } + + async getActivities(start: number, limit: number): Promise { + return this.client.get(this.url.ACTIVITIES, { + params: { start, limit } + }); + } + async getActivity(activity: { + activityId: GCActivityId; + }): Promise { + if (!activity.activityId) throw new Error('Missing activityId'); + return this.client.get( + this.url.ACTIVITY + activity.activityId + ); + } + async countActivities(): Promise { + return this.client.get(this.url.STAT_ACTIVITIES, { + params: { + aggregation: 'lifetime', + startDate: '1970-01-01', + endDate: DateTime.now().toFormat('yyyy-MM-dd'), + metric: 'duration' + } + }); + } + + async downloadOriginalActivityData( + activity: { activityId: GCActivityId }, + dir: string, + type: ExportFileTypeValue = 'zip' + ): Promise { + if (!activity.activityId) throw new Error('Missing activityId'); + if (!checkIsDirectory(dir)) { + createDirectory(dir); + } + let fileBuffer: Buffer; + if (type === 'tcx') { + fileBuffer = await this.client.get( + this.url.DOWNLOAD_TCX + activity.activityId + ); + } else if (type === 'gpx') { + fileBuffer = await this.client.get( + this.url.DOWNLOAD_GPX + activity.activityId + ); + } else if (type === 'kml') { + fileBuffer = await this.client.get( + this.url.DOWNLOAD_KML + activity.activityId + ); + } else if (type === 'zip') { + fileBuffer = await this.client.get( + this.url.DOWNLOAD_ZIP + activity.activityId, + { + responseType: 'arraybuffer' + } + ); + } else { + throw new Error( + 'downloadOriginalActivityData - Invalid type: ' + type + ); + } + writeToFile( + path.join(dir, `${activity.activityId}.${type}`), + fileBuffer + ); + } + + async uploadActivity( + file: string, + format: UploadFileTypeTypeValue = 'fit' + ) { + const detectedFormat = (format || path.extname(file))?.toLowerCase(); + console.log('uploadActivity - detectedFormat:', detectedFormat); + const filename = path.basename(file); + console.log('uploadActivity - filename:', filename); + + if (!_.includes(UploadFileType, detectedFormat)) { + throw new Error('uploadActivity - Invalid format: ' + format); + } + + const fileBuffer = fs.createReadStream(file); + const form = new FormData(); + form.append('userfile', fileBuffer); + const response = this.client.post( + this.url.UPLOAD + '.' + format, + form, + { + headers: { + ...form.getHeaders() + } + } + ); + // TODO: FIX THIS. GARMIN Activity Uploads service is down + // https://connect.garmin.com/status/ + return response; } } diff --git a/src/garmin/UrlClass.ts b/src/garmin/UrlClass.ts index 6f8a569..aedbfc3 100644 --- a/src/garmin/UrlClass.ts +++ b/src/garmin/UrlClass.ts @@ -1,3 +1,4 @@ +import { UPLOAD_SERVICE } from './Urls'; import { GarminDomain } from './types'; export class UrlClass { @@ -32,17 +33,31 @@ export class UrlClass { get USER_SETTINGS() { return `${this.GC_API}/userprofile-service/userprofile/user-settings/`; } -} - -export enum ExportFileType { - tcx = 'tcx', - gpx = 'gpx', - kml = 'kml', - zip = 'zip' -} - -export enum UploadFileType { - tcx = 'tcx', - gpx = 'gpx', - fit = 'fit' + get USER_PROFILE() { + return `${this.GC_API}/userprofile-service/socialProfile`; + } + get ACTIVITIES() { + return `${this.GC_API}/activitylist-service/activities/search/activities`; + } + get ACTIVITY() { + return `${this.GC_API}/activity-service/activity/`; + } + get STAT_ACTIVITIES() { + return `${this.GC_API}/fitnessstats-service/activity`; + } + get DOWNLOAD_ZIP() { + return `${this.GC_API}/download-service/files/activity/`; + } + get DOWNLOAD_GPX() { + return `${this.GC_API}/download-service/export/gpx/activity/`; + } + get DOWNLOAD_TCX() { + return `${this.GC_API}/download-service/export/tcx/activity/`; + } + get DOWNLOAD_KML() { + return `${this.GC_API}/download-service/export/kml/activity/`; + } + get UPLOAD() { + return `${this.BASE_URL}/upload-service/upload/`; + } } diff --git a/src/garmin/types.ts b/src/garmin/types.ts index 80491ad..d212ac4 100644 --- a/src/garmin/types.ts +++ b/src/garmin/types.ts @@ -5,6 +5,21 @@ export type GCWorkoutId = string; export type GCBadgeId = number; export type GarminDomain = 'garmin.com' | 'garmin.cn'; +export enum ExportFileType { + tcx = 'tcx', + gpx = 'gpx', + kml = 'kml', + zip = 'zip' +} + +export enum UploadFileType { + tcx = 'tcx', + gpx = 'gpx', + fit = 'fit' +} +export type ExportFileTypeValue = keyof typeof ExportFileType; +export type UploadFileTypeTypeValue = keyof typeof UploadFileType; + export interface IUserInfo { userProfileId: GCUserProfileId; username: string; @@ -605,6 +620,7 @@ export interface IOauth1Token { } export interface IOauth2Token { + // from Garmin API scope: string; jti: string; access_token: string; @@ -619,3 +635,91 @@ export interface IOauth2Token { last_update_date: string; expires_date: string; } + +export interface IUserSettings { + id: number; + userData: IUserData; + userSleep: { + sleepTime: number; + defaultSleepTime: boolean; + wakeTime: number; + defaultWakeTime: boolean; + }; + connectDate: unknown; + sourceType: unknown; + userSleepWindows: IUserSleepWindows[]; +} +export interface IUserData { + gender: unknown; + weight: unknown; + height: unknown; + timeFormat: string; + birthDate: unknown; + measurementSystem: string; + activityLevel: unknown; + handedness: string; + powerFormat: { + formatId: number; + formatKey: string; + minFraction: number; + maxFraction: number; + groupingUsed: boolean; + displayFormat: unknown; + }; + heartRateFormat: { + formatId: number; + formatKey: string; + minFraction: number; + maxFraction: number; + groupingUsed: boolean; + displayFormat: unknown; + }; + firstDayOfWeek: { + dayId: number; + dayName: string; + sortOrder: number; + isPossibleFirstDay: boolean; + }; + vo2MaxRunning: unknown; + vo2MaxCycling: unknown; + lactateThresholdSpeed: unknown; + lactateThresholdHeartRate: unknown; + diveNumber: unknown; + intensityMinutesCalcMethod: string; + moderateIntensityMinutesHrZone: number; + vigorousIntensityMinutesHrZone: number; + hydrationMeasurementUnit: string; + hydrationContainers: unknown[]; + hydrationAutoGoalEnabled: boolean; + firstbeatMaxStressScore: unknown; + firstbeatCyclingLtTimestamp: unknown; + firstbeatRunningLtTimestamp: unknown; + thresholdHeartRateAutoDetected: unknown; + ftpAutoDetected: unknown; + trainingStatusPausedDate: unknown; + weatherLocation: { + useFixedLocation: unknown; + latitude: unknown; + longitude: unknown; + locationName: unknown; + isoCountryCode: unknown; + postalCode: unknown; + }; + golfDistanceUnit: string; + golfElevationUnit: unknown; + golfSpeedUnit: unknown; + externalBottomTime: unknown; +} +export interface IUserSleepWindows { + sleepWindowFrequency: string; + startSleepTimeSecondsFromMidnight: number; + endSleepTimeSecondsFromMidnight: number; +} + +export interface ICountActivities { + countOfActivities: number; + date: string; + stats: { + all: Record; + }; +} From 85568eb14595f13bc9b5d03054793e99c2382c23 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 09:33:44 +0800 Subject: [PATCH 08/33] feat: upload and delete activity --- src/garmin/GarminConnect.ts | 23 ++++++++++++++++------- src/garmin/UrlClass.ts | 5 ++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index 763db6e..c13a13b 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -196,10 +196,6 @@ export default class GarminConnect { format: UploadFileTypeTypeValue = 'fit' ) { const detectedFormat = (format || path.extname(file))?.toLowerCase(); - console.log('uploadActivity - detectedFormat:', detectedFormat); - const filename = path.basename(file); - console.log('uploadActivity - filename:', filename); - if (!_.includes(UploadFileType, detectedFormat)) { throw new Error('uploadActivity - Invalid format: ' + format); } @@ -212,12 +208,25 @@ export default class GarminConnect { form, { headers: { - ...form.getHeaders() + 'Content-Type': form.getHeaders()['content-type'] } } ); - // TODO: FIX THIS. GARMIN Activity Uploads service is down - // https://connect.garmin.com/status/ return response; } + + async deleteActivity(activity: { + activityId: GCActivityId; + }): Promise { + if (!activity.activityId) throw new Error('Missing activityId'); + await this.client.post( + this.url.ACTIVITY + activity.activityId, + null, + { + headers: { + 'X-Http-Method-Override': 'DELETE' + } + } + ); + } } diff --git a/src/garmin/UrlClass.ts b/src/garmin/UrlClass.ts index aedbfc3..aa0c9ec 100644 --- a/src/garmin/UrlClass.ts +++ b/src/garmin/UrlClass.ts @@ -58,6 +58,9 @@ export class UrlClass { return `${this.GC_API}/download-service/export/kml/activity/`; } get UPLOAD() { - return `${this.BASE_URL}/upload-service/upload/`; + return `${this.GC_API}/upload-service/upload/`; + } + get IMPORT_DATA() { + return `${this.GC_API}/modern/import-data`; } } From efc98ce8688f5633dd6e5381dce23dea67ece7b9 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 09:51:22 +0800 Subject: [PATCH 09/33] doc: update readme --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dcc0ed5..003436e 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,21 @@ TODO: - [x] New HttpClient class - [x] Login and get user token - [x] Garmin URLs works with `garmin.cn` and `garmin.com` -- [ ] Handle MFA - [x] Auto refresh Ouath2 token - [x] Oauth1,Oauth2 token import and export. -- [ ] Replace new urls of Activity,Profile,Gear etc. - [x] Download Activity, countActivities, getActivities, getActivity, getUserProfile, getUserSettings -- [ ] Upload Activity (2023-10-02 Garmin upload service is down. See https://connect.garmin.com/status/) +- [x] Upload Activity, delete Activity +- [ ] Implementation of other methods, such as Badge,Workout,Gear etc +- [ ] Handle MFA +- [ ] Unit test + +If something is not working, please check [https://connect.garmin.com/status/](https://connect.garmin.com/status/) first. + +Currently, most of previous features are working, but some of Rest API are not added, such as `Gear`,`Workout`,`Badge` etc. So if you need these features, please add a PR. + +All of above work inspired by [https://github.com/matin/garth](https://github.com/matin/garth). Many thanks. + +--- A powerful JavaScript library for connecting to Garmin Connect for sending and receiving health and workout data. It comes with some predefined methods to get and set different kinds of data for your Garmin account, but also have the possibility to make [custom requests](#custom-requests) `GET`, `POST` and `PUT` are currently supported. This makes it easy to implement whatever may be missing to suite your needs. From 8757b9a92cef03156e52ae6531deac2c22c3c95e Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 09:58:31 +0800 Subject: [PATCH 10/33] feat: get OAUTH_CONSUMER from url, not hard code --- src/common/HttpClient.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index ed40ee0..9906516 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -27,11 +27,8 @@ const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile'; const USER_AGENT_BROWSER = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'; -const OAUTH_CONSUMER = { - key: 'REPLACE_ME', - secret: 'REPLACE_ME' -}; - +const OAUTH_CONSUMER_URL = + 'https://thegarth.s3.amazonaws.com/oauth_consumer.json'; // refresh token let isRefreshing = false; let refreshSubscribers: ((token: string) => void)[] = []; @@ -41,6 +38,7 @@ export class HttpClient { url: UrlClass; oauth1Token: IOauth1Token | undefined; oauth2Token: IOauth2Token | undefined; + OAUTH_CONSUMER: IOauth1Consumer | undefined; constructor(url: UrlClass) { this.url = url; @@ -98,6 +96,14 @@ export class HttpClient { }); } + async fetchOauthConsumer() { + const response = await axios.get(OAUTH_CONSUMER_URL); + this.OAUTH_CONSUMER = { + key: response.data.consumer_key, + secret: response.data.consumer_secret + }; + } + async checkTokenVaild() { if (this.oauth2Token) { if (this.oauth2Token.expires_at < DateTime.now().toSeconds()) { @@ -147,6 +153,7 @@ export class HttpClient { * @returns {Promise} */ async login(username: string, password: string): Promise { + await this.fetchOauthConsumer(); // Step1-3: Get ticket from page. const ticket = await this.getLoginTicket(username, password); // Step4: Oauth1 @@ -230,11 +237,11 @@ export class HttpClient { } async refreshOauth2Toekn() { - if (!this.oauth2Token || !this.oauth1Token) { - throw new Error('No Oauth2Token or Oauth1Token'); + if (!this.oauth2Token || !this.oauth1Token || !this.OAUTH_CONSUMER) { + throw new Error('No Oauth2Token or Oauth1Token or OAUTH_CONSUMER'); } const oauth1 = { - oauth: this.getOauthClient(OAUTH_CONSUMER), + oauth: this.getOauthClient(this.OAUTH_CONSUMER), token: this.oauth1Token }; await this.exchange(oauth1); @@ -242,6 +249,9 @@ export class HttpClient { } async getOauth1Token(ticket: string): Promise { + if (!this.OAUTH_CONSUMER) { + throw new Error('No OAUTH_CONSUMER'); + } const params = { ticket, 'login-url': this.url.GARMIN_SSO_EMBED, @@ -251,7 +261,7 @@ export class HttpClient { params )}`; - const oauth = this.getOauthClient(OAUTH_CONSUMER); + const oauth = this.getOauthClient(this.OAUTH_CONSUMER); const step4RequestData = { url: url, From 439992f970c0b11173252557653d92879afe471d Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 10:27:11 +0800 Subject: [PATCH 11/33] feat: handle Account locked --- README.md | 1 + src/common/HttpClient.ts | 35 +++++++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 003436e..e1e4535 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ TODO: - [x] Upload Activity, delete Activity - [ ] Implementation of other methods, such as Badge,Workout,Gear etc - [ ] Handle MFA +- [x] Handle Account locked - [ ] Unit test If something is not working, please check [https://connect.garmin.com/status/](https://connect.garmin.com/status/) first. diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index 9906516..033f607 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -23,6 +23,8 @@ import { DateTime } from 'luxon'; const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); const TICKET_RE = new RegExp('ticket=([^"]+)"'); +const ACCOUNT_LOCKED_RE = new RegExp('var statuss*=s*"([^"]*)"'); + const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile'; const USER_AGENT_BROWSER = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'; @@ -47,8 +49,12 @@ export class HttpClient { (response) => response, async (error) => { const originalRequest = error.config; + // console.log('originalRequest:', originalRequest) // Auto Refresh token if (error.response.status === 401 && !originalRequest._retry) { + if (!this.oauth2Token) { + return; + } if (isRefreshing) { try { const token = await new Promise( @@ -68,9 +74,9 @@ export class HttpClient { originalRequest._retry = true; isRefreshing = true; - console.log('interceptors: refreshOauth2Toekn start'); - await this.refreshOauth2Toekn(); - console.log('interceptors: refreshOauth2Toekn end'); + console.log('interceptors: refreshOauth2Token start'); + await this.refreshOauth2Token(); + console.log('interceptors: refreshOauth2Token end'); isRefreshing = false; refreshSubscribers.forEach((subscriber) => subscriber(this.oauth2Token!.access_token) @@ -108,7 +114,7 @@ export class HttpClient { if (this.oauth2Token) { if (this.oauth2Token.expires_at < DateTime.now().toSeconds()) { console.error('Token expired!'); - await this.refreshOauth2Toekn(); + await this.refreshOauth2Token(); } } } @@ -227,16 +233,29 @@ export class HttpClient { 'User-Agent': USER_AGENT_BROWSER } }); + // console.log('step3Result:', step3Result) + this.handleAccountLocked(step3Result); const ticketRegResult = TICKET_RE.exec(step3Result); if (!ticketRegResult) { - throw new Error('login - ticket not found'); + throw new Error( + 'login failed (Ticket not found), please check username and password' + ); + } + } + + handleAccountLocked(htmlStr: string): void { + const accountLockedRegResult = ACCOUNT_LOCKED_RE.exec(htmlStr); + if (accountLockedRegResult) { + const msg = accountLockedRegResult[1]; + console.error(msg); + throw new Error( + 'login failed (AccountLocked), please open connect web page to unlock your account' + ); } - const ticket = ticketRegResult[1]; - return ticket; } - async refreshOauth2Toekn() { + async refreshOauth2Token() { if (!this.oauth2Token || !this.oauth1Token || !this.OAUTH_CONSUMER) { throw new Error('No Oauth2Token or Oauth1Token or OAUTH_CONSUMER'); } From c1bfccb539b8bc1758ff15ab5b46a403bcd71a4f Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 10:38:49 +0800 Subject: [PATCH 12/33] feat: handle MFA (TODO) --- src/common/HttpClient.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index 033f607..a5deae7 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -235,6 +235,7 @@ export class HttpClient { }); // console.log('step3Result:', step3Result) this.handleAccountLocked(step3Result); + this.handleMFA(step3Result); const ticketRegResult = TICKET_RE.exec(step3Result); if (!ticketRegResult) { @@ -244,6 +245,9 @@ export class HttpClient { } } + // TODO: Handle MFA + handleMFA(htmlStr: string): void {} + handleAccountLocked(htmlStr: string): void { const accountLockedRegResult = ACCOUNT_LOCKED_RE.exec(htmlStr); if (accountLockedRegResult) { From a292f2fcaaf54adac459ad0e54e44b6b0aa7e574 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 10:43:43 +0800 Subject: [PATCH 13/33] fix: missing ticket --- src/common/HttpClient.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index a5deae7..fec49d5 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -1,25 +1,22 @@ import axios, { - AxiosError, - AxiosHeaders, AxiosInstance, AxiosRequestConfig, AxiosResponse, RawAxiosRequestHeaders } from 'axios'; +import FormData from 'form-data'; +import _ from 'lodash'; +import { DateTime } from 'luxon'; +import OAuth from 'oauth-1.0a'; +import qs from 'qs'; +import { UrlClass } from '../garmin/UrlClass'; import { - GarminDomain, IOauth1, IOauth1Consumer, IOauth1Token, IOauth2Token } from '../garmin/types'; -import { UrlClass } from '../garmin/UrlClass'; -import _ from 'lodash'; -import OAuth from 'oauth-1.0a'; -import FormData from 'form-data'; -import qs from 'qs'; const crypto = require('crypto'); -import { DateTime } from 'luxon'; const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); const TICKET_RE = new RegExp('ticket=([^"]+)"'); @@ -240,9 +237,11 @@ export class HttpClient { const ticketRegResult = TICKET_RE.exec(step3Result); if (!ticketRegResult) { throw new Error( - 'login failed (Ticket not found), please check username and password' + 'login failed (Ticket not found or MFA), please check username and password' ); } + const ticket = ticketRegResult[1]; + return ticket; } // TODO: Handle MFA From 354895a6132a82881489d752e35f9fac1a405e82 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 11:24:40 +0800 Subject: [PATCH 14/33] feat: v1.6.0 --- README.md | 43 ++++++++++++++++++++++++++++++++++++++++--- package.json | 2 +- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e1e4535..d4dc81f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ TODO: - [ ] Handle MFA - [x] Handle Account locked - [ ] Unit test +- [ ] Listeners If something is not working, please check [https://connect.garmin.com/status/](https://connect.garmin.com/status/) first. @@ -54,12 +55,48 @@ const GCClient = new GarminConnect({ }); // Uses credentials from garmin.config.json or uses supplied params await GCClient.login(); -const userInfo = await GCClient.getUserInfo(); +const userProfile = await GCClient.getUserProfile(); ``` -Now you can check `userInfo.emailAddress` to verify that your login was successful. +Now you can check `userProfile.userName` (userName is your email address) to verify that your login was successful. -## Reusing your session +## Reusing your session(since v1.6.0) + +### Save token to file and reuse it. + +```js +GCClient.saveTokenToFile('/path/to/save/tokens'); +``` + +Result: + +```bash +$ ls /path/to/save/tokens +oauth1_token.json oauth2_token.json +``` + +Reuse token: + +```js +GCClient.loadTokenByFile('/path/to/save/tokens'); +``` + +### Or just save your token to db or other storage. + +```js +const oauth1 = GCClient.client.oauth1Token; +const oauth2 = GCClient.client.oauth2Token; +// save to db or other storage +... +``` + +Reuse token: + +```js +GCClient.loadToken(oauth1, oauth2); +``` + +## Reusing your session(depreated) This is an experimental feature and might not yet provide full stability. diff --git a/package.json b/package.json index f9a4955..c2b5b3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "garmin-connect", - "version": "1.5.0", + "version": "1.6.0", "description": "Makes it simple to interface with Garmin Connect to get or set any data point", "main": "./dist/index.js", "types": "./dist/index.d.ts", From ce1e4ff596296c4505e0c87284adebc1720e926d Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 11:58:16 +0800 Subject: [PATCH 15/33] feat: make GarminConnect.client public --- src/garmin/GarminConnect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index c13a13b..d15687a 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -48,7 +48,7 @@ export enum Event { export interface Session {} export default class GarminConnect { - private client: HttpClient; + client: HttpClient; private _userHash: GCUserHash | undefined; private credentials: GCCredentials; private listeners: Listeners; From 664660a018547ef3adb6fac506de5e95b3ff2927 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 12:05:50 +0800 Subject: [PATCH 16/33] feat: exportToken --- src/garmin/GarminConnect.ts | 10 ++++++++++ src/garmin/types.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index d15687a..380c356 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -14,6 +14,7 @@ import { GarminDomain, IActivity, ICountActivities, + IGarminTokens, IOauth1Token, IOauth2Token, ISocialProfile, @@ -113,6 +114,15 @@ export default class GarminConnect { const oauth2 = JSON.parse(oauth2Data); this.client.oauth2Token = oauth2; } + exportToken(): IGarminTokens { + if (!this.client.oauth1Token || !this.client.oauth2Token) { + throw new Error('exportToken: Token not found'); + } + return { + oauth1: this.client.oauth1Token, + oauth2: this.client.oauth2Token + }; + } // from db or localstorage etc loadToken(oauth1: IOauth1Token, oauth2: IOauth2Token): void { this.client.oauth1Token = oauth1; diff --git a/src/garmin/types.ts b/src/garmin/types.ts index d212ac4..b9dfcc5 100644 --- a/src/garmin/types.ts +++ b/src/garmin/types.ts @@ -614,6 +614,10 @@ export interface IOauth1 { oauth: OAuth; } +export interface IGarminTokens { + oauth1: IOauth1Token; + oauth2: IOauth2Token; +} export interface IOauth1Token { oauth_token: string; oauth_token_secret: string; From 09f30fcd58b0f8b8dcd1bfdac227255442b12d63 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 12:06:32 +0800 Subject: [PATCH 17/33] v1.6.1-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c2b5b3d..f047522 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "garmin-connect", - "version": "1.6.0", + "version": "1.6.1-rc.1", "description": "Makes it simple to interface with Garmin Connect to get or set any data point", "main": "./dist/index.js", "types": "./dist/index.d.ts", From 3a391c8952ae2b682992356f60007522e1162b0c Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 15:01:28 +0800 Subject: [PATCH 18/33] v1.6.1-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f047522..767b514 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "garmin-connect", - "version": "1.6.1-rc.1", + "version": "1.6.1-rc.2", "description": "Makes it simple to interface with Garmin Connect to get or set any data point", "main": "./dist/index.js", "types": "./dist/index.d.ts", From 22c1d61b43fad8a158350f4651c3ad50cbf10b9d Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 15:02:33 +0800 Subject: [PATCH 19/33] fix: refreshOauth2Token fetch OAUTH_CONSUMER --- src/common/HttpClient.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index fec49d5..828fbcd 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -259,11 +259,14 @@ export class HttpClient { } async refreshOauth2Token() { - if (!this.oauth2Token || !this.oauth1Token || !this.OAUTH_CONSUMER) { - throw new Error('No Oauth2Token or Oauth1Token or OAUTH_CONSUMER'); + if (!this.OAUTH_CONSUMER) { + await this.fetchOauthConsumer(); + } + if (!this.oauth2Token || !this.oauth1Token) { + throw new Error('No Oauth2Token or Oauth1Token'); } const oauth1 = { - oauth: this.getOauthClient(this.OAUTH_CONSUMER), + oauth: this.getOauthClient(this.OAUTH_CONSUMER!), token: this.oauth1Token }; await this.exchange(oauth1); From 9560efab973889e6101b0abda7688b64d9ce0503 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 15:48:15 +0800 Subject: [PATCH 20/33] chore: remove logs --- src/common/HttpClient.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index 828fbcd..50fbaca 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -181,7 +181,7 @@ export class HttpClient { const step1Url = `${this.url.GARMIN_SSO_EMBED}?${qs.stringify( step1Params )}`; - console.log('login - step1Url:', step1Url); + // console.log('login - step1Url:', step1Url); await this.client.get(step1Url); // Step2 Get _csrf @@ -192,7 +192,7 @@ export class HttpClient { gauthHost: this.url.GARMIN_SSO_EMBED }; const step2Url = `${this.url.SIGNIN_URL}?${qs.stringify(step2Params)}`; - console.log('login - step2Url:', step2Url); + // console.log('login - step2Url:', step2Url); const step2Result = await this.get(step2Url); // console.log('login - step2Result:', step2Result) const csrfRegResult = CSRF_RE.exec(step2Result); @@ -200,7 +200,7 @@ export class HttpClient { throw new Error('login - csrf not found'); } const csrf_token = csrfRegResult[1]; - console.log('login - csrf:', csrf_token); + // console.log('login - csrf:', csrf_token); // Step3 Get ticket const signinParams = { @@ -215,7 +215,7 @@ export class HttpClient { redirectAfterAccountCreationUrl: this.url.GARMIN_SSO_EMBED }; const step3Url = `${this.url.SIGNIN_URL}?${qs.stringify(signinParams)}`; - console.log('login - step3Url:', step3Url); + // console.log('login - step3Url:', step3Url); const step3Form = new FormData(); step3Form.append('username', username); step3Form.append('password', password); @@ -293,7 +293,7 @@ export class HttpClient { method: 'GET' }; const headers = oauth.toHeader(oauth.authorize(step4RequestData)); - console.log('getOauth1Token - headers:', headers); + // console.log('getOauth1Token - headers:', headers); const response = await this.get(url, { headers: { @@ -301,9 +301,9 @@ export class HttpClient { 'User-Agent': USER_AGENT_CONNECTMOBILE } }); - console.log('getOauth1Token - response:', response); + // console.log('getOauth1Token - response:', response); const token = qs.parse(response) as unknown as IOauth1Token; - console.log('getOauth1Token - token:', token); + // console.log('getOauth1Token - token:', token); this.oauth1Token = token; return { token, oauth }; } @@ -327,7 +327,7 @@ export class HttpClient { key: oauth1.token.oauth_token, secret: oauth1.token.oauth_token_secret }; - console.log('exchange - token:', token); + // console.log('exchange - token:', token); const baseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`; const requestData = { @@ -337,9 +337,9 @@ export class HttpClient { }; const step5AuthData = oauth1.oauth.authorize(requestData, token); - console.log('login - step5AuthData:', step5AuthData); + // console.log('login - step5AuthData:', step5AuthData); const url = `${baseUrl}?${qs.stringify(step5AuthData)}`; - console.log('exchange - url:', url); + // console.log('exchange - url:', url); this.oauth2Token = undefined; const response = await this.post(url, null, { headers: { @@ -347,9 +347,9 @@ export class HttpClient { 'Content-Type': 'application/x-www-form-urlencoded' } }); - console.log('exchange - response:', response); + // console.log('exchange - response:', response); this.oauth2Token = this.setOauth2TokenExpiresAt(response); - console.log('exchange - oauth2Token:', this.oauth2Token); + // console.log('exchange - oauth2Token:', this.oauth2Token); } setOauth2TokenExpiresAt(token: IOauth2Token): IOauth2Token { From 8cb3ed19da20ff02f246f28be548c7d2c9e87351 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 15:48:27 +0800 Subject: [PATCH 21/33] v1.6.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 767b514..7f9ab5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "garmin-connect", - "version": "1.6.1-rc.2", + "version": "1.6.1", "description": "Makes it simple to interface with Garmin Connect to get or set any data point", "main": "./dist/index.js", "types": "./dist/index.d.ts", From e82aa730357a08b23640991843fcbe7e825eb16f Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 22:59:48 +0800 Subject: [PATCH 22/33] new bug at sign in step: Handle Phone number --- src/common/HttpClient.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index 50fbaca..2c3d7e9 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -21,10 +21,11 @@ const crypto = require('crypto'); const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"'); const TICKET_RE = new RegExp('ticket=([^"]+)"'); const ACCOUNT_LOCKED_RE = new RegExp('var statuss*=s*"([^"]*)"'); +const PAGE_TITLE_RE = new RegExp('([^<]*)'); const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile'; const USER_AGENT_BROWSER = - 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'; + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36'; const OAUTH_CONSUMER_URL = 'https://thegarth.s3.amazonaws.com/oauth_consumer.json'; @@ -232,6 +233,7 @@ export class HttpClient { }); // console.log('step3Result:', step3Result) this.handleAccountLocked(step3Result); + this.handlePageTitle(step3Result); this.handleMFA(step3Result); const ticketRegResult = TICKET_RE.exec(step3Result); @@ -247,6 +249,22 @@ export class HttpClient { // TODO: Handle MFA handleMFA(htmlStr: string): void {} + // TODO: Handle Phone number + handlePageTitle(htmlStr: string): void { + const pageTitileRegResult = PAGE_TITLE_RE.exec(htmlStr); + if (pageTitileRegResult) { + const title = pageTitileRegResult[1]; + console.log('login page title:', title); + if (_.includes(title, 'Update Phone Number')) { + // current I don't know where to update it + // See: https://github.com/matin/garth/issues/19 + throw new Error( + "login failed (Update Phone number), please update your phone number, currently I don't know where to update it" + ); + } + } + } + handleAccountLocked(htmlStr: string): void { const accountLockedRegResult = ACCOUNT_LOCKED_RE.exec(htmlStr); if (accountLockedRegResult) { From dd454dc7cd1b3e1abf9eb343003a9679264a78f0 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 2 Oct 2023 23:04:38 +0800 Subject: [PATCH 23/33] chore: remove unused codes --- package-lock.json | 831 +--------------------------------------- package.json | 4 +- src/common/CFClient.ts | 147 ------- src/common/DateUtils.ts | 6 - src/garmin/UrlClass.ts | 1 - src/garmin/Urls.ts | 125 ------ 6 files changed, 8 insertions(+), 1106 deletions(-) delete mode 100644 src/common/CFClient.ts delete mode 100644 src/common/DateUtils.ts delete mode 100644 src/garmin/Urls.ts diff --git a/package-lock.json b/package-lock.json index 2da7148..0d73de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,22 @@ { "name": "garmin-connect", - "version": "1.5.0", + "version": "1.6.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "garmin-connect", - "version": "1.5.0", + "version": "1.6.1", "license": "MIT", "dependencies": { "app-root-path": "^3.1.0", "axios": "^1.5.1", - "cloudscraper": "^4.6.0", "crypto": "^1.0.1", "form-data": "^4.0.0", "lodash": "^4.17.21", "luxon": "^3.4.3", "oauth-1.0a": "^2.2.6", - "qs": "^6.11.2", - "request": "^2.88.2" + "qs": "^6.11.2" }, "devDependencies": { "@types/app-root-path": "^1.2.4", @@ -123,21 +121,6 @@ "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", "dev": true }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -188,40 +171,11 @@ "node": ">=8" } }, - "node_modules/asn1": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", - "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "engines": { - "node": ">=0.8" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" - }, "node_modules/axios": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", @@ -238,39 +192,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peer": true - }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -281,15 +202,6 @@ "concat-map": "0.0.1" } }, - "node_modules/brotli": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", - "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", - "peer": true, - "dependencies": { - "base64-js": "^1.1.2" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -308,26 +220,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" - }, - "node_modules/cloudscraper": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cloudscraper/-/cloudscraper-4.6.0.tgz", - "integrity": "sha512-42g6atOAQwhoMlzCYsB1238RYEQa3ibcxhjVeYuZQDLGSZjBNAKOlF/2kcPwZUhlRKA9LDwuYQ7/0LCoMui2ww==", - "dependencies": { - "request-promise": "^4.2.4" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "brotli": "^1.3.2", - "request": "^2.88.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -381,7 +273,8 @@ "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true }, "node_modules/cross-spawn": { "version": "7.0.3", @@ -403,17 +296,6 @@ "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in." }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -422,15 +304,6 @@ "node": ">=0.4.0" } }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -463,29 +336,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "engines": [ - "node >=0.6.0" - ] - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, "node_modules/follow-redirects": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", @@ -505,14 +355,6 @@ } } }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "engines": { - "node": "*" - } - }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -559,35 +401,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dependencies": { - "assert-plus": "^1.0.0" - } - }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "engines": { - "node": ">=4" - } - }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", - "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -619,20 +432,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - }, - "engines": { - "node": ">=0.8", - "npm": ">=1.3.7" - } - }, "node_modules/human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -669,11 +468,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -686,45 +480,6 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" - }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" - }, - "node_modules/jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -826,14 +581,6 @@ "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "engines": { - "node": "*" - } - }, "node_modules/object-inspect": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", @@ -884,11 +631,6 @@ "node": ">=8" } }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" - }, "node_modules/pre-commit": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/pre-commit/-/pre-commit-1.2.2.tgz", @@ -1099,11 +841,6 @@ "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", "dev": true }, - "node_modules/psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" - }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -1114,14 +851,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", @@ -1157,114 +886,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, - "node_modules/request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/request-promise": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz", - "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==", - "deprecated": "request-promise has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142", - "dependencies": { - "bluebird": "^3.5.0", - "request-promise-core": "1.1.4", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "request": "^2.34" - } - }, - "node_modules/request-promise-core": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", - "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", - "dependencies": { - "lodash": "^4.17.19" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "request": "^2.34" - } - }, - "node_modules/request/node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 0.12" - } - }, - "node_modules/request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1316,38 +937,6 @@ "os-shim": "^0.1.2" } }, - "node_modules/sshpk": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", - "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -1384,34 +973,6 @@ "node": ">=8" } }, - "node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" - }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -1431,42 +992,12 @@ "node": ">=4.2.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, - "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "bin": { - "uuid": "bin/uuid" - } - }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1579,17 +1110,6 @@ "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", "dev": true }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1622,34 +1142,11 @@ "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", "dev": true }, - "asn1": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", - "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "requires": { - "safer-buffer": "~2.1.0" - } - }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" - }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" - }, - "aws4": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" - }, "axios": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", @@ -1666,25 +1163,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "peer": true - }, - "bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "requires": { - "tweetnacl": "^0.14.3" - } - }, - "bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" - }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1695,15 +1173,6 @@ "concat-map": "0.0.1" } }, - "brotli": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", - "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", - "peer": true, - "requires": { - "base64-js": "^1.1.2" - } - }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1719,19 +1188,6 @@ "get-intrinsic": "^1.0.2" } }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" - }, - "cloudscraper": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cloudscraper/-/cloudscraper-4.6.0.tgz", - "integrity": "sha512-42g6atOAQwhoMlzCYsB1238RYEQa3ibcxhjVeYuZQDLGSZjBNAKOlF/2kcPwZUhlRKA9LDwuYQ7/0LCoMui2ww==", - "requires": { - "request-promise": "^4.2.4" - } - }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1776,7 +1232,8 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true }, "cross-spawn": { "version": "7.0.3", @@ -1794,28 +1251,11 @@ "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==" }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "requires": { - "assert-plus": "^1.0.0" - } - }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, - "ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "requires": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -1842,36 +1282,11 @@ "strip-final-newline": "^2.0.0" } }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, "follow-redirects": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==" }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" - }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -1906,28 +1321,6 @@ "pump": "^3.0.0" } }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "requires": { - "assert-plus": "^1.0.0" - } - }, - "har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" - }, - "har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "requires": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - } - }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -1947,16 +1340,6 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" }, - "http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "requires": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - } - }, "human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -1981,11 +1364,6 @@ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -1998,42 +1376,6 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" - }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" - }, - "json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" - }, - "jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -2111,11 +1453,6 @@ "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" }, - "oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" - }, "object-inspect": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", @@ -2151,11 +1488,6 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, - "performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" - }, "pre-commit": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/pre-commit/-/pre-commit-1.2.2.tgz", @@ -2318,11 +1650,6 @@ "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", "dev": true }, - "psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" - }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -2333,11 +1660,6 @@ "once": "^1.3.1" } }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - }, "qs": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", @@ -2369,79 +1691,6 @@ } } }, - "request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "dependencies": { - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - }, - "qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==" - } - } - }, - "request-promise": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz", - "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==", - "requires": { - "bluebird": "^3.5.0", - "request-promise-core": "1.1.4", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - } - }, - "request-promise-core": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", - "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", - "requires": { - "lodash": "^4.17.19" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2483,27 +1732,6 @@ "os-shim": "^0.1.2" } }, - "sshpk": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", - "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - } - }, - "stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" - }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -2536,28 +1764,6 @@ "has-flag": "^4.0.0" } }, - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" - }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -2570,35 +1776,12 @@ "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", "dev": true }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "requires": { - "punycode": "^2.1.0" - } - }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" - }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 7f9ab5b..a68ecc4 100644 --- a/package.json +++ b/package.json @@ -55,14 +55,12 @@ "dependencies": { "app-root-path": "^3.1.0", "axios": "^1.5.1", - "cloudscraper": "^4.6.0", "crypto": "^1.0.1", "form-data": "^4.0.0", "lodash": "^4.17.21", "luxon": "^3.4.3", "oauth-1.0a": "^2.2.6", - "qs": "^6.11.2", - "request": "^2.88.2" + "qs": "^6.11.2" }, "pre-commit": "pretty" } diff --git a/src/common/CFClient.ts b/src/common/CFClient.ts deleted file mode 100644 index 2f36584..0000000 --- a/src/common/CFClient.ts +++ /dev/null @@ -1,147 +0,0 @@ -import cloudscraper, { Options, Response } from 'cloudscraper'; -import request, { Headers, CookieJar } from 'request'; -import { CookieJar as ToughCookieJar } from 'tough-cookie'; -import qs from 'qs'; -import fs from 'fs'; -import path from 'path'; - -const asJson = (body: string): T => { - try { - const jsonBody = JSON.parse(body); - return jsonBody as T; - } catch (e) { - // Do nothing - } - return body as T; -}; - -export default class CFClient { - private cookies: CookieJar & { _jar?: ToughCookieJar }; - private headers: Headers; - - constructor(headers: Headers) { - this.cookies = request.jar(); - this.headers = headers || {}; - } - - serializeCookies() { - return this.cookies._jar?.serializeSync(); - } - - importCookies(cookies: ToughCookieJar.Serialized) { - const deserialized = ToughCookieJar.deserializeSync(cookies); - this.cookies = request.jar(); - this.cookies._jar = deserialized; - } - - async scraper(options: Options): Promise { - return new Promise((resolve) => { - cloudscraper(options, (err, res) => { - resolve(res); - }); - }); - } - - /** - * @param {string} downloadDir - * @param {string} url - * @param {*} data - */ - async downloadBlob(downloadDir = '', url: string, data?: any) { - const queryData = qs.stringify(data); - const queryDataString = queryData ? `?${queryData}` : ''; - const options = { - method: 'GET', - jar: this.cookies, - uri: `${url}${queryDataString}`, - headers: this.headers, - encoding: null - } as Options; - return new Promise((resolve) => { - cloudscraper(options, async (err, response, body) => { - const { headers } = response || {}; - const { 'content-disposition': contentDisposition } = - headers || {}; - const downloadDirNormalized = path.normalize(downloadDir); - if (contentDisposition) { - const defaultName = `garmin_connect_download_${Date.now()}`; - const [, fileName = defaultName] = - contentDisposition.match(/filename="?([^"]+)"?/) || []; - const filePath = path.resolve( - downloadDirNormalized, - fileName - ); - fs.writeFileSync(filePath, body); - resolve(filePath); - } - }); - }); - } - - async get(url: string, data?: any) { - const queryData = qs.stringify(data); - const queryDataString = queryData ? `?${queryData}` : ''; - const options = { - method: 'GET', - jar: this.cookies, - uri: `${url}${queryDataString}`, - headers: this.headers - } as Options; - const { body } = await this.scraper(options); - return asJson(body); - } - - async post(url: string, data: any) { - const options = { - method: 'POST', - uri: url, - jar: this.cookies, - formData: data, - headers: this.headers - }; - const { body } = await this.scraper(options); - return asJson(body); - } - - async delete(url: string) { - const options = { - method: 'DELETE', - uri: url, - jar: this.cookies, - headers: this.headers - }; - const { body } = await this.scraper(options); - return asJson(body); - } - - async postJson(url: string, data: any, headers: Headers) { - const options = { - method: 'POST', - uri: url, - jar: this.cookies, - json: data, - headers: { - ...this.headers, - ...headers, - 'Content-Type': 'application/json' - } - }; - const { body } = await this.scraper(options); - return asJson(body); - } - - async putJson(url: string, data: any) { - const options = { - method: 'PUT', - uri: url, - jar: this.cookies, - json: data, - headers: { - ...this.headers, - 'Content-Type': 'application/json' - } - }; - const { body } = await this.scraper(options); - return asJson(body); - } -} diff --git a/src/common/DateUtils.ts b/src/common/DateUtils.ts deleted file mode 100644 index 2716d00..0000000 --- a/src/common/DateUtils.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function toDateString(date: Date) { - const offset = date.getTimezoneOffset(); - const offsetDate = new Date(date.getTime() - offset * 60 * 1000); - const [dateString] = offsetDate.toISOString().split('T'); - return dateString; -} diff --git a/src/garmin/UrlClass.ts b/src/garmin/UrlClass.ts index aa0c9ec..a5d05e3 100644 --- a/src/garmin/UrlClass.ts +++ b/src/garmin/UrlClass.ts @@ -1,4 +1,3 @@ -import { UPLOAD_SERVICE } from './Urls'; import { GarminDomain } from './types'; export class UrlClass { diff --git a/src/garmin/Urls.ts b/src/garmin/Urls.ts deleted file mode 100644 index 39de2aa..0000000 --- a/src/garmin/Urls.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { GCActivityId, GCBadgeId, GCUserHash, GCWorkoutId } from './types'; - -export const GC_MODERN = 'https://connect.garmin.com/modern'; -export const GARMIN_SSO_ORIGIN = 'https://sso.garmin.com'; -export const GARMIN_SSO = `${GARMIN_SSO_ORIGIN}/sso`; -export const BASE_URL = `${GC_MODERN}/proxy`; -export const SIGNIN_URL = `${GARMIN_SSO}/signin`; -export const LOGIN_URL = `${GARMIN_SSO}/login`; - -export const ACTIVITY_SERVICE = `${BASE_URL}/activity-service`; -export const ACTIVITYLIST_SERVICE = `${BASE_URL}/activitylist-service`; -export const BADGE_SERVICE = `${BASE_URL}/badge-service`; -export const CURRENT_USER_SERVICE = `${GC_MODERN}/currentuser-service/user/info`; -export const DEVICE_SERVICE = `${BASE_URL}/device-service`; -export const DOWNLOAD_SERVICE = `${BASE_URL}/download-service`; -export const USERPROFILE_SERVICE = `${BASE_URL}/userprofile-service`; -export const WELLNESS_SERVICE = `${BASE_URL}/wellness-service`; -export const WORKOUT_SERVICE = `${BASE_URL}/workout-service`; -export const UPLOAD_SERVICE = `${BASE_URL}/upload-service`; -export const GEAR_SERVICE = `${BASE_URL}/gear-service`; - -export const USER_SETTINGS = `${USERPROFILE_SERVICE}/userprofile/user-settings/`; - -export enum ExportFileType { - tcx = 'tcx', - gpx = 'gpx', - kml = 'kml', - zip = 'zip' -} - -export enum UploadFileType { - tcx = 'tcx', - gpx = 'gpx', - fit = 'fit' -} - -export const activity = (id: GCActivityId) => - `${ACTIVITY_SERVICE}/activity/${id}`; - -export const image = (id: GCActivityId) => - `${ACTIVITY_SERVICE}/activity/${id}/image`; - -export const imageDelete = (id: GCActivityId, imageId: string) => - `${ACTIVITY_SERVICE}/activity/${id}/image/${imageId}`; - -export const weather = (id: GCActivityId) => `${activity(id)}/weather`; - -export const activityDetails = (id: GCActivityId) => `${activity(id)}/details`; - -export const activities = () => - `${ACTIVITYLIST_SERVICE}/activities/search/activities`; - -export const badgesAvailable = () => `${BADGE_SERVICE}/badge/available`; - -export const badgeDetail = (id: GCBadgeId) => - `${BADGE_SERVICE}/badge/detail/v2/${id}`; - -export const badgesEarned = () => `${BADGE_SERVICE}/badge/earned`; - -export const dailyHeartRate = (userHash: GCUserHash) => - `${WELLNESS_SERVICE}/wellness/dailyHeartRate/${userHash}`; - -export const dailySleep = () => `${WELLNESS_SERVICE}/wellness/dailySleep`; - -export const dailySleepData = (userHash: GCUserHash) => - `${WELLNESS_SERVICE}/wellness/dailySleepData/${userHash}`; - -export const dailySummaryChart = (userHash: GCUserHash) => - `${WELLNESS_SERVICE}/wellness/dailySummaryChart/${userHash}`; - -export const deviceInfo = (userHash: GCUserHash) => - `${DEVICE_SERVICE}/deviceservice/device-info/all/${userHash}`; - -export const schedule = (id: GCActivityId) => - `${WORKOUT_SERVICE}/schedule/${id}`; - -export const userInfo = () => CURRENT_USER_SERVICE; - -export const socialProfile = (userHash: GCUserHash) => - `${USERPROFILE_SERVICE}/socialProfile/${userHash}`; - -export const userSettings = () => USER_SETTINGS; - -export const originalFile = (id: GCActivityId) => - `${DOWNLOAD_SERVICE}/files/activity/${id}`; - -/** - * - * @param id {string} - * @param type "tcx" | "gpx" | "kml" - * @return {`${string}/export/${string}/activity/${string}`} - */ -export const exportFile = (id: GCActivityId, type: ExportFileType) => - `${DOWNLOAD_SERVICE}/export/${type}/activity/${id}`; - -export const workout = (id?: GCWorkoutId) => { - if (id) { - return `${WORKOUT_SERVICE}/workout/${id}`; - } - return `${WORKOUT_SERVICE}/workout`; -}; - -export const workouts = () => `${WORKOUT_SERVICE}/workouts`; - -export const socialConnections = (userHash: GCUserHash) => - `${USERPROFILE_SERVICE}/socialProfile/connections/${userHash}`; - -export const newsFeed = () => - `${ACTIVITYLIST_SERVICE}/activities/subscriptionFeed`; - -export const upload = (format: UploadFileType) => - `${UPLOAD_SERVICE}/upload/${format}`; - -export const listGear = (userProfilePk: number, availableGearDate?: Date) => - `${GEAR_SERVICE}/gear/filterGear?userProfilePk=${userProfilePk}${ - availableGearDate - ? `&${availableGearDate.getFullYear()}-${availableGearDate.getMonth()}-${availableGearDate.getDay()}` - : '' - }`; - -export const linkGear = (activityId: GCActivityId, gearUuid: string) => - `${GEAR_SERVICE}/gear/link/${gearUuid}/activity/${activityId}`; - -export const unlinkGear = (activityId: GCActivityId, gearUuid: string) => - `${GEAR_SERVICE}/gear/unlink/${gearUuid}/activity/${activityId}`; From 1d224c20fd4f31a1fcb2c470ff891c38b97f162f Mon Sep 17 00:00:00 2001 From: gooin Date: Tue, 3 Oct 2023 13:32:17 +0800 Subject: [PATCH 24/33] fix: error handling --- src/common/HttpClient.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index 2c3d7e9..f955d01 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -49,7 +49,10 @@ export class HttpClient { const originalRequest = error.config; // console.log('originalRequest:', originalRequest) // Auto Refresh token - if (error.response.status === 401 && !originalRequest._retry) { + if ( + error?.response?.status === 401 && + !originalRequest?._retry + ) { if (!this.oauth2Token) { return; } From e96a6b952e1c24efdf9b9b2ebddc6ecfb3abd557 Mon Sep 17 00:00:00 2001 From: gooin Date: Tue, 3 Oct 2023 13:32:59 +0800 Subject: [PATCH 25/33] v1.6.2-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a68ecc4..0f40c63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "garmin-connect", - "version": "1.6.1", + "version": "1.6.2-rc.1", "description": "Makes it simple to interface with Garmin Connect to get or set any data point", "main": "./dist/index.js", "types": "./dist/index.d.ts", From 68898c8e187a6ce47be4a2dad471ece3c4476b9b Mon Sep 17 00:00:00 2001 From: gooin Date: Tue, 3 Oct 2023 13:56:57 +0800 Subject: [PATCH 26/33] fix: doc --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d4dc81f..afd2941 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ await GCClient.login(); const userProfile = await GCClient.getUserProfile(); ``` -Now you can check `userProfile.userName` (userName is your email address) to verify that your login was successful. +Now you can check `userProfile.userName` to verify that your login was successful. ## Reusing your session(since v1.6.0) From d9f1379527e932a91d7e28efed9f11a678e8ef85 Mon Sep 17 00:00:00 2001 From: gooin Date: Tue, 3 Oct 2023 23:24:51 +0800 Subject: [PATCH 27/33] feat: delete method --- src/common/HttpClient.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/common/HttpClient.ts b/src/common/HttpClient.ts index f955d01..69fab97 100644 --- a/src/common/HttpClient.ts +++ b/src/common/HttpClient.ts @@ -134,6 +134,17 @@ export class HttpClient { return response?.data; } + async delete(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.post(url, null, { + ...config, + headers: { + ...config?.headers, + 'X-Http-Method-Override': 'DELETE' + } + }); + return response?.data; + } + setCommonHeader(headers: RawAxiosRequestHeaders): void { _.each(headers, (headerValue, key) => { this.client.defaults.headers.common[key] = headerValue; From 901a70f2b217e85454d596baf9d640023a34aa34 Mon Sep 17 00:00:00 2001 From: gooin Date: Tue, 3 Oct 2023 23:25:29 +0800 Subject: [PATCH 28/33] feat: workouts get add delete --- src/garmin/GarminConnect.ts | 79 ++++++++++++++++++++--- src/garmin/UrlClass.ts | 11 +++- src/garmin/types.ts | 123 ++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 10 deletions(-) diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index 380c356..8dca99b 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -1,6 +1,7 @@ import appRoot from 'app-root-path'; import FormData from 'form-data'; +import _ from 'lodash'; import { DateTime } from 'luxon'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -19,10 +20,12 @@ import { IOauth2Token, ISocialProfile, IUserSettings, + IWorkout, + IWorkoutDetail, UploadFileType, UploadFileTypeTypeValue } from './types'; -import _ from 'lodash'; +import Running from './workouts/Running'; let config: GCCredentials | undefined = undefined; @@ -213,7 +216,7 @@ export default class GarminConnect { const fileBuffer = fs.createReadStream(file); const form = new FormData(); form.append('userfile', fileBuffer); - const response = this.client.post( + const response = await this.client.post( this.url.UPLOAD + '.' + format, form, { @@ -229,14 +232,72 @@ export default class GarminConnect { activityId: GCActivityId; }): Promise { if (!activity.activityId) throw new Error('Missing activityId'); - await this.client.post( - this.url.ACTIVITY + activity.activityId, - null, - { - headers: { - 'X-Http-Method-Override': 'DELETE' - } + await this.client.delete(this.url.ACTIVITY + activity.activityId); + } + + async getWorkouts(start: number, limit: number): Promise { + return this.client.get(this.url.WORKOUTS, { + params: { + start, + limit } + }); + } + async getWorkoutDetail(workout: { + workoutId: string; + }): Promise { + if (!workout.workoutId) throw new Error('Missing workoutId'); + return this.client.get( + this.url.WORKOUT(workout.workoutId) ); } + + async addWorkout( + workout: IWorkoutDetail | Running + ): Promise { + if (!workout) throw new Error('Missing workout'); + + if (workout instanceof Running) { + if (workout.isValid()) { + const data = { ...workout.toJson() }; + if (!data.description) { + data.description = 'Added by garmin-connect for Node.js'; + } + return this.client.post( + this.url.WORKOUT(), + data + ); + } + } + + const newWorkout = _.omit(workout, [ + 'workoutId', + 'ownerId', + 'updatedDate', + 'createdDate', + 'author' + ]); + if (!newWorkout.description) { + newWorkout.description = 'Added by garmin-connect for Node.js'; + } + // console.log('addWorkout - newWorkout:', newWorkout) + return this.client.post(this.url.WORKOUT(), newWorkout); + } + + async addRunningWorkout( + name: string, + meters: number, + description: string + ): Promise { + const running = new Running(); + running.name = name; + running.distance = meters; + running.description = description; + return this.addWorkout(running); + } + + async deleteWorkout(workout: { workoutId: string }) { + if (!workout.workoutId) throw new Error('Missing workout'); + return this.client.delete(this.url.WORKOUT(workout.workoutId)); + } } diff --git a/src/garmin/UrlClass.ts b/src/garmin/UrlClass.ts index a5d05e3..d5394b1 100644 --- a/src/garmin/UrlClass.ts +++ b/src/garmin/UrlClass.ts @@ -1,4 +1,4 @@ -import { GarminDomain } from './types'; +import { GCWorkoutId, GarminDomain } from './types'; export class UrlClass { private domain: GarminDomain; @@ -62,4 +62,13 @@ export class UrlClass { get IMPORT_DATA() { return `${this.GC_API}/modern/import-data`; } + WORKOUT(id?: GCWorkoutId) { + if (id) { + return `${this.GC_API}/workout-service/workout/${id}`; + } + return `${this.GC_API}/workout-service/workout`; + } + get WORKOUTS() { + return `${this.GC_API}/workout-service/workouts`; + } } diff --git a/src/garmin/types.ts b/src/garmin/types.ts index b9dfcc5..fb21097 100644 --- a/src/garmin/types.ts +++ b/src/garmin/types.ts @@ -727,3 +727,126 @@ export interface ICountActivities { all: Record; }; } + +// Workouts + +export interface IWorkout { + workoutId?: number; + ownerId?: number; + workoutName: string; + description?: string; + updateDate: Date; + createdDate: Date; + sportType: ISportType; + trainingPlanId: null; + author: IAuthor; + estimatedDurationInSecs: number; + estimatedDistanceInMeters: null; + estimateType: null; + estimatedDistanceUnit: IUnit; + poolLength: number; + poolLengthUnit: IUnit; + workoutProvider: string; + workoutSourceId: string; + consumer: null; + atpPlanId: null; + workoutNameI18nKey: null; + descriptionI18nKey: null; + shared: boolean; + estimated: boolean; +} + +export interface IWorkoutDetail extends IWorkout { + workoutSegments: IWorkoutSegment[]; +} +export interface IAuthor { + userProfilePk: null; + displayName: null; + fullName: null; + profileImgNameLarge: null; + profileImgNameMedium: null; + profileImgNameSmall: null; + userPro: boolean; + vivokidUser: boolean; +} + +export interface IUnit { + unitId: null; + unitKey: null; + factor: null; +} + +export interface ISportType { + sportTypeId: number; + sportTypeKey: string; + displayOrder?: number; +} + +export interface IWorkoutSegment { + segmentOrder: number; + sportType: ISportType; + workoutSteps: IWorkoutStep[]; +} + +export interface IWorkoutStep { + type: string; + stepId: number; + stepOrder: number; + stepType: IStepType; + childStepId: null; + description: null; + endCondition: IEndCondition; + endConditionValue: number | null; + preferredEndConditionUnit: IUnit | null; + endConditionCompare: null; + targetType: ITargetType; + targetValueOne: null; + targetValueTwo: null; + targetValueUnit: null; + zoneNumber: null; + secondaryTargetType: null; + secondaryTargetValueOne: null; + secondaryTargetValueTwo: null; + secondaryTargetValueUnit: null; + secondaryZoneNumber: null; + endConditionZone: null; + strokeType: IStrokeType; + equipmentType: IEquipmentType; + category: null; + exerciseName: null; + workoutProvider: null; + providerExerciseSourceId: null; + weightValue: null; + weightUnit: null; +} + +export interface IEndCondition { + conditionTypeId: number; + conditionTypeKey: string; + displayOrder: number; + displayable: boolean; +} + +export interface IEquipmentType { + equipmentTypeId: number; + equipmentTypeKey: null; + displayOrder: number; +} + +export interface IStepType { + stepTypeId: number; + stepTypeKey: string; + displayOrder: number; +} + +export interface IStrokeType { + strokeTypeId: number; + strokeTypeKey: null; + displayOrder: number; +} + +export interface ITargetType { + workoutTargetTypeId: number; + workoutTargetTypeKey: string; + displayOrder: number; +} From f6a8e186f9998f20d0e04ece3a257f52e5895ada Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 9 Oct 2023 17:22:05 +0800 Subject: [PATCH 29/33] feat: make old env happy --- tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index bb24908..7839251 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,12 +2,12 @@ "compilerOptions": { "module": "CommonJS", "moduleResolution": "Node", - "target": "ES2018", + "target": "ES5", "sourceMap": true, "outDir": "dist", "allowJs": true, "esModuleInterop": true, - "lib": ["ES2018"], + "lib": ["ES5"], "resolveJsonModule": true, "noImplicitAny": true, "strictFunctionTypes": true, From fbfc156fb812b8a1b000ba9e8a861fa4216a4e87 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 9 Oct 2023 17:22:15 +0800 Subject: [PATCH 30/33] v1.6.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0f40c63..fdabadd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "garmin-connect", - "version": "1.6.2-rc.1", + "version": "1.6.3", "description": "Makes it simple to interface with Garmin Connect to get or set any data point", "main": "./dist/index.js", "types": "./dist/index.d.ts", From d78bb981f1e6b2ca70d14e66699cee8556c26ff3 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 9 Oct 2023 18:03:26 +0800 Subject: [PATCH 31/33] fix: make old env happy --- src/garmin/GarminConnect.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/garmin/GarminConnect.ts b/src/garmin/GarminConnect.ts index 8dca99b..4bb5190 100644 --- a/src/garmin/GarminConnect.ts +++ b/src/garmin/GarminConnect.ts @@ -3,8 +3,8 @@ import appRoot from 'app-root-path'; import FormData from 'form-data'; import _ from 'lodash'; import { DateTime } from 'luxon'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; +import * as fs from 'fs'; +import * as path from 'path'; import { HttpClient } from '../common/HttpClient'; import { checkIsDirectory, createDirectory, writeToFile } from '../utils'; import { UrlClass } from './UrlClass'; From 5ecbe287e99f9cc8d69614ed7da6c0966557afc4 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 9 Oct 2023 18:13:49 +0800 Subject: [PATCH 32/33] fix: make old evn happy --- src/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index bbc7ba4..6597fcc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,4 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; +import * as fs from 'fs'; export const checkIsDirectory = (filePath: string): boolean => { return fs.existsSync(filePath) && fs.lstatSync(filePath).isDirectory(); From 8f17778edcec4f91dd212c4557ddaad329933242 Mon Sep 17 00:00:00 2001 From: gooin Date: Mon, 9 Oct 2023 18:14:03 +0800 Subject: [PATCH 33/33] v1.6.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fdabadd..25dbd20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "garmin-connect", - "version": "1.6.3", + "version": "1.6.5", "description": "Makes it simple to interface with Garmin Connect to get or set any data point", "main": "./dist/index.js", "types": "./dist/index.d.ts",