tempest-rb 0.1.0 → 0.1.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: c28613c22b61422cea99a83eb372bca8a1c1404c51f5d491846380bdd33d5196
4
- data.tar.gz: efbfee28e847621e49afdc75da6d3ada58513dc16c29eaa29c1ef99753cb1336
3
+ metadata.gz: 425f1a89085c0d2d762d9ef2697d0f34b752e7bfb479ff4dec077678987fa378
4
+ data.tar.gz: e61021b6f27a4e038f8b1b4d51697c169f5c90b99f7dc4ea0cdcfffacde4ba65
5
5
  SHA512:
6
- metadata.gz: f1670a1a245c27a1177efca4ee74fc23f83d625b40fbabe368126d82d25c5866768800adabe2c16a38094b7abc03ee36138a5d72c51a83b39f0dfa0b019362b3
7
- data.tar.gz: 5bdb09991abbf954111f27bce93b8d6d22900442f910f70994e97130cb657a52fd083005c889d4aa1bc15a0c35617e6c480363e9bfd63f1ffc0ef82646e8cfc8
6
+ metadata.gz: d3058fd2a2457246b63225ad6f4d46ac85e90e791dc0f364da1bc37ee1ab6b4ecb185aca488cd2f83c59e79c168d21e65f115a0b08c2cf49865ccf2f2c4e76f1
7
+ data.tar.gz: 6c092f9332873d3cea39f85fa33b8447f6fc4b8685c8b25b18490de845ac0eda408de15494a29e18a7f465d3e005248bbacb497f2365a4b7854512e10fcc86bf
@@ -123,6 +123,11 @@ module Tempest
123
123
  cached = @mutex.synchronize { @cache[did] }
124
124
  return cached_value(cached) unless cached.nil?
125
125
 
126
+ if @async && (path = cached_file_for(did))
127
+ @mutex.synchronize { @cache[did] = path }
128
+ return path
129
+ end
130
+
126
131
  if @async
127
132
  enqueue_resolve(did)
128
133
  nil
@@ -141,6 +146,10 @@ module Tempest
141
146
  value.equal?(NOT_FOUND) ? nil : value
142
147
  end
143
148
 
149
+ def cached_file_for(did)
150
+ Dir.glob(File.join(@cache_dir, "#{sanitize(did)}__*.png")).max_by { |path| File.mtime(path) }
151
+ end
152
+
144
153
  def resolve_and_cache(did)
145
154
  path = resolve_sync(did)
146
155
  @mutex.synchronize { @cache[did] = path.nil? ? NOT_FOUND : path }
data/lib/tempest/cli.rb CHANGED
@@ -100,6 +100,7 @@ module Tempest
100
100
  avatar_store: avatar_store,
101
101
  timeline_store: timeline_store(env),
102
102
  opener: opener_for(env: env),
103
+ reauth: build_reauth(env, stdout, stdin, session_factory),
103
104
  )
104
105
 
105
106
  begin
@@ -155,6 +156,17 @@ module Tempest
155
156
  value.nil? || value.empty? ? nil : value
156
157
  end
157
158
 
159
+ # Builds the proc REPL::Runner uses to honour `:relogin`. The lambda
160
+ # re-reads credentials from `env` on each call (so a user can update env
161
+ # in-process if needed) and goes through the same 2FA prompt path as
162
+ # initial sign-in.
163
+ def build_reauth(env, stdout, stdin, session_factory)
164
+ lambda do
165
+ config = Tempest::Config.from_env(env)
166
+ create_with_2fa(config, env, stdout, stdin, session_factory)
167
+ end
168
+ end
169
+
158
170
  def create_with_2fa(config, env, stdout, stdin, session_factory)
159
171
  token = env["TEMPEST_AUTH_FACTOR_TOKEN"]
160
172
  session_factory.call(config, auth_factor_token: token)
@@ -5,7 +5,7 @@ module Tempest
5
5
  Command = Data.define(:name, :args)
6
6
 
7
7
  class Dispatcher
