activematrix 0.0.0

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.
@@ -0,0 +1,451 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'matrix_sdk'
4
+
5
+ require 'erb'
6
+ require 'net/http'
7
+ require 'openssl'
8
+ require 'uri'
9
+
10
+ module MatrixSdk
11
+ class Api
12
+ extend MatrixSdk::Extensions
13
+ include MatrixSdk::Logging
14
+
15
+ USER_AGENT = "Ruby Matrix SDK v#{MatrixSdk::VERSION}"
16
+ DEFAULT_HEADERS = {
17
+ 'accept' => 'application/json',
18
+ 'user-agent' => USER_AGENT
19
+ }.freeze
20
+
21
+ attr_accessor :access_token, :connection_address, :connection_port, :device_id, :autoretry, :global_headers
22
+ attr_reader :homeserver, :validate_certificate, :open_timeout, :read_timeout, :well_known, :proxy_uri, :threadsafe
23
+
24
+ ignore_inspect :access_token, :logger
25
+
26
+ # @param homeserver [String,URI] The URL to the Matrix homeserver, without the /_matrix/ part
27
+ # @param params [Hash] Additional parameters on creation
28
+ # @option params [Symbol[]] :protocols The protocols to include (:AS, :CS, :IS, :SS), defaults to :CS
29
+ # @option params [String] :address The connection address to the homeserver, if different to the HS URL
30
+ # @option params [Integer] :port The connection port to the homeserver, if different to the HS URL
31
+ # @option params [String] :access_token The access token to use for the connection
32
+ # @option params [String] :device_id The ID of the logged in decide to use
33
+ # @option params [Boolean] :autoretry (true) Should requests automatically be retried in case of rate limits
34
+ # @option params [Boolean] :validate_certificate (false) Should the connection require valid SSL certificates
35
+ # @option params [Integer] :transaction_id (0) The starting ID for transactions
36
+ # @option params [Numeric] :backoff_time (5000) The request backoff time in milliseconds
37
+ # @option params [Numeric] :open_timeout (60) The timeout in seconds to wait for a TCP session to open
38
+ # @option params [Numeric] :read_timeout (240) The timeout in seconds for reading responses
39
+ # @option params [Hash] :global_headers Additional headers to set for all requests
40
+ # @option params [Boolean] :skip_login Should the API skip logging in if the HS URL contains user information
41
+ # @option params [Boolean] :synapse (true) Is the API connecting to a Synapse instance
42
+ # @option params [Boolean,:multithread] :threadsafe (:multithread) Should the connection be threadsafe/mutexed - or
43
+ # safe for simultaneous multi-thread usage. Will default to +:multithread+ - a.k.a. per-thread HTTP connections
44
+ # and requests
45
+ # @note Using threadsafe +:multithread+ currently doesn't support connection re-use
46
+ def initialize(homeserver, **params)
47
+ @homeserver = homeserver
48
+ raise ArgumentError, 'Homeserver URL must be String or URI' unless @homeserver.is_a?(String) || @homeserver.is_a?(URI)
49
+
50
+ @homeserver = URI.parse("#{'https://' unless @homeserver.start_with? 'http'}#{@homeserver}") unless @homeserver.is_a? URI
51
+ @homeserver.path.gsub!(/\/?_matrix\/?/, '') if @homeserver.path =~ /_matrix\/?$/
52
+ raise ArgumentError, 'Please use the base URL for your HS (without /_matrix/)' if @homeserver.path.include? '/_matrix/'
53
+
54
+ @proxy_uri = params.fetch(:proxy_uri, nil)
55
+ @connection_address = params.fetch(:address, nil)
56
+ @connection_port = params.fetch(:port, nil)
57
+ @access_token = params.fetch(:access_token, nil)
58
+ @device_id = params.fetch(:device_id, nil)
59
+ @autoretry = params.fetch(:autoretry, true)
60
+ @validate_certificate = params.fetch(:validate_certificate, false)
61
+ @transaction_id = params.fetch(:transaction_id, 0)
62
+ @backoff_time = params.fetch(:backoff_time, 5000)
63
+ @open_timeout = params.fetch(:open_timeout, nil)
64
+ @read_timeout = params.fetch(:read_timeout, nil)
65
+ @well_known = params.fetch(:well_known, {})
66
+ @global_headers = DEFAULT_HEADERS.dup
67
+ @global_headers.merge!(params.fetch(:global_headers)) if params.key? :global_headers
68
+ @synapse = params.fetch(:synapse, true)
69
+ @http = nil
70
+ @inflight = []
71
+
72
+ self.threadsafe = params.fetch(:threadsafe, :multithread)
73
+
74
+ ([params.fetch(:protocols, [:CS])].flatten - protocols).each do |proto|
75
+ self.class.include MatrixSdk::Protocols.const_get(proto)
76
+ end
77
+
78
+ login(user: @homeserver.user, password: @homeserver.password) if @homeserver.user && @homeserver.password && !@access_token && !params[:skip_login] && protocol?(:CS)
79
+ @homeserver.userinfo = '' unless params[:skip_login]
80
+ end
81
+
82
+ # Create an API connection to a domain entry
83
+ #
84
+ # This will follow the server discovery spec for client-server and federation
85
+ #
86
+ # @example Opening a Matrix API connection to a homeserver
87
+ # hs = MatrixSdk::API.new_for_domain 'example.com'
88
+ # hs.connection_address
89
+ # # => 'matrix.example.com'
90
+ # hs.connection_port
91
+ # # => 443
92
+ #
93
+ # @param domain [String] The domain to set up the API connection for, can contain a ':' to denote a port
94
+ # @param target [:client,:identity,:server] The target for the domain lookup
95
+ # @param keep_wellknown [Boolean] Should the .well-known response be kept for further handling
96
+ # @param params [Hash] Additional options to pass to .new
97
+ # @return [API] The API connection
98
+ def self.new_for_domain(domain, target: :client, keep_wellknown: false, ssl: true, **params)
99
+ domain, port = domain.split(':')
100
+ uri = URI("http#{ssl ? 's' : ''}://#{domain}")
101
+ well_known = nil
102
+ target_uri = nil
103
+ logger = ::Logging.logger[self]
104
+ logger.debug "Resolving #{domain}"
105
+
106
+ if !port.nil? && !port.empty?
107
+ # If the domain is fully qualified according to Matrix (FQDN and port) then skip discovery
108
+ target_uri = URI("https://#{domain}:#{port}")
109
+ elsif target == :server
110
+ # Attempt SRV record discovery
111
+ target_uri = begin
112
+ require 'resolv'
113
+ resolver = Resolv::DNS.new
114
+ srv = "_matrix._tcp.#{domain}"
115
+ logger.debug "Trying DNS #{srv}..."
116
+ d = resolver.getresource(srv, Resolv::DNS::Resource::IN::SRV)
117
+ d
118
+ rescue StandardError => e
119
+ logger.debug "DNS lookup failed with #{e.class}: #{e.message}"
120
+ nil
121
+ end
122
+
123
+ if target_uri.nil?
124
+ # Attempt .well-known discovery for server-to-server
125
+ well_known = begin
126
+ wk_uri = URI("https://#{domain}/.well-known/matrix/server")
127
+ logger.debug "Trying #{wk_uri}..."
128
+ data = Net::HTTP.start(wk_uri.host, wk_uri.port, use_ssl: true, open_timeout: 5, read_timeout: 5, write_timeout: 5) do |http|
129
+ http.get(wk_uri.path).body
130
+ end
131
+ JSON.parse(data)
132
+ rescue StandardError => e
133
+ logger.debug "Well-known failed with #{e.class}: #{e.message}"
134
+ nil
135
+ end
136
+
137
+ target_uri = well_known['m.server'] if well_known&.key?('m.server')
138
+ else
139
+ target_uri = URI("https://#{target_uri.target}:#{target_uri.port}")
140
+ end
141
+ elsif %i[client identity].include? target
142
+ # Attempt .well-known discovery
143
+ well_known = begin
144
+ wk_uri = URI("https://#{domain}/.well-known/matrix/client")
145
+ logger.debug "Trying #{wk_uri}..."
146
+ data = Net::HTTP.start(wk_uri.host, wk_uri.port, use_ssl: true, open_timeout: 5, read_timeout: 5, write_timeout: 5) do |http|
147
+ http.get(wk_uri.path).body
148
+ end
149
+ JSON.parse(data)
150
+ rescue StandardError => e
151
+ logger.debug "Well-known failed with #{e.class}: #{e.message}"
152
+ nil
153
+ end
154
+
155
+ if well_known
156
+ key = 'm.homeserver'
157
+ key = 'm.identity_server' if target == :identity
158
+
159
+ if well_known.key?(key) && well_known[key].key?('base_url')
160
+ uri = URI(well_known[key]['base_url'])
161
+ target_uri = uri
162
+ end
163
+ end
164
+ end
165
+ logger.debug "Using #{target_uri.inspect}"
166
+
167
+ # Fall back to direct domain connection
168
+ target_uri ||= URI("https://#{domain}:8448")
169
+
170
+ params[:well_known] = well_known if keep_wellknown
171
+
172
+ new(
173
+ uri,
174
+ **params.merge(
175
+ address: target_uri.host,
176
+ port: target_uri.port
177
+ )
178
+ )
179
+ end
180
+
181
+ # Get a list of enabled protocols on the API client
182
+ #
183
+ # @example
184
+ # MatrixSdk::Api.new_for_domain('matrix.org').protocols
185
+ # # => [:IS, :CS]
186
+ #
187
+ # @return [Symbol[]] An array of enabled APIs
188
+ def protocols
189
+ self
190
+ .class.included_modules
191
+ .reject { |m| m&.name.nil? }
192
+ .select { |m| m.name.start_with? 'MatrixSdk::Protocols::' }
193
+ .map { |m| m.name.split('::').last.to_sym }
194
+ end
195
+
196
+ # Check if a protocol is enabled on the API connection
197
+ #
198
+ # @example Checking for identity server API support
199
+ # api.protocol? :IS
200
+ # # => false
201
+ #
202
+ # @param protocol [Symbol] The protocol to check
203
+ # @return [Boolean] Is the protocol enabled
204
+ def protocol?(protocol)
205
+ protocols.include? protocol
206
+ end
207
+
208
+ # @param seconds [Numeric]
209
+ # @return [Numeric]
210
+ def open_timeout=(seconds)
211
+ @http.finish if @http && @open_timeout != seconds
212
+ @open_timeout = seconds
213
+ end
214
+
215
+ # @param seconds [Numeric]
216
+ # @return [Numeric]
217
+ def read_timeout=(seconds)
218
+ @http.finish if @http && @read_timeout != seconds
219
+ @read_timeout = seconds
220
+ end
221
+
222
+ # @param validate [Boolean]
223
+ # @return [Boolean]
224
+ def validate_certificate=(validate)
225
+ # The HTTP connection needs to be reopened if this changes
226
+ @http.finish if @http && validate != @validate_certificate
227
+ @validate_certificate = validate
228
+ end
229
+
230
+ # @param hs_info [URI]
231
+ # @return [URI]
232
+ def homeserver=(hs_info)
233
+ # TODO: DNS query for SRV information about HS?
234
+ return unless hs_info.is_a? URI
235
+
236
+ @http.finish if @http && homeserver != hs_info
237
+ @homeserver = hs_info
238
+ end
239
+
240
+ # @param [URI] proxy_uri The URI for the proxy to use
241
+ # @return [URI]
242
+ def proxy_uri=(proxy_uri)
243
+ proxy_uri = URI(proxy_uri.to_s) unless proxy_uri.is_a? URI
244
+
245
+ if @http && @proxy_uri != proxy_uri
246
+ @http.finish
247
+ @http = nil
248
+ end
249
+ @proxy_uri = proxy_uri
250
+ end
251
+
252
+ # @param [Boolean,:multithread] threadsafe What level of thread-safety the API should use
253
+ # @return [Boolean,:multithread]
254
+ def threadsafe=(threadsafe)
255
+ raise ArgumentError, 'Threadsafe must be either a boolean or :multithread' unless [true, false, :multithread].include? threadsafe
256
+ raise ArugmentError, 'JRuby only support :multithread/false for threadsafe' if RUBY_ENGINE == 'jruby' && threadsafe == true
257
+
258
+ @threadsafe = threadsafe
259
+ @http_lock = nil unless threadsafe == true
260
+ @threadsafe
261
+ end
262
+
263
+ # Perform a raw Matrix API request
264
+ #
265
+ # @example Simple API query
266
+ # api.request(:get, :client_r0, '/account/whoami')
267
+ # # => { :user_id => "@alice:matrix.org" }
268
+ #
269
+ # @example Advanced API request
270
+ # api.request(:post,
271
+ # :media_r0,
272
+ # '/upload',
273
+ # body_stream: open('./file'),
274
+ # headers: { 'content-type' => 'image/png' })
275
+ # # => { :content_uri => "mxc://example.com/AQwafuaFswefuhsfAFAgsw" }
276
+ #
277
+ # @param method [Symbol] The method to use, can be any of the ones under Net::HTTP
278
+ # @param api [Symbol] The API symbol to use, :client_r0 is the current CS one
279
+ # @param path [String] The API path to call, this is the part that comes after the API definition in the spec
280
+ # @param options [Hash] Additional options to pass along to the request
281
+ # @option options [Hash] :query Query parameters to set on the URL
282
+ # @option options [Hash,String] :body The body to attach to the request, will be JSON-encoded if sent as a hash
283
+ # @option options [IO] :body_stream A body stream to attach to the request
284
+ # @option options [Hash] :headers Additional headers to set on the request
285
+ # @option options [Boolean] :skip_auth (false) Skip authentication
286
+ def request(method, api, path, **options)
287
+ url = homeserver.dup.tap do |u|
288
+ u.path = api_to_path(api) + path
289
+ u.query = [u.query, URI.encode_www_form(options.fetch(:query))].flatten.compact.join('&') if options[:query]
290
+ u.query = nil if u.query.nil? || u.query.empty?
291
+ end
292
+
293
+ failures = 0
294
+ loop do
295
+ raise MatrixConnectionError, "Server still too busy to handle request after #{failures} attempts, try again later" if failures >= 10
296
+
297
+ req_id = ('A'..'Z').to_a.sample(4).join
298
+
299
+ req_obj = construct_request(url: url, method: method, **options)
300
+ print_http(req_obj, id: req_id)
301
+ response = duration = nil
302
+
303
+ loc_http = http
304
+ perform_request = proc do
305
+ @inflight << loc_http
306
+ dur_start = Time.now
307
+ response = loc_http.request req_obj
308
+ dur_end = Time.now
309
+ duration = dur_end - dur_start
310
+ rescue EOFError
311
+ logger.error 'Socket closed unexpectedly'
312
+ raise
313
+ ensure
314
+ @inflight.delete loc_http
315
+ end
316
+
317
+ if @threadsafe == true
318
+ http_lock.synchronize { perform_request.call }
319
+ else
320
+ perform_request.call
321
+ loc_http.finish if @threadsafe == :multithread
322
+ end
323
+ print_http(response, duration: duration, id: req_id)
324
+
325
+ begin
326
+ data = JSON.parse(response.body, symbolize_names: true)
327
+ rescue JSON::JSONError => e
328
+ logger.debug "#{e.class} error when parsing response. #{e}"
329
+ data = nil
330
+ end
331
+
332
+ if response.is_a? Net::HTTPTooManyRequests
333
+ raise MatrixRequestError.new_by_code(data, response.code) unless autoretry
334
+
335
+ failures += 1
336
+ waittime = data[:retry_after_ms] || data[:error][:retry_after_ms] || @backoff_time
337
+ sleep(waittime.to_f / 1000.0)
338
+ next
339
+ end
340
+
341
+ if response.is_a? Net::HTTPSuccess
342
+ unless data
343
+ logger.error "Received non-parsable data in 200 response; #{response.body.inspect}"
344
+ raise MatrixConnectionError, response
345
+ end
346
+ return MatrixSdk::Response.new self, data
347
+ end
348
+ raise MatrixRequestError.new_by_code(data, response.code) if data
349
+
350
+ raise MatrixConnectionError.class_by_code(response.code), response
351
+ end
352
+ end
353
+
354
+ # Generate a transaction ID
355
+ #
356
+ # @return [String] An arbitrary transaction ID
357
+ def transaction_id
358
+ ret = @transaction_id ||= 0
359
+ @transaction_id = @transaction_id.succ
360
+ ret
361
+ end
362
+
363
+ def stop_inflight
364
+ @inflight.each(&:finish)
365
+ end
366
+
367
+ private
368
+
369
+ def construct_request(method:, url:, **options)
370
+ request = Net::HTTP.const_get(method.to_s.capitalize.to_sym).new url.request_uri
371
+
372
+ # FIXME: Handle bodies better, avoid duplicating work
373
+ request.body = options[:body] if options.key? :body
374
+ request.body = request.body.to_json if options.key?(:body) && !request.body.is_a?(String)
375
+ request.body_stream = options[:body_stream] if options.key? :body_stream
376
+
377
+ global_headers.each { |h, v| request[h] = v }
378
+ if request.body || request.body_stream
379
+ request.content_type = 'application/json'
380
+ request.content_length = (request.body || request.body_stream).size
381
+ end
382
+
383
+ request['authorization'] = "Bearer #{access_token}" if access_token && !options.fetch(:skip_auth, false)
384
+ if options.key? :headers
385
+ options[:headers].each do |h, v|
386
+ request[h.to_s.downcase] = v
387
+ end
388
+ end
389
+
390
+ request
391
+ end
392
+
393
+ def print_http(http, body: true, duration: nil, id: nil)
394
+ return unless logger.debug?
395
+
396
+ if http.is_a? Net::HTTPRequest
397
+ dir = "#{id ? "#{id} : " : nil}>"
398
+ logger.debug "#{dir} Sending a #{http.method} request to `#{http.path}`:"
399
+ else
400
+ dir = "#{id ? "#{id} : " : nil}<"
401
+ logger.debug "#{dir} Received a #{http.code} #{http.message} response:#{duration ? " [#{(duration * 1000).to_i}ms]" : nil}"
402
+ end
403
+ http.to_hash.map { |k, v| "#{k}: #{k == 'authorization' ? '[ REDACTED ]' : v.join(', ')}" }.each do |h|
404
+ logger.debug "#{dir} #{h}"
405
+ end
406
+ logger.debug dir
407
+ if body
408
+ clean_body = JSON.parse(http.body) rescue nil if http.body
409
+ clean_body.each_key { |k| clean_body[k] = '[ REDACTED ]' if %w[password access_token].include?(k) }.to_json if clean_body.is_a? Hash
410
+ clean_body = clean_body.to_s if clean_body
411
+ logger.debug "#{dir} #{clean_body.length < 200 ? clean_body : clean_body.slice(0..200) + "... [truncated, #{clean_body.length} Bytes]"}" if clean_body
412
+ end
413
+ rescue StandardError => e
414
+ logger.warn "#{e.class} occured while printing request debug; #{e.message}\n#{e.backtrace.join "\n"}"
415
+ end
416
+
417
+ def api_to_path(api)
418
+ return "/_synapse/#{api.to_s.split('_').join('/')}" if @synapse && api.to_s.start_with?('admin_')
419
+
420
+ # TODO: <api>_current / <api>_latest
421
+ "/_matrix/#{api.to_s.split('_').join('/')}"
422
+ end
423
+
424
+ def http
425
+ return @http if @http&.active?
426
+
427
+ host = (@connection_address || homeserver.host)
428
+ port = (@connection_port || homeserver.port)
429
+
430
+ connection = @http unless @threadsafe == :multithread
431
+ connection ||= if proxy_uri
432
+ Net::HTTP.new(host, port, proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password)
433
+ else
434
+ Net::HTTP.new(host, port)
435
+ end
436
+
437
+ connection.open_timeout = open_timeout if open_timeout
438
+ connection.read_timeout = read_timeout if read_timeout
439
+ connection.use_ssl = homeserver.scheme == 'https'
440
+ connection.verify_mode = validate_certificate ? ::OpenSSL::SSL::VERIFY_PEER : ::OpenSSL::SSL::VERIFY_NONE
441
+ connection.start
442
+ @http = connection unless @threadsafe == :multithread
443
+
444
+ connection
445
+ end
446
+
447
+ def http_lock
448
+ @http_lock ||= Mutex.new if @threadsafe == true
449
+ end
450
+ end
451
+ end