git-fit 0.10.10 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4cd7d43e318f96dcab83874635c24e7ead0ec772f550f2fdcd9ea7c81a6a44b
4
- data.tar.gz: e106683db19d232870990bcbc59bcb35f59be4fec8c33d1141ec858632446522
3
+ metadata.gz: 49c616974f43c0eef2a07460e664450cffb887629d63b0e7d9f64e9da73dec09
4
+ data.tar.gz: 8924a27a358440f1f1a2bd1b233d6a11589d5aca0eb9c68be63bd31679e5e9af
5
5
  SHA512:
6
- metadata.gz: da0ac336bbe16839be618bb0ddbcc6db7513630231b89cd690249568908d0fbe0f20110ec2904d589e18b3bb2ff14431e7138b846b9c3dd0ed65b4018f033e29
7
- data.tar.gz: 101b8bc30d222858e2b61a592027ae701fb4563fdfeb832688929ba01cd7c87588411023cec5057b8af8fe7f81a15c18de62a3b25a2b97577a3f11496de3b672
6
+ metadata.gz: 42ea6e2aaef24cfb9f3aecf136ac365542bca0ce9824ee0bbba2b1e970c28a1f7a3db201be81cd3717a3bc85283c89274051e6ecef7d83df89105599e83a588b
7
+ data.tar.gz: 30f1c4f88ac926e4581159e7a76ed92e0fc88ef764e9145d5901efacbab53baf387bbe7aad2864afdd46ecaa5d548043b1cd9e61ed2ed8009bf169d9d70145f8
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../sync/garmin_base'
4
+ require 'json'
5
+ require 'fileutils'
6
+
7
+ module GitFit
8
+ module Auth
9
+ module Garmin
10
+ # Raised when MFA is required and no code is available. The login session
11
+ # (pre-auth cookie jar / storage_state) has already been persisted so the
12
+ # user can resume with `--mfa-code` without re-triggering MFA.
13
+ class MfaRequired < GitFit::Sync::AuthError; end
14
+
15
+ # Persists the pre-auth login session created during an interrupted MFA
16
+ # flow. This is a *temporary* session (30-minute TTL matching the MFA code
17
+ # validity window), distinct from the long-lived persistent session cookie
18
+ # (garmin_playwright_session_*.json) that Strategy B keeps after success.
19
+ module LoginSession
20
+ DIR = 'data/cache'
21
+ TTL_SECONDS = 30 * 60
22
+
23
+ module_function
24
+
25
+ def save(domain, state)
26
+ FileUtils.mkdir_p(DIR)
27
+ state['saved_at'] = Time.now.to_f
28
+ File.write(path(domain), JSON.generate(state))
29
+ end
30
+
31
+ def load(domain)
32
+ p = path(domain)
33
+ return nil unless File.exist?(p)
34
+
35
+ state = JSON.parse(File.read(p))
36
+ return nil if (Time.now.to_f - state['saved_at'].to_f) > TTL_SECONDS
37
+
38
+ state
39
+ rescue JSON::ParserError
40
+ nil
41
+ end
42
+
43
+ def clear(domain)
44
+ File.delete(path(domain)) if File.exist?(path(domain))
45
+ end
46
+
47
+ def path(domain)
48
+ File.join(DIR, "garmin_mfa_login_#{domain.tr('.', '_')}.json")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'di_exchange'
4
+ require_relative 'login_session'
4
5
  require_relative '../../sync/garmin_base'
5
6
  require 'open3'
6
7
  require 'tmpdir'
@@ -43,9 +44,16 @@ module GitFit
43
44
  def call(email:, password:, domain: 'garmin.com', mfa_code: nil)
44
45
  check_curl_available!
45
46
 
47
+ code = mfa_code.to_s.strip
48
+ if !code.empty? && (saved = LoginSession.load(domain))
49
+ return resume_login(domain, code, saved)
50
+ end
51
+
46
52
  errors = []
47
53
  begin
48
54
  return finish(mobile_login(domain, email, password, mfa_code), domain)