8
- KNOWN_COMMANDS = %i[timeline quit help stream open].freeze
8
+ KNOWN_COMMANDS = %i[timeline quit help stream open relogin].freeze
9
9
  DOLLAR_ID = /\A\$[A-Z]{2}\z/.freeze
10
10
 
11
11
  def dispatch(input)
@@ -190,7 +190,7 @@ module Tempest
190
190
  prefix += id_label(var) if var
191
191
  prefix += bracket(time) if time
192
192
  identity = handle ? handle_label(handle) : did_label(did)
193
- identity = "#{icon} #{identity}" if icon
193
+ identity = "#{icon} #{identity}" if icon
194
194
  "#{prefix}#{identity}: #{text}"
195
195
  end
196
196
 
@@ -18,6 +18,7 @@ module Tempest
18
18
  :timeline Fetch and print the home timeline
19
19
  :stream on|off Toggle the Jetstream live feed
20
20
  :open $LX Open the URL with id $LX in the browser
21
+ :relogin Re-authenticate when the cached session is dead
21
22
  :help Show this help
22
23
  :quit Exit tempest (or Ctrl-D)
23
24
 
@@ -28,10 +29,12 @@ module Tempest
28
29
 
29
30
  DEFAULT_OPENER = ->(url) { system("open", url) }
30
31
 
32
+ RELOGIN_HINT = "type :relogin to re-authenticate".freeze
33
+
31
34
  def initialize(session:, client:, input:, output:, dispatcher: Dispatcher.new,
32
35
  stream_manager: nil, handle_resolver: nil, stream_output: nil,
33
36
  timeline_store: nil, registry: Registry.new, opener: DEFAULT_OPENER,
34
- avatar_store: nil)
37
+ avatar_store: nil, reauth: nil)
35
38
  @session = session
36
39
  @client = client
37
40
  @input = input
@@ -44,6 +47,7 @@ module Tempest
44
47
  @registry = registry
45
48
  @opener = opener
46
49
  @avatar_store = avatar_store
50
+ @reauth = reauth
47
51
  # URIs already printed via bootstrap_timeline or backfill_timeline.
48
52
  # Jetstream's cursor-replay can re-emit those same posts on startup
49
53
  # (the persisted cursor is older than the getTimeline window), so the
@@ -104,6 +108,8 @@ module Tempest
104
108
  handle_reply(command.args[0], command.args[1])
105
109
  when :open
106
110
  handle_open(command.args.first)
111
+ when :relogin
112
+ handle_relogin
107
113
  when :unknown
108
114
  @output.puts "unknown command: :#{command.args.first}"
109
115
  end
@@ -133,10 +139,25 @@ module Tempest
133
139
  def handle_post(text)
134
140
  response = Post.create(@client, did: @session.did, text: text)
135
141
  @output.puts "posted: #{response["uri"]}"
142
+ rescue Tempest::AuthenticationError => e
143
+ @output.puts "error: #{e.message} (#{RELOGIN_HINT})"
136
144
  rescue Tempest::Error => e
137
145
  @output.puts "error: #{e.message}"
138
146
  end
139
147
 
148
+ def handle_relogin
149
+ if @reauth.nil?
150
+ @output.puts "relogin is not available in this session"
151
+ return
152
+ end
153
+
154
+ new_session = @reauth.call
155
+ @session.replace_with!(new_session)
156
+ @output.puts "signed in as @#{@session.handle}"
157
+ rescue Tempest::Error => e
158
+ @output.puts "relogin failed: #{e.message}"
159
+ end
160
+
140
161
  def handle_reply(var, body)
141
162
  target = @registry.find_post(var)
142
163
  if target.nil?
@@ -155,6 +176,8 @@ module Tempest
155
176
  reply: { uri: reply_uri_for(target), cid: target.cid },
156
177
  )
157
178
  @output.puts "posted: #{response["uri"]}"
179
+ rescue Tempest::AuthenticationError => e
180
+ @output.puts "error: #{e.message} (#{RELOGIN_HINT})"
158
181
  rescue Tempest::Error => e
159
182
  @output.puts "error: #{e.message}"
