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