55
+ rescue MfaRequired
56
+ raise
49
57
  rescue GitFit::Sync::AuthError => e
50
58
  errors << "mobile+cffi: #{e.message}"
51
59
  warn "[garmin_auth] #{errors.last}"
@@ -56,6 +64,8 @@ module GitFit
56
64
 
57
65
  begin
58
66
  return finish(portal_login(domain, email, password, mfa_code), domain)
67
+ rescue MfaRequired
68
+ raise
59
69
  rescue GitFit::Sync::AuthError => e
60
70
  errors << "portal+cffi: #{e.message}"
61
71
  warn "[garmin_auth] #{errors.last}"
@@ -76,11 +86,11 @@ module GitFit
76
86
  def mobile_login(domain, email, password, mfa_code)
77
87
  sso = "https://sso.#{domain}"
78
88
  with_session_files do |jar, body|
79
- mobile_login_session(sso, email, password, mfa_code, jar, body)
89
+ mobile_login_session(sso, domain, email, password, mfa_code, jar, body)
80
90
  end
81
91
  end
82
92
 
83
- def mobile_login_session(sso, email, password, mfa_code, jar, body)
93
+ def mobile_login_session(sso, domain, email, password, mfa_code, jar, body)
84
94
  random_delay('mobile+cffi')
85
95
  signin_url = "#{sso}/mobile/sso/en_US/sign-in"
86
96
  get_params = { clientId: MOBILE_CLIENT_ID, service: MOBILE_SERVICE }
@@ -130,7 +140,8 @@ module GitFit
130
140
  if resp_type == 'MFA_REQUIRED'
131
141
  method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
132
142
  res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
133
- impersonate: MOBILE_FINGERPRINT, jar: jar, output: body, email: email)
143
+ impersonate: MOBILE_FINGERPRINT, jar: jar, output: body,
144
+ email: email, domain: domain, service_url: MOBILE_SERVICE)
134
145
  resp_type = res.dig('responseStatus', 'type')
135
146
  end
136
147
 
@@ -146,19 +157,19 @@ module GitFit
146
157
  def portal_login(domain, email, password, mfa_code)
147
158
  sso = "https://sso.#{domain}"
148
159
  TLS_FINGERPRINTS.each do |imp|
149
- result = attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
160
+ result = attempt_portal_fingerprint(sso, domain, imp, email, password, mfa_code)
150
161
  return result if result
151
162
  end
152
163
  raise GitFit::Sync::AuthError, 'portal+cffi: all TLS fingerprints exhausted'
153
164
  end
154
165
 
155
- def attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
166
+ def attempt_portal_fingerprint(sso, domain, imp, email, password, mfa_code)
156
167
  with_session_files do |jar, body|
157
- attempt_portal_in_session(sso, imp, email, password, mfa_code, jar, body)
168
+ attempt_portal_in_session(sso, domain, imp, email, password, mfa_code, jar, body)
158
169
  end
159
170
  end
160
171
 
161
- def attempt_portal_in_session(sso, imp, email, password, mfa_code, jar, body)
172
+ def attempt_portal_in_session(sso, domain, imp, email, password, mfa_code, jar, body)
162
173
  random_delay("portal+cffi/#{imp}")
163
174
  signin_url = "#{sso}/portal/sso/en-US/sign-in"
164
175
  get_params = { clientId: PORTAL_CLIENT_ID, service: PORTAL_SERVICE }
@@ -216,7 +227,8 @@ module GitFit
216
227
  if resp_type == 'MFA_REQUIRED'
217
228
  method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
218
229
  res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
219
- impersonate: imp, jar: jar, output: body, email: email)
230
+ impersonate: imp, jar: jar, output: body,
231
+ email: email, domain: domain, service_url: PORTAL_SERVICE)
220
232
  resp_type = res.dig('responseStatus', 'type')
221
233
  end
222
234
 
@@ -231,8 +243,17 @@ module GitFit
231
243
  nil
232
244
  end
233
245
 