160
183
  end
@@ -37,6 +37,7 @@ module Tempest
37
37
 
38
38
  def disable
39
39
  return unless @enabled
40
+ @io.print "\e_Ga=d,q=2\e\\"
40
41
  @io.print "\e[r"
41
42
  @io.flush if @io.respond_to?(:flush)
42
43
  @enabled = false
@@ -130,6 +131,7 @@ module Tempest
130
131
 
131
132
  def wrap_to_cols(line)
132
133
  return [line] unless @cols && @cols.positive?
134
+ return [line] if line.include?("\e_G")
133
135
  return [line] if Reline::Unicode.calculate_width(line, true) <= @cols
134
136
 
135
137
  chunks, _ = Reline::Unicode.split_by_width(line, @cols)
@@ -46,6 +46,7 @@ module Tempest
46
46
  @handle = handle
47
47
  @pds_host = pds_host
48
48
  @identifier = identifier
49
+ @refresh_mutex = Mutex.new
49
50
  end
50
51
 
51
52
  def access_expired?
@@ -54,7 +55,40 @@ module Tempest
54
55
  Time.now.to_i + EXPIRY_LEEWAY_SECONDS >= exp
55
56
  end
56
57
 
57
- def refresh!
58
+ # Adopts another Session's credentials in place. Used by :relogin so the
59
+ # XRPCClient that already holds a reference to this Session keeps working
60
+ # without having to be reconstructed.
61
+ def replace_with!(other)
62
+ @refresh_mutex.synchronize do
63
+ @access_jwt = other.access_jwt
64
+ @refresh_jwt = other.refresh_jwt
65
+ @did = other.did
66
+ @handle = other.handle
67
+ @pds_host = other.pds_host
68
+ end
69
+ @on_change&.call(self)
70
+ self
71
+ end
72
+
73
+ # Refreshes the session using the current refresh_jwt.
74
+ #
75
+ # When `if_unchanged_from:` is supplied, the refresh is skipped if the
76
+ # session's access_jwt has already moved past that value. Combined with the
77
+ # internal mutex, this lets concurrent callers coalesce a single
78
+ # refreshSession round-trip: the first caller refreshes while the rest wait
79
+ # for the lock and then observe the new token, no-op'ing instead of issuing
80
+ # duplicate refresh requests.
81
+ def refresh!(if_unchanged_from: nil)
82
+ @refresh_mutex.synchronize do
83
+ return self if if_unchanged_from && @access_jwt != if_unchanged_from
84
+
85
+ perform_refresh
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def perform_refresh
58
92
  url = "#{@pds_host}/xrpc/com.atproto.server.refreshSession"
59
93
  response = Tempest::HTTP.post_json(
60
94
  url,
@@ -77,8 +111,6 @@ module Tempest
77
111
  self
78
112
  end
79
113
 
80
- private
81
-
82
114
  def jwt_exp(token)
83
115
  _, payload, _ = token.split(".")
84
116
  return nil if payload.nil?
@@ -1,3 +1,3 @@
1
1
  module Tempest
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
@@ -38,10 +38,11 @@ module Tempest
38
38
  end
39
39
 
40
40
  def perform
41
- response = yield(@session.access_jwt)
41
+ attempted_jwt = @session.access_jwt
42
+ response = yield(attempted_jwt)
42
43
 
43
- if response.unauthorized?
44
- @session.refresh!
44
+ if auth_expired_response?(response)
45
+ @session.refresh!(if_unchanged_from: attempted_jwt)
45
46
  response = yield(@session.access_jwt)
46
47
  end
47
48
 
@@ -49,5 +50,13 @@ module Tempest
49
50
 
50
51
  response.body
51
52
  end
53
+
54
+ def auth_expired_response?(response)
55
+ return true if response.unauthorized?
56
+ return false unless response.status == 400
57
+ return false unless response.body.is_a?(Hash)
58
+
59
+ response.body["error"] == "ExpiredToken"
60
+ end
52
61
  end
53
62
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tempest-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuya Fujiwara