async-matrix-bridge 2.0.0 → 3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/README.md +79 -142
- data/lib/async/matrix/bridge/puppet.rb +89 -0
- data/lib/async/matrix/bridge/registration.rb +116 -197
- data/lib/async/matrix/bridge/service.rb +250 -0
- data/lib/async/matrix/bridge/transaction.rb +90 -0
- data/lib/async/matrix/bridge/transaction_store.rb +0 -39
- data/lib/async/matrix/bridge/version.rb +1 -1
- metadata +16 -23
- data/lib/async/matrix/bridge/endpoint.rb +0 -482
- data/lib/async/matrix/bridge/registration/schema/namespace_list.json +0 -22
- data/lib/async/matrix/bridge/registration/schema/registration.json +0 -91
|
@@ -3,242 +3,161 @@
|
|
|
3
3
|
# Released under the Apache License, Version 2.0.
|
|
4
4
|
# Copyright, 2026, by General Intelligence Systems.
|
|
5
5
|
|
|
6
|
-
require "async/matrix"
|
|
7
|
-
require "json_schemer"
|
|
8
|
-
require "pathname"
|
|
9
6
|
require "yaml"
|
|
10
7
|
|
|
8
|
+
require_relative "puppet"
|
|
9
|
+
|
|
11
10
|
module Async
|
|
12
11
|
module Matrix
|
|
13
12
|
module Bridge
|
|
13
|
+
# What the homeserver was told about this service, which is also the whole of its authority.
|
|
14
|
+
#
|
|
15
|
+
# A service cannot register itself: one which could would claim `.*` and read everything, so an
|
|
16
|
+
# administrator puts this file beside the homeserver's own configuration by hand.
|
|
17
|
+
#
|
|
18
|
+
# The namespaces are regexes the homeserver holds too, which is why a user outside them is
|
|
19
|
+
# refused here rather than asked about: the answer is already known.
|
|
14
20
|
class Registration
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Homeserver = Struct.new(:address, :domain)
|
|
18
|
-
|
|
19
|
-
Appservice = Struct.new(:as_token, :hs_token, :sender_localpart)
|
|
20
|
-
|
|
21
|
-
NAMESPACES = %w[users rooms aliases].freeze
|
|
22
|
-
|
|
23
|
-
attr_reader :raw, :homeserver, :appservice
|
|
24
|
-
|
|
25
|
-
def self.schema
|
|
26
|
-
@schema ||= JSONSchemer.schema(SCHEMA_DIR.join("registration.json"))
|
|
27
|
-
end
|
|
21
|
+
KINDS = ["users", "aliases", "rooms"].freeze
|
|
28
22
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"Registration file not found: #{pathname}",
|
|
35
|
-
)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
new(YAML.safe_load_file(pathname), homeserver: homeserver, domain: domain)
|
|
39
|
-
end
|
|
23
|
+
# @parameter path [String] the registration file.
|
|
24
|
+
# @parameter homeserver [String] where the homeserver is.
|
|
25
|
+
# @returns [Registration] the registration it describes.
|
|
26
|
+
def self.load(path, homeserver:)
|
|
27
|
+
self.new(::YAML.safe_load_file(path), homeserver: homeserver)
|
|
40
28
|
end
|
|
41
29
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"Registration validation failed: #{messages}",
|
|
49
|
-
)
|
|
30
|
+
# @parameter value [Hash] the registration, as the file spells it.
|
|
31
|
+
# @parameter homeserver [String] where the homeserver is.
|
|
32
|
+
def initialize(value, homeserver:)
|
|
33
|
+
["id", "as_token", "hs_token", "sender_localpart"].each do |field|
|
|
34
|
+
unless value[field]
|
|
35
|
+
raise ArgumentError, "A registration must declare #{field}!"
|
|
50
36
|
end
|
|
51
37
|
end
|
|
52
|
-
end
|
|
53
38
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@raw = data
|
|
58
|
-
@homeserver = Homeserver.new(homeserver, domain)
|
|
59
|
-
@appservice = Appservice.new(
|
|
60
|
-
data["as_token"],
|
|
61
|
-
data["hs_token"],
|
|
62
|
-
data["sender_localpart"],
|
|
63
|
-
)
|
|
39
|
+
@value = value
|
|
40
|
+
@homeserver = homeserver
|
|
41
|
+
@domain = ::URI.parse(homeserver).host
|
|
64
42
|
end
|
|
65
43
|
|
|
66
|
-
|
|
44
|
+
# @attribute [Hash] the registration, as the file spells it.
|
|
45
|
+
attr :value
|
|
67
46
|
|
|
68
|
-
|
|
47
|
+
# @attribute [String] where the homeserver is.
|
|
48
|
+
attr :homeserver
|
|
69
49
|
|
|
70
|
-
|
|
50
|
+
# @attribute [String] the server name the users of this service belong to.
|
|
51
|
+
attr :domain
|
|
71
52
|
|
|
72
|
-
|
|
53
|
+
# @returns [String] what the homeserver knows this service as.
|
|
54
|
+
def id = @value["id"]
|
|
73
55
|
|
|
74
|
-
|
|
56
|
+
# @returns [String] the token this service authenticates to the homeserver with.
|
|
57
|
+
def as_token = @value["as_token"]
|
|
75
58
|
|
|
76
|
-
|
|
59
|
+
# @returns [String] the token the homeserver authenticates to this service with.
|
|
60
|
+
def hs_token = @value["hs_token"]
|
|
77
61
|
|
|
78
|
-
|
|
62
|
+
# @returns [String] the user an unnamed request acts as.
|
|
63
|
+
def sender = "@#{@value["sender_localpart"]}:#{@domain}"
|
|
79
64
|
|
|
80
|
-
|
|
65
|
+
# @returns [Boolean] whether the homeserver pushes read receipts and typing as well as events.
|
|
66
|
+
def ephemeral? = @value.fetch("receive_ephemeral", false)
|
|
81
67
|
|
|
68
|
+
# @parameter kind [String] `users`, `aliases` or `rooms`.
|
|
69
|
+
# @returns [Array(Hash)] the namespaces of that kind.
|
|
82
70
|
def namespaces(kind)
|
|
83
|
-
unless
|
|
84
|
-
raise ArgumentError, "
|
|
71
|
+
unless KINDS.include?(kind)
|
|
72
|
+
raise ArgumentError, "A registration has no #{kind} namespace!"
|
|
85
73
|
end
|
|
86
74
|
|
|
87
|
-
@
|
|
75
|
+
@value.dig("namespaces", kind) || []
|
|
88
76
|
end
|
|
89
77
|
|
|
78
|
+
# Whether this service is interested in something, which is the same answer the homeserver has.
|
|
79
|
+
#
|
|
80
|
+
# @parameter kind [String] `users`, `aliases` or `rooms`.
|
|
81
|
+
# @parameter value [String] the ID or alias to ask about.
|
|
82
|
+
# @returns [Boolean] whether a namespace covers it.
|
|
90
83
|
def covers?(kind, value)
|
|
91
|
-
namespaces(kind).any?
|
|
84
|
+
self.namespaces(kind).any?{|namespace| self.class.claims?(namespace, value)}
|
|
92
85
|
end
|
|
93
86
|
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
# Whether nothing else may create this, which is what an exclusive namespace means.
|
|
88
|
+
#
|
|
89
|
+
# @parameter kind [String] `users`, `aliases` or `rooms`.
|
|
90
|
+
# @parameter value [String] the ID or alias to ask about.
|
|
91
|
+
# @returns [Boolean] whether this service owns it alone.
|
|
96
92
|
def exclusive?(kind, value)
|
|
97
|
-
namespaces(kind).any? do |
|
|
98
|
-
|
|
93
|
+
self.namespaces(kind).any? do |namespace|
|
|
94
|
+
namespace["exclusive"] and self.class.claims?(namespace, value)
|
|
99
95
|
end
|
|
100
96
|
end
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
__END__
|
|
107
|
-
require "tempfile"
|
|
108
|
-
|
|
109
|
-
describe "Async::Matrix::Bridge::Registration" do
|
|
110
|
-
Registration = Async::Matrix::Bridge::Registration
|
|
111
|
-
|
|
112
|
-
def valid_data
|
|
113
|
-
{
|
|
114
|
-
"id" => "agent",
|
|
115
|
-
"url" => "http://appservice:9000",
|
|
116
|
-
"as_token" => "as_secret",
|
|
117
|
-
"hs_token" => "hs_secret",
|
|
118
|
-
"sender_localpart" => "agent",
|
|
119
|
-
"namespaces" => {
|
|
120
|
-
"users" => [{"regex" => "@_agent_.*:localhost", "exclusive" => true}],
|
|
121
|
-
"aliases" => [],
|
|
122
|
-
"rooms" => [],
|
|
123
|
-
},
|
|
124
|
-
}
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def rejected?(data)
|
|
128
|
-
Registration.new(data, homeserver: "http://hs", domain: "d")
|
|
129
|
-
false
|
|
130
|
-
rescue Async::Matrix::BadJsonError
|
|
131
|
-
true
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def build(overrides = {})
|
|
135
|
-
Registration.new(valid_data.merge(overrides), homeserver: "http://synapse:8008", domain: "localhost")
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
it "reads the spec fields off the registration" do
|
|
139
|
-
registration = build
|
|
140
|
-
registration.id.should == "agent"
|
|
141
|
-
registration.url.should == "http://appservice:9000"
|
|
142
|
-
registration.sender_localpart.should == "agent"
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
it "presents the config surface a Client and an Endpoint need" do
|
|
146
|
-
registration = build
|
|
147
|
-
registration.homeserver.address.should == "http://synapse:8008"
|
|
148
|
-
registration.appservice.as_token.should == "as_secret"
|
|
149
|
-
registration.appservice.hs_token.should == "hs_secret"
|
|
150
|
-
registration.bot_mxid.should == "@agent:localhost"
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
it "derives bot_mxid from sender_localpart and the supplied domain" do
|
|
154
|
-
Registration.new(valid_data, homeserver: "http://hs", domain: "example.org")
|
|
155
|
-
.bot_mxid.should == "@agent:example.org"
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
it "requires the spec's required fields" do
|
|
159
|
-
["id", "as_token", "hs_token", "sender_localpart", "namespaces"].map do |field|
|
|
160
|
-
rejected?(valid_data.reject { |k, _| k == field })
|
|
161
|
-
end.should == [true, true, true, true, true]
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
it "requires url to be present but allows an explicit null" do
|
|
165
|
-
build("url" => nil).url.should == nil
|
|
166
|
-
|
|
167
|
-
rejected?(valid_data.reject { |k, _| k == "url" }).should == true
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
it "rejects a namespace entry missing regex or exclusive" do
|
|
171
|
-
[{"exclusive" => true}, {"regex" => "@a:b"}].map do |entry|
|
|
172
|
-
rejected?(valid_data.merge("namespaces" => {"users" => [entry]}))
|
|
173
|
-
end.should == [true, true]
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
it "rejects a non-string id" do
|
|
177
|
-
rejected?(valid_data.merge("id" => 42)).should == true
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
it "reports whether a value falls in a namespace" do
|
|
181
|
-
registration = build
|
|
182
|
-
registration.covers?(:users, "@_agent_alice:localhost").should == true
|
|
183
|
-
registration.covers?(:users, "@someone:localhost").should == false
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
it "reports exclusivity separately from coverage" do
|
|
187
|
-
registration = build("namespaces" => {"users" => [{"regex" => "@a.*:localhost", "exclusive" => false}]})
|
|
188
|
-
registration.covers?(:users, "@alice:localhost").should == true
|
|
189
|
-
registration.exclusive?(:users, "@alice:localhost").should == false
|
|
190
|
-
end
|
|
191
97
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
98
|
+
# Whether one namespace names this, which is the whole of it rather than part.
|
|
99
|
+
#
|
|
100
|
+
# Anchored because a namespace names a user rather than appears inside one: unanchored,
|
|
101
|
+
# `@_bridge_.*` would claim `@notours_bridge_x:localhost` too, and a bridge asks this about
|
|
102
|
+
# senders to decide what is its own.
|
|
103
|
+
#
|
|
104
|
+
# @parameter namespace [Hash] one entry of one kind.
|
|
105
|
+
# @parameter value [String] the ID or alias to ask about.
|
|
106
|
+
# @returns [Boolean] whether the regex matches all of it.
|
|
107
|
+
def self.claims?(namespace, value)
|
|
108
|
+
::Regexp.new("\\A(?:#{namespace["regex"]})\\z").match?(value)
|
|
203
109
|
end
|
|
204
110
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
111
|
+
# A client which acts as this service, or as one of the users it may act as.
|
|
112
|
+
#
|
|
113
|
+
# Identity assertion rather than a login: the token says which service, so the user is a
|
|
114
|
+
# parameter on every request. `Async::REST::Resource#with` merges it into the reference, which
|
|
115
|
+
# is why this is a derived resource rather than a middleware.
|
|
116
|
+
#
|
|
117
|
+
# `ts` is timestamp massaging, which only a service may ask for: it says when an event really
|
|
118
|
+
# happened, so a backlog carried over from another network is not stamped as now.
|
|
119
|
+
#
|
|
120
|
+
# @parameter client [Async::Matrix::ClientServer::Client] an unauthenticated client.
|
|
121
|
+
# @parameter user_id [String | nil] the user to act as, or nil for this service's own.
|
|
122
|
+
# @parameter device_id [String | nil] the device to act as, which is honoured from v1.17.
|
|
123
|
+
# @parameter ts [Integer | nil] when what is sent through it happened, in milliseconds.
|
|
124
|
+
# @returns [Async::Matrix::ClientServer::Client] a client which acts as them.
|
|
125
|
+
def as(client, user_id = nil, device_id: nil, ts: nil)
|
|
126
|
+
service = client.authenticated(self.as_token)
|
|
127
|
+
parameters = {user_id: user_id, device_id: device_id, ts: ts}.compact
|
|
128
|
+
|
|
129
|
+
case user_id
|
|
130
|
+
in nil
|
|
131
|
+
service.with(parameters: parameters)
|
|
132
|
+
in String if self.covers?("users", user_id)
|
|
133
|
+
service.with(parameters: parameters)
|
|
134
|
+
in String
|
|
135
|
+
raise ArgumentError, "#{user_id} is outside this service's namespaces!"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
218
138
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
file.unlink
|
|
230
|
-
end
|
|
231
|
-
end
|
|
139
|
+
# The user this service is, as something which can be given a device.
|
|
140
|
+
#
|
|
141
|
+
# An unnamed request acts as this user, but acting as a *device* of theirs needs one to exist:
|
|
142
|
+
# a service holds keys per device, and the sender has none until it makes one.
|
|
143
|
+
#
|
|
144
|
+
# @parameter client [Async::Matrix::ClientServer::Client] an unauthenticated client.
|
|
145
|
+
# @returns [Puppet] the user this service is.
|
|
146
|
+
def bot(client)
|
|
147
|
+
Puppet.new(self, client, @value["sender_localpart"])
|
|
148
|
+
end
|
|
232
149
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
150
|
+
# One of this service's users, which has to be made before it can be acted as.
|
|
151
|
+
#
|
|
152
|
+
# @parameter client [Async::Matrix::ClientServer::Client] an unauthenticated client.
|
|
153
|
+
# @parameter localpart [String] the part of the ID which names them, without the sigil.
|
|
154
|
+
# @returns [Puppet] the user, and what makes it exist.
|
|
155
|
+
def puppet(client, localpart)
|
|
156
|
+
Puppet.new(self, client, localpart)
|
|
240
157
|
end
|
|
241
158
|
|
|
242
|
-
|
|
159
|
+
def inspect = "\#<#{self.class} #{self.id}>"
|
|
160
|
+
end
|
|
243
161
|
end
|
|
244
162
|
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
require "openssl"
|
|
8
|
+
|
|
9
|
+
require "protocol/http/middleware"
|
|
10
|
+
require "protocol/http/response"
|
|
11
|
+
require "protocol/url/reference"
|
|
12
|
+
|
|
13
|
+
require "console"
|
|
14
|
+
|
|
15
|
+
require_relative "transaction"
|
|
16
|
+
require_relative "transaction_store"
|
|
17
|
+
|
|
18
|
+
module Async
|
|
19
|
+
module Matrix
|
|
20
|
+
module Bridge
|
|
21
|
+
# The paths a homeserver calls, which this service answers.
|
|
22
|
+
#
|
|
23
|
+
# A middleware rather than a Rack application: it is the same primitive which sits below a
|
|
24
|
+
# resource on the way out, pointed the other way, so it composes with everything else in the
|
|
25
|
+
# stack and reads a streamed body without an adapter in between.
|
|
26
|
+
class Service < ::Protocol::HTTP::Middleware
|
|
27
|
+
# Where every path this service answers begins.
|
|
28
|
+
PREFIX = "/_matrix/app/v1"
|
|
29
|
+
|
|
30
|
+
JSON_HEADERS = {"content-type" => "application/json"}.freeze
|
|
31
|
+
|
|
32
|
+
# @parameter registration [Registration] what the homeserver was told about this service.
|
|
33
|
+
# @parameter version [String] the specification version to type the events as.
|
|
34
|
+
# @parameter store [TransactionStore] what remembers the transactions already handled.
|
|
35
|
+
# @yields {|transaction| ...}
|
|
36
|
+
# @parameter transaction [Transaction] the events the homeserver pushed.
|
|
37
|
+
def initialize(registration, version: Async::Matrix::LATEST, store: TransactionStore.new, &block)
|
|
38
|
+
super(::Protocol::HTTP::Middleware::NotFound)
|
|
39
|
+
|
|
40
|
+
@registration = registration
|
|
41
|
+
@version = version
|
|
42
|
+
@store = store
|
|
43
|
+
@block = block
|
|
44
|
+
|
|
45
|
+
@queries = {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @attribute [Registration] what the homeserver was told about this service.
|
|
49
|
+
attr :registration
|
|
50
|
+
|
|
51
|
+
# Answer whether a user is one of this service's own.
|
|
52
|
+
#
|
|
53
|
+
# The homeserver asks before it invents them, and nothing means no.
|
|
54
|
+
#
|
|
55
|
+
# @yields {|user_id| ...} whether the user exists, or should be made to.
|
|
56
|
+
def query_user(&block)
|
|
57
|
+
@queries[:user] = block
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Answer whether a room alias is one of this service's own, creating the room if it is.
|
|
61
|
+
#
|
|
62
|
+
# @yields {|alias_id| ...} whether the room exists, or should be made to.
|
|
63
|
+
def query_room(&block)
|
|
64
|
+
@queries[:room] = block
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Answer what this service bridges to, which a client asks a homeserver and it asks here.
|
|
68
|
+
#
|
|
69
|
+
# @yields {|kind, rest| ...} the protocols, locations or users it was asked for.
|
|
70
|
+
def query_thirdparty(&block)
|
|
71
|
+
@queries[:thirdparty] = block
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @parameter request [Protocol::HTTP::Request] what the homeserver asked.
|
|
75
|
+
# @returns [Protocol::HTTP::Response] what this service answers.
|
|
76
|
+
def call(request)
|
|
77
|
+
reference = ::Protocol::URL::Reference.parse(request.path)
|
|
78
|
+
segments = reference.path.to_s.delete_prefix("#{PREFIX}/").split("/")
|
|
79
|
+
parameters = self.parameters_of(reference)
|
|
80
|
+
|
|
81
|
+
case [request.method, segments]
|
|
82
|
+
# Nothing of ours, so nothing to say about it: a health check is not an unauthorised request.
|
|
83
|
+
in [_, [*]] if !reference.path.to_s.start_with?("#{PREFIX}/")
|
|
84
|
+
self.failure(404, "M_UNRECOGNIZED", "This service does not answer #{request.path}.")
|
|
85
|
+
|
|
86
|
+
# Every path of ours, the ping included: a wrong token is what the ping exists to find.
|
|
87
|
+
in [_, [*]] if !self.authentic?(request, parameters)
|
|
88
|
+
self.failure(403, "M_FORBIDDEN", "The token does not match the registration.")
|
|
89
|
+
|
|
90
|
+
in ["POST", ["ping"]]
|
|
91
|
+
self.rendered(200, {})
|
|
92
|
+
|
|
93
|
+
in ["PUT", ["transactions", txn_id]]
|
|
94
|
+
self.transaction(request, ::Protocol::URL::Encoding.unescape(txn_id))
|
|
95
|
+
|
|
96
|
+
in ["GET", ["users", user_id]]
|
|
97
|
+
self.answered(:user, ::Protocol::URL::Encoding.unescape(user_id), parameters)
|
|
98
|
+
|
|
99
|
+
in ["GET", ["rooms", alias_id]]
|
|
100
|
+
self.answered(:room, ::Protocol::URL::Encoding.unescape(alias_id), parameters)
|
|
101
|
+
|
|
102
|
+
in ["GET", ["thirdparty", *rest]]
|
|
103
|
+
self.answered(:thirdparty, rest, parameters)
|
|
104
|
+
|
|
105
|
+
# Which tells a homeserver that a feature is missing, rather than that this service is broken.
|
|
106
|
+
else
|
|
107
|
+
self.failure(404, "M_UNRECOGNIZED", "This service does not answer #{request.path}.")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def inspect = "\#<#{self.class} #{@registration.id}>"
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Hand the transaction over, and say whether to send it again.
|
|
116
|
+
#
|
|
117
|
+
# It is not recorded until it has been handled, so a handler which raises leaves the
|
|
118
|
+
# homeserver to retry rather than losing what it carried.
|
|
119
|
+
#
|
|
120
|
+
# @parameter request [Protocol::HTTP::Request] the request it arrived in.
|
|
121
|
+
# @parameter txn_id [String] the transaction, which the homeserver numbers.
|
|
122
|
+
# @returns [Protocol::HTTP::Response] the answer.
|
|
123
|
+
def transaction(request, txn_id)
|
|
124
|
+
case @store.seen?(txn_id)
|
|
125
|
+
in true
|
|
126
|
+
Console.debug(self) {"Transaction #{txn_id} was handled already."}
|
|
127
|
+
|
|
128
|
+
self.rendered(200, {})
|
|
129
|
+
in false
|
|
130
|
+
transaction = Transaction.for(request, txn_id, version: @version)
|
|
131
|
+
|
|
132
|
+
@block&.call(transaction)
|
|
133
|
+
@store.mark_seen(txn_id)
|
|
134
|
+
|
|
135
|
+
self.rendered(200, {})
|
|
136
|
+
end
|
|
137
|
+
rescue ::JSON::ParserError => error
|
|
138
|
+
# Sending it again would carry the same bytes, so this says so rather than asking for them.
|
|
139
|
+
Console.error(self, "A transaction was not JSON.", txn_id: txn_id, error: error)
|
|
140
|
+
|
|
141
|
+
self.failure(400, "M_BAD_JSON", error.message)
|
|
142
|
+
rescue => error
|
|
143
|
+
# Not recorded, so the homeserver sends it again: whatever failed may not fail twice.
|
|
144
|
+
Console.error(self, "A transaction was not handled.", txn_id: txn_id, error: error)
|
|
145
|
+
|
|
146
|
+
self.failure(500, "M_UNKNOWN", error.message)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Ask whoever registered an answer for this kind of question.
|
|
150
|
+
#
|
|
151
|
+
# The answer is a block rather than a lambda, so one which does not want the query string
|
|
152
|
+
# may simply not take it.
|
|
153
|
+
#
|
|
154
|
+
# @parameter kind [Symbol] `:user`, `:room` or `:thirdparty`.
|
|
155
|
+
# @parameter subject [Object] what was asked about.
|
|
156
|
+
# @parameter parameters [Hash] the query string, which the third party lookups read.
|
|
157
|
+
# @returns [Protocol::HTTP::Response] the answer.
|
|
158
|
+
def answered(kind, subject, parameters)
|
|
159
|
+
query = @queries[kind]
|
|
160
|
+
|
|
161
|
+
case query&.call(subject, parameters)
|
|
162
|
+
# Nothing answered, so this service does not claim it.
|
|
163
|
+
in nil | false
|
|
164
|
+
self.failure(404, "M_NOT_FOUND", "This service does not know #{subject}.")
|
|
165
|
+
|
|
166
|
+
# A document to send back, which the third party lookups answer with.
|
|
167
|
+
in Hash | Array => document
|
|
168
|
+
self.rendered(200, document)
|
|
169
|
+
|
|
170
|
+
# Anything else is a yes, and a yes carries no document.
|
|
171
|
+
else
|
|
172
|
+
self.rendered(200, {})
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Whether the request carries the token the registration declares.
|
|
177
|
+
#
|
|
178
|
+
# From v1.4 it is a bearer token. Before that it was a query parameter, which a homeserver
|
|
179
|
+
# may still send, and which must agree when both are there.
|
|
180
|
+
#
|
|
181
|
+
# @parameter request [Protocol::HTTP::Request] the request to check.
|
|
182
|
+
# @parameter parameters [Hash] its query string, decoded.
|
|
183
|
+
# @returns [Boolean] whether it matches.
|
|
184
|
+
def authentic?(request, parameters)
|
|
185
|
+
offered = [self.bearer(request), parameters["access_token"]].compact
|
|
186
|
+
|
|
187
|
+
case offered
|
|
188
|
+
in []
|
|
189
|
+
false
|
|
190
|
+
in [*] if offered.all?{|token| self.matches?(token)}
|
|
191
|
+
true
|
|
192
|
+
else
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @parameter request [Protocol::HTTP::Request] the request to read.
|
|
198
|
+
# @returns [String | nil] the bearer token it carries.
|
|
199
|
+
def bearer(request)
|
|
200
|
+
case request.headers["authorization"]
|
|
201
|
+
in String => authorization if authorization.start_with?("Bearer ")
|
|
202
|
+
authorization.delete_prefix("Bearer ")
|
|
203
|
+
else
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Compared in constant time, because the comparison is what an attacker measures.
|
|
209
|
+
#
|
|
210
|
+
# @parameter token [String] the token offered.
|
|
211
|
+
# @returns [Boolean] whether it is the one the registration declares.
|
|
212
|
+
def matches?(token)
|
|
213
|
+
::OpenSSL.fixed_length_secure_compare(token, @registration.hs_token)
|
|
214
|
+
rescue ArgumentError
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The query string, which a reference carries as it arrived rather than decoded.
|
|
219
|
+
#
|
|
220
|
+
# @parameter reference [Protocol::URL::Reference] the path this request asked for.
|
|
221
|
+
# @returns [Hash] its parameters, which may be none.
|
|
222
|
+
def parameters_of(reference)
|
|
223
|
+
case reference.query
|
|
224
|
+
in String => query
|
|
225
|
+
::Protocol::URL::Encoding.decode_www_form(query)
|
|
226
|
+
else
|
|
227
|
+
{}
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @parameter status [Integer] the status to answer with.
|
|
232
|
+
# @parameter payload [Hash | Array] the document to answer with.
|
|
233
|
+
# @returns [Protocol::HTTP::Response] the response.
|
|
234
|
+
def rendered(status, payload)
|
|
235
|
+
::Protocol::HTTP::Response[status, JSON_HEADERS, [::JSON.generate(payload)]]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The shape the client-server API uses, so a caller reads it the same way either side.
|
|
239
|
+
#
|
|
240
|
+
# @parameter status [Integer] the status to answer with.
|
|
241
|
+
# @parameter errcode [String] the Matrix error code.
|
|
242
|
+
# @parameter error [String] what went wrong, for a person to read.
|
|
243
|
+
# @returns [Protocol::HTTP::Response] the response.
|
|
244
|
+
def failure(status, errcode, error)
|
|
245
|
+
self.rendered(status, {errcode: errcode, error: error})
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|