234
- def handle_mfa(sso, method, login_params, post_headers, mfa_code, impersonate:, jar:, output:, email:)
246
+ def handle_mfa(sso, method, login_params, post_headers, mfa_code,
247
+ impersonate:, jar:, output:, email:, domain:, service_url:)
235
248
  code = resolve_mfa_code(mfa_code, email)
249
+ if code.nil?
250
+ save_login_session(domain, sso, method, login_params, post_headers, impersonate, jar, service_url)
251
+ raise MfaRequired, mfa_required_message(email)
252
+ end
253
+ verify_mfa(sso, method, login_params, post_headers, code, impersonate:, jar:, output:)
254
+ end
255
+
256
+ def verify_mfa(sso, method, login_params, post_headers, code, impersonate:, jar:, output:)
236
257
  mfa_data = {
237
258
  'mfaMethod' => method,
238
259
  'mfaVerificationCode' => code,
@@ -267,6 +288,30 @@ module GitFit
267
288
  raise GitFit::Sync::AuthError, 'MFA verification failed on all endpoints'
268
289
  end
269
290
 
291
+ def save_login_session(domain, sso, method, login_params, post_headers, impersonate, jar, service_url)
292
+ LoginSession.save(domain, {
293
+ 'strategy' => 'A',
294
+ 'sso' => sso,
295
+ 'method' => method,
296
+ 'login_params' => login_params,
297
+ 'post_headers' => post_headers,
298
+ 'impersonate' => impersonate,
299
+ 'jar' => File.read(jar),
300
+ 'service_url' => service_url,
301
+ })
302
+ end
303
+
304
+ def resume_login(domain, mfa_code, saved)
305
+ with_session_files do |jar, body|
306
+ File.write(jar, saved['jar'])
307
+ res = verify_mfa(saved['sso'], saved['method'], saved['login_params'],
308
+ saved['post_headers'], mfa_code,
309
+ impersonate: saved['impersonate'], jar: jar, output: body)
310
+ LoginSession.clear(domain)
311
+ finish([res['serviceTicketId'], saved['service_url']], domain)
312
+ end
313
+ end
314
+
270
315
  def resolve_mfa_code(mfa_code, email)
271
316
  code = mfa_code.to_s.strip
272
317
  if code.empty?
@@ -274,15 +319,16 @@ module GitFit
274
319
  $stderr.write('Enter the 6-digit code: ') if $stdin.tty?
275
320
  code = $stdin.gets.to_s.strip
276
321
  end
277
- return code unless code.empty?
278
-
279
- raise GitFit::Sync::AuthError,
280
- "MFA required — check your email (#{email}) for the code.\n" \
281
- " This code is valid for a few minutes.\n\n" \
282
- " To continue, choose one:\n" \
283
- " echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
284
- " git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
285
- " If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
322
+ code.empty? ? nil : code
323
+ end
324
+
325
+ def mfa_required_message(email)
326
+ "MFA required check your email (#{email}) for the code.\n" \
327
+ " This code is valid for a few minutes.\n\n" \
328
+ " To continue, choose one:\n" \
329
+ " echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
330
+ " git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
331
+ " If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
286
332
  end
287
333
 
288
334
  def check_curl_available!
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'di_exchange'
4
+ require_relative 'login_session'
4
5
  require_relative '../../sync/garmin_base'
5
6
  require 'json'
6
7
  require 'fileutils'
@@ -29,6 +30,10 @@ module GitFit
29
30
  session_path = _session_file_path(domain)
30
31
  chrome_path = _find_chrome
31
32
 
33
+ if !mfa_code.empty? && (saved = LoginSession.load(domain))
34
+ return resume_login(domain, mfa_code, saved, chrome_path)
35
+ end
36
+
32
37
  _launch_playwright(chrome_path) do |browser|
33
38
  session = _load_session(session_path)
34
39
  warn "loaded session cookies (#{session[:age_days].round} days old)" if session
@@ -38,7 +43,7 @@ module GitFit
38
43
  context = browser.new_context(**opts)
39
44
 
40
45
  begin
41
- ticket, service_url = _mobile_api_login(context.request, domain, email, password, mfa_code, sso)
46
+ ticket, service_url = _mobile_api_login(context, domain, email, password, mfa_code, sso)
42
47
  token = DIExchange.call(domain, ticket, service_url)
43
48
  _save_session(context, session_path)
44
49
  token
@@ -48,7 +53,23 @@ module GitFit
48
53
  end
49
54
  end
50
55
 
51
- def _mobile_api_login(context, _domain, email, password, mfa_code, sso)
56
+ def resume_login(domain, mfa_code, saved, chrome_path)
57
+ _launch_playwright(chrome_path) do |browser|
58
+ context = browser.new_context(storageState: saved['storage_state'])
59
+ begin
60
+ ticket, = verify_mfa(context, saved['sso'], saved['method'], mfa_code)
61
+ token = DIExchange.call(domain, ticket, MOBILE_SERVICE)
62
+ _save_session(context, _session_file_path(domain))
63
+ LoginSession.clear(domain)
64
+ token
65
+ ensure
66
+ context.close
67
+ end
68
+ end
69
+ end
70
+
71
+ def _mobile_api_login(context, domain, email, password, mfa_code, sso)
72
+ req = context.request
52
73
  headers = {
53
74
  'User-Agent' => MOBILE_UA,
54
75
  'Accept' => 'application/json, text/plain, */*',
@@ -59,7 +80,7 @@ module GitFit
59
80
  params = { 'clientId' => MOBILE_CLIENT_ID, 'locale' => 'en-US', 'service' => MOBILE_SERVICE }
60
81
  referer = "#{sso}/mobile/sso/en_US/sign-in?clientId=#{MOBILE_CLIENT_ID}&service=#{MOBILE_SERVICE}"
61
82
 
62
- signin_resp = context.get(
83
+ signin_resp = req.get(
63
84
  "#{sso}/mobile/sso/en_US/sign-in",
64
85
  params: { 'clientId' => MOBILE_CLIENT_ID, 'service' => MOBILE_SERVICE },
65
86
  headers: { 'User-Agent' => MOBILE_UA, 'Accept' => 'text/html,...', 'Accept-Language' => 'en-US,en;q=0.9' },
@@ -78,7 +99,7 @@ module GitFit
78
99
 
79
100
  _random_delay('mobile')
80
101
  warn 'posting login credentials...'
81
- resp = context.post(
102
+ resp = req.post(
82
103
  "#{sso}/mobile/api/login",
83
104
  params: params,
84
105
  headers: headers.merge('Referer' => referer),
@@ -103,8 +124,30 @@ module GitFit
103
124
 
104
125
  mfa_method = data.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
105
126
  code = resolve_mfa_code(email, mfa_code)
127
+ if code.nil?
128
+ LoginSession.save(domain, {
129
+ 'strategy' => 'B',
130
+ 'sso' => sso,
131
+ 'method' => mfa_method,
132
+ 'storage_state' => context.storage_state,
133
+ })
134
+ raise MfaRequired, mfa_required_message(email)
135
+ end
106
136
 
137
+ verify_mfa(context, sso, mfa_method, code)
138
+ end
139
+
140
+ def verify_mfa(context, sso, mfa_method, code)
107
141
  warn 'verifying MFA code...'
142
+ headers = {
143
+ 'User-Agent' => MOBILE_UA,
144
+ 'Accept' => 'application/json, text/plain, */*',
145
+ 'Content-Type' => 'application/json',
146
+ 'Origin' => sso,
147
+ 'Accept-Language' => 'en-US,en;q=0.9',
148
+ }
149
+ params = { 'clientId' => MOBILE_CLIENT_ID, 'locale' => 'en-US', 'service' => MOBILE_SERVICE }
150
+ referer = "#{sso}/mobile/sso/en_US/sign-in?clientId=#{MOBILE_CLIENT_ID}&service=#{MOBILE_SERVICE}"
108
151
  mfa_data = {
109
152
  'mfaMethod' => mfa_method,
110
153
  'mfaVerificationCode' => code,
@@ -113,7 +156,7 @@ module GitFit
113
156
  'mfaSetup' => false,
114
157
  }
115
158
 
116
- resp = context.post(
159
+ resp = context.request.post(
117
160
  "#{sso}/mobile/api/mfa/verifyCode",
118
161
  params: params,
119
162
  headers: headers.merge('Referer' => referer),
@@ -143,15 +186,16 @@ module GitFit
143
186
  $stderr.write('Enter the 6-digit code: ') if $stdin.tty?
144
187
  code = $stdin.gets.to_s.strip
145
188
  end
146
- return code unless code.empty?
189
+ code.empty? ? nil : code
190
+ end
147
191
 
148
- raise GitFit::Sync::AuthError,
149
- "MFA required — check your email (#{email}) for the code.\n" \
150
- " This code is valid for a few minutes.\n\n" \
151
- " To continue, choose one:\n" \
152
- " echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
153
- " git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
154
- " If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
192
+ def mfa_required_message(email)
193
+ "MFA required — check your email (#{email}) for the code.\n" \
194
+ " This code is valid for a few minutes.\n\n" \
195
+ " To continue, choose one:\n" \
196
+ " echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
197
+ " git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
198
+ " If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
155
199
  end
156
200
 
157
201
  def _random_delay(label)
@@ -3,6 +3,7 @@
3
3
  require_relative 'garmin/strategy_a'
4
4
  require_relative 'garmin/strategy_b'
5
5
  require_relative 'garmin/di_exchange'
6
+ require_relative 'garmin/login_session'
6
7
  require_relative '../config'
7
8
  require_relative '../cli/gh_cli'
8
9
  require 'json'
@@ -48,10 +49,10 @@ module GitFit
48
49
  if chosen.size > 1
49
50
  raise GitFit::Sync::AuthError,
50
51
  'git fit auth garmin: -A/-B/--auto/--sync are mutually exclusive — ' \
51
- 'use exactly one (default: -B)'
52
+ 'use exactly one (default: --auto)'
52
53
  end
53
54
 
54
- chosen.first || :B
55
+ chosen.first || :auto
55
56
  end
56
57
 
57
58
  def flag_enabled?(flag)
@@ -88,7 +89,7 @@ module GitFit
88
89
 
89
90
  def single_strategy(label, creds)
90
91
  token, error = run_strategy(label, creds)
91
- exit_with_error("Garmin auth failed: #{error}") unless token
92
+ exit_with_error("Garmin auth failed: #{error.message}") unless token
92
93
 
93
94
  token
94
95
  end
@@ -97,22 +98,35 @@ module GitFit
97
98
  token, b_error = run_strategy(:B, creds)
98
99
  return token if token
99
100
 
101
+ if b_error.is_a?(MfaRequired)
102
+ # B triggered MFA and saved the login session. Falling back to A
103
+ # would re-trigger a second MFA — stop and ask the user for the code.
104
+ exit_with_error(b_error.message)
105
+ end
106
+
100
107
  warn '[auto] B failed, falling back to A'
101
108
  token, a_error = run_strategy(:A, creds)
102
109
  return token if token
103
110
 
104
- exit_with_error("Garmin auth failed: B: #{b_error}; A: #{a_error}")
111
+ exit_with_error(a_error.message) if a_error.is_a?(MfaRequired)
112
+
113
+ exit_with_error("Garmin auth failed: B: #{b_error.message}; A: #{a_error.message}")
105
114
  end
106
115
 
107
116
  def run_strategy(label, creds)
108
117
  token = STRATEGIES.fetch(label).call(**creds)
109
- return [token, nil] if json_round_trips?(token)
110
-
111
- [nil, "Strategy #{label} returned a token that fails JSON round-trip"]
118
+ return [token, nil] if token && json_round_trips?(token)
119
+
120
+ message = if token.nil?
121
+ "Strategy #{label} failed (no token returned)"
122
+ else
123
+ "Strategy #{label} returned a token that fails JSON round-trip"
124
+ end
125
+ [nil, RuntimeError.new(message)]
112
126
  rescue GitFit::Sync::AuthError => e
113
- [nil, e.message]
127
+ [nil, e]
114
128
  rescue StandardError => e
115
- [nil, "unexpected error: #{e.message}"]
129
+ [nil, e]
116
130
  end
117
131
 
118
132
  def json_round_trips?(token)
data/lib/git_fit/cli.rb CHANGED
@@ -74,8 +74,8 @@ module GitFit
74
74
 
75
75
  desc 'auth SOURCE', 'Authenticate with a sync source (strava, garmin, garmin_cn)'
76
76
  option :A, type: :boolean, desc: 'Garmin Strategy A: curl-impersonate shellout'
77
- option :B, type: :boolean, desc: 'Garmin Strategy B: Playwright (default)'
78
- option :auto, type: :boolean, desc: 'Garmin auto: B first, fallback to A'
77
+ option :B, type: :boolean, desc: 'Garmin Strategy B: Playwright'
78
+ option :auto, type: :boolean, desc: 'Garmin auto: B first, fallback to A (default)'
79
79
  option :sync, type: :boolean, desc: 'Garmin: promote cached token to auth_seed'
80
80
  option :mfa_code, type: :string, desc: 'Garmin MFA code (from email), re-run to continue'
81
81
  def auth(source)
@@ -48,6 +48,8 @@ module GitFit
48
48
  # xingzhe: # env: GIT_FIT_XINGZHE_EMAIL
49
49
  # email: "" # env: GIT_FIT_XINGZHE_PASSWORD
50
50
  # password: ""
51
+ # session_path: "" # env: GIT_FIT_XINGZHE_SESSION_PATH
52
+ # # default data/cache/xingzhe_session.json
51
53
 
52
54
  export:
53
55
  json:
@@ -22,14 +22,17 @@ module GitFit
22
22
 
23
23
  RAW_EXT = 'json'
24
24
 
25
+ SESSION_TTL = 30 * 24 * 60 * 60
26
+
25
27
  register_adapter
26
28
  config_key 'xingzhe'
27
- register_config :email, :password
29
+ register_config :email, :password, :session_path
28
30
 
29
31
  def initialize(...)
30
32
  super
31
33
  @email = @config['email']
32
34
  @password = @config['password']
35
+ @session_path = @config['session_path'] || File.join('data', 'cache', 'xingzhe_session.json')
33
36
  end
34
37
 
35
38
  def before_call
@@ -38,9 +41,15 @@ module GitFit
38
41
  end
39
42
 
40
43
  def authenticate
41
- result = login
42
- @auth_method = 'login' if result
43
- result
44
+ if load_session && session_valid?
45
+ @auth_method = 'cached'
46
+ return true
47
+ end
48
+ if login
49
+ @auth_method = 'login'
50
+ return true
51
+ end
52
+ false
44
53
  end
45
54
 
46
55
  def pending_ids
@@ -67,6 +76,14 @@ module GitFit
67
76
  process_activity(pending[:summary], pending[:type])
68
77
  end
69
78
 
79
+ def auth_error?
80
+ [302, 401, 403].include?(@last_http_code)
81
+ end
82
+
83
+ def last_auth_code
84
+ @last_http_code
85
+ end
86
+
70
87
  private
71
88
 
72
89
  def http_request(method, path, body: nil, params: nil, headers: {})
@@ -85,7 +102,26 @@ module GitFit
85
102
  http.use_ssl = true
86
103
  http.open_timeout = 30
87
104
  http.read_timeout = 60
88
- http.start { |h| h.request(req) }
105
+ resp = http.start { |h| h.request(req) }
106
+ @last_http_code = resp.code.to_i
107
+ resp
108
+ end
109
+
110
+ def authed_get(path, params: nil)
111
+ resp = http_request(:get, path, params: params, headers: @headers || {})
112
+ return resp unless auth_failure?(resp)
113
+
114
+ remove_stale_session
115
+ return resp unless login
116
+
117
+ http_request(:get, path, params: params, headers: @headers)
118
+ end
119
+
120
+ def auth_failure?(resp)
121
+ return false unless resp
122
+ c = resp.code.to_i
123
+ (c == 400 && resp.body.to_s.include?('not allowed')) ||
124
+ [302, 401, 403].include?(c)
89
125
  end
90
126
 
91
127
  def login
@@ -102,10 +138,57 @@ module GitFit
102
138
  cookie = extract_cookie(resp)
103
139
  return false unless cookie
104
140
 
141
+ @session_expires_at = parse_max_age(resp)
142
+ @headers = { 'Cookie' => cookie, 'Accept' => 'application/json' }
143
+ save_session(cookie)
144
+ true
145
+ end
146
+
147
+ def load_session
148
+ return false unless File.exist?(@session_path)
149
+
150
+ data = JSON.parse(File.read(@session_path))
151
+ cookie = data['cookie']
152
+ @session_expires_at = data['expires_at']
153
+ return false unless cookie
154
+
105
155
  @headers = { 'Cookie' => cookie, 'Accept' => 'application/json' }
106
156
  true
157
+ rescue StandardError => e
158
+ warn "XingZhe: failed to load cached session: #{e.message}"
159
+ remove_stale_session
160
+ false
161
+ end
162
+
163
+ def session_valid?
164
+ return false unless @session_expires_at
165
+ Time.now.to_i < @session_expires_at - 60
166
+ end
167
+
168
+ def save_session(cookie)
169
+ FileUtils.mkdir_p(File.dirname(@session_path))
170
+ File.write(@session_path, JSON.generate({
171
+ cookie: cookie,
172
+ expires_at: @session_expires_at,
173
+ }))
174
+ rescue StandardError => e
175
+ warn "XingZhe: failed to save session: #{e.message}"
176
+ end
177
+
178
+ def remove_stale_session
179
+ File.delete(@session_path) if File.exist?(@session_path)
180
+ rescue StandardError => e
181
+ warn "XingZhe: failed to remove stale session: #{e.message}"
182
+ end
183
+
184
+ def parse_max_age(resp)
185
+ set_cookie = resp['set-cookie'].to_s
186
+ ma = set_cookie[/Max-Age=(\d+)/, 1]
187
+ ma ? Time.now.to_i + ma.to_i : Time.now.to_i + SESSION_TTL
107
188
  end
108
189
 
190
+ attr_reader :session_path
191
+
109
192
  def rsa_encrypt(password)
110
193
  pem = "-----BEGIN PUBLIC KEY-----\n#{RSA_PUBKEY}\n-----END PUBLIC KEY-----"
111
194
  key = OpenSSL::PKey::RSA.new(pem)
@@ -124,9 +207,8 @@ module GitFit
124
207
  end
125
208
 
126
209
  def fetch_page(offset, limit)
127
- resp = http_request(:get, WORKOUTS_PATH,
210
+ resp = authed_get(WORKOUTS_PATH,
128
211
  params: { offset: offset, limit: limit },
129
- headers: @headers
130
212
  )
131
213
  return nil unless resp.code.to_i == 200
132
214
 
@@ -202,7 +284,7 @@ module GitFit
202
284
  end
203
285
 
204
286
  def fetch_stream(id)
205
- resp = http_request(:get, "#{STREAM_PATH}#{id}/stream/", headers: @headers || {})
287
+ resp = authed_get("#{STREAM_PATH}#{id}/stream/")
206
288
  return nil unless resp.code.to_i == 200
207
289
 
208
290
  JSON.parse(resp.body).dig('data')
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.10.10'
4
+ VERSION = '0.11.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-fit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.10
4
+ version: 0.11.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -248,6 +248,7 @@ files:
248
248
  - lib/git-fit.rb
249
249
  - lib/git_fit/auth/garmin.rb
250
250
  - lib/git_fit/auth/garmin/di_exchange.rb
251
+ - lib/git_fit/auth/garmin/login_session.rb
251
252
  - lib/git_fit/auth/garmin/strategy_a.rb
252
253
  - lib/git_fit/auth/garmin/strategy_b.rb
253
254
  - lib/git_fit/auth/garmin_token.rb