1# frozen_string_literal: true
2
3require "blather"
4require "em-http"
5require "em_promise"
6require "em-synchrony/em-http" # For apost vs post
7require "link-header-parser"
8require "uri"
9require "value_semantics/monkey_patched"
10
11module Snikket
12 class Launch < Blather::Stanza::Iq
13 register nil, "launch", "xmpp:snikket.org/hosting/v1"
14
15 def self.new(
16 type=nil, to=nil, id=nil,
17 domain: nil, region: nil, test_instance: false, av_region: "na"
18 )
19 stanza = super(type || :set, to, id)
20 node = Nokogiri::XML::Node.new("launch", stanza.document)
21 node.default_namespace = registered_ns
22 stanza << node
23 stanza.domain = domain if domain
24 stanza.region = region if region
25 stanza.av_region = av_region if av_region
26 stanza.test_instance = test_instance
27 stanza
28 end
29
30 def domain=(domain)
31 query.at_xpath("./ns:domain", ns: self.class.registered_ns)&.remove
32 node = Nokogiri::XML::Node.new("domain", document)
33 node.default_namespace = self.class.registered_ns
34 node.content = domain
35 query << node
36 end
37
38 def region=(domain)
39 query.at_xpath("./ns:region", ns: self.class.registered_ns)&.remove
40 node = Nokogiri::XML::Node.new("region", document)
41 node.default_namespace = self.class.registered_ns
42 node.content = domain
43 query << node
44 end
45
46 def av_region=(domain)
47 query.at_xpath("./ns:av-region", ns: self.class.registered_ns)&.remove
48 node = Nokogiri::XML::Node.new("av-region", document)
49 node.default_namespace = self.class.registered_ns
50 node.content = domain
51 query << node
52 end
53
54 def query
55 at_xpath("./ns:launch", ns: self.class.registered_ns)
56 end
57
58 def test_instance=(enabled)
59 query.at_xpath("./ns:test-instance", ns: self.class.registered_ns)&.remove
60 return unless enabled
61
62 node = Nokogiri::XML::Node.new("test-instance", document)
63 node.default_namespace = self.class.registered_ns
64 query << node
65 end
66 end
67
68 class Launched < Blather::Stanza::Iq
69 register :snikket_launched, "launched", "xmpp:snikket.org/hosting/v1"
70
71 def instance_id
72 query
73 .at_xpath("./ns:instance-id", ns: self.class.registered_ns)
74 &.content
75 end
76
77 def bootstrap_token
78 query
79 .at_xpath("./ns:bootstrap/ns:token", ns: self.class.registered_ns)
80 &.content
81 end
82
83 def status
84 query
85 .at_xpath("./ns:status", ns: self.class.registered_ns)
86 &.content&.to_sym
87 end
88
89 def records
90 query
91 .xpath("./ns:records/ns:record", ns: self.class.registered_ns)
92 &.map(&Record.method(:new))
93 end
94
95 def query
96 at_xpath("./ns:launched", ns: self.class.registered_ns)
97 end
98 end
99
100 class Record
101 NS = "xmpp:snikket.org/hosting/v1"
102
103 def initialize(element)
104 @element = element
105 end
106
107 def name
108 @element
109 .at_xpath("./ns:name", ns: NS)
110 &.content
111 end
112
113 def type
114 @element
115 .at_xpath("./ns:type", ns: NS)
116 &.content
117 end
118
119 def status
120 @element
121 .at_xpath("./ns:status", ns: NS)
122 &.content&.to_sym
123 end
124
125 def expected
126 @element
127 .xpath("./ns:expected/ns:value", ns: NS)
128 &.map(&:content)
129 end
130
131 def found
132 @element
133 .xpath("./ns:found/ns:value", ns: NS)
134 &.map(&:content)
135 end
136 end
137
138 class Instance < Blather::Stanza::Iq
139 register :snikket_instance, "instance", "xmpp:snikket.org/hosting/v1"
140
141 def self.new(type=nil, to=nil, id=nil, instance_id: nil)
142 stanza = super(type || :get, to, id)
143 node = Nokogiri::XML::Node.new("instance", stanza.document)
144 node.default_namespace = registered_ns
145 stanza << node
146 stanza.instance_id = instance_id if instance_id
147 stanza
148 end
149
150 def inherit(*)
151 query.remove
152 super
153 end
154
155 def instance_id=(instance_id)
156 query.at_xpath("./ns:instance-id", ns: self.class.registered_ns)&.remove
157 node = Nokogiri::XML::Node.new("instance-id", document)
158 node.default_namespace = self.class.registered_ns
159 node.content = instance_id
160 query << node
161 end
162
163 def instance_id
164 query
165 .at_xpath("./ns:instance-id", ns: self.class.registered_ns)
166 &.content
167 end
168
169 def update_needed?
170 !!query.at_xpath("./ns:update-needed", ns: self.class.registered_ns)
171 end
172
173 def operation
174 query.at_xpath("./ns:operation", ns: self.class.registered_ns)
175 end
176
177 def status
178 query
179 .at_xpath("./ns:status", ns: self.class.registered_ns)
180 &.content&.to_sym
181 end
182
183 def query
184 at_xpath("./ns:instance", ns: self.class.registered_ns)
185 end
186 end
187
188 class Stop < Blather::Stanza::Iq
189 register nil, "stop", "xmpp:snikket.org/hosting/v1"
190
191 def self.new(type=nil, to=nil, id=nil, instance_id: nil)
192 stanza = super(type || :set, to, id)
193 node = Nokogiri::XML::Node.new("stop", stanza.document)
194 node.default_namespace = registered_ns
195 stanza << node
196 stanza.instance_id = instance_id if instance_id
197 stanza
198 end
199
200 def instance_id=(instance_id)
201 query.at_xpath("./ns:instance-id", ns: self.class.registered_ns)&.remove
202 node = Nokogiri::XML::Node.new("instance-id", document)
203 node.default_namespace = self.class.registered_ns
204 node.content = instance_id
205 query << node
206 end
207
208 def query
209 at_xpath("./ns:stop", ns: self.class.registered_ns)
210 end
211 end
212
213 class Delete < Blather::Stanza::Iq
214 register nil, "delete", "xmpp:snikket.org/hosting/v1"
215
216 def self.new(type=nil, to=nil, id=nil, instance_id: nil)
217 stanza = super(type || :set, to, id)
218 node = Nokogiri::XML::Node.new("delete", stanza.document)
219 node.default_namespace = registered_ns
220 stanza << node
221 stanza.instance_id = instance_id if instance_id
222 stanza
223 end
224
225 def instance_id=(instance_id)
226 query.at_xpath("./ns:instance-id", ns: self.class.registered_ns)&.remove
227 node = Nokogiri::XML::Node.new("instance-id", document)
228 node.default_namespace = self.class.registered_ns
229 node.content = instance_id
230 query << node
231 end
232
233 def query
234 at_xpath("./ns:delete", ns: self.class.registered_ns)
235 end
236 end
237
238 class DomainInfo < Blather::Stanza::Iq
239 register nil, "domain-info", "xmpp:snikket.org/hosting/v1"
240
241 def self.new(type=nil, to=nil, id=nil, domain: nil)
242 stanza = super(type || :get, to, id)
243 if domain
244 node = Nokogiri::XML::Node.new("domain-info", stanza.document)
245 node.default_namespace = registered_ns
246 stanza << node
247 end
248 stanza.domain = domain if domain
249 stanza
250 end
251
252 def domain=(domain)
253 query.at_xpath("./ns:domain", ns: self.class.registered_ns)&.remove
254 node = Nokogiri::XML::Node.new("domain", document)
255 node.default_namespace = self.class.registered_ns
256 node.content = domain
257 query << node
258 end
259
260 def instance_id
261 query
262 .at_xpath("./ns:instance-id", ns: self.class.registered_ns)
263 &.content
264 end
265
266 def available?
267 !!query.at_xpath("./ns:available", ns: self.class.registered_ns)
268 end
269
270 def custom?
271 !!query.at_xpath("./ns:custom", ns: self.class.registered_ns)
272 end
273
274 def query
275 at_xpath("./ns:domain-info", ns: self.class.registered_ns)
276 end
277 end
278
279 class CustomerInstance
280 class InviteRepo
281 def initialize(bootstrap_uri:)
282 @bootstrap_uri = bootstrap_uri
283 end
284
285 def fetch
286 url = bootstrap_uri
287 EMPromise.resolve(nil).then {
288 req(url)
289 }.then { |res|
290 parse_invite(url, res)
291 }.catch { nil }
292 end
293
294 protected
295
296 attr_reader :bootstrap_uri
297
298 def req(url)
299 EM::HttpRequest.new(
300 url, tls: { verify_peer: true }
301 ).ahead(redirects: 5)
302 end
303
304 def parse_invite(url, res)
305 LinkHeaderParser.parse(
306 Array(res.response_header["LINK"]), base_uri: url
307 ).group_by_relation_type["alternate"]&.find do |header|
308 URI.parse(header.target_uri).scheme == "xmpp"
309 end&.target_uri
310 end
311 end
312
313 def self.for(customer, domain, launched)
314 new(
315 instance_id: launched.instance_id,
316 bootstrap_token: launched.bootstrap_token || "",
317 customer_id: customer.customer_id,
318 domain: domain
319 )
320 end
321
322 value_semantics do
323 instance_id String
324 bootstrap_token String
325 customer_id String
326 domain String
327 end
328
329 def bootstrap_uri
330 "https://#{domain}/invites_bootstrap?token=#{bootstrap_token}"
331 end
332
333 def fetch_invite
334 InviteRepo.new(bootstrap_uri: bootstrap_uri).fetch
335 end
336 end
337
338 class Repo
339 def initialize(db: LazyObject.new { DB })
340 @db = db
341 end
342
343 def find_by_customer(customer)
344 promise = @db.query_defer(<<~SQL, [customer.customer_id])
345 SELECT instance_id, bootstrap_token, customer_id, domain
346 FROM snikket_instances
347 WHERE customer_id=$1
348 SQL
349 promise.then do |rows|
350 rows.map { |row| CustomerInstance.new(**row.transform_keys(&:to_sym)) }
351 end
352 end
353
354 def del(instance)
355 return EMPromise.resolve(nil) unless instance
356
357 params = [instance.instance_id, instance.customer_id, instance.domain]
358
359 IQ_MANAGER.write(Delete.new(
360 nil, CONFIG[:snikket_hosting_api], instance_id: instance.instance_id
361 )).then do
362 @db.exec_defer(<<~SQL, params)
363 DELETE FROM snikket_instances
364 WHERE instance_id=$1 AND customer_id=$2 AND domain=$3
365 SQL
366 end
367 end
368
369 def put(instance)
370 params = [
371 instance.instance_id, instance.bootstrap_token,
372 instance.customer_id, instance.domain
373 ]
374 @db.exec_defer(<<~SQL, params)
375 INSERT INTO snikket_instances
376 (instance_id, bootstrap_token, customer_id, domain)
377 VALUES
378 ($1, $2, $3, $4)
379 ON CONFLICT (instance_id)
380 DO UPDATE SET bootstrap_token=$2
381 SQL
382 end
383 end
384end