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