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 Delete < Blather::Stanza::Iq
177 register nil, "delete", "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("delete", 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:delete", ns: self.class.registered_ns)
198 end
199 end
200
201 class DomainInfo < Blather::Stanza::Iq
202 register nil, "domain-info", "xmpp:snikket.org/hosting/v1"
203
204 def self.new(type=nil, to=nil, id=nil, domain: nil)
205 stanza = super(type || :get, to, id)
206 if domain
207 node = Nokogiri::XML::Node.new("domain-info", stanza.document)
208 node.default_namespace = registered_ns
209 stanza << node
210 end
211 stanza.domain = domain if domain
212 stanza
213 end
214
215 def domain=(domain)
216 query.at_xpath("./ns:domain", ns: self.class.registered_ns)&.remove
217 node = Nokogiri::XML::Node.new("domain", document)
218 node.default_namespace = self.class.registered_ns
219 node.content = domain
220 query << node
221 end
222
223 def instance_id
224 query
225 .at_xpath("./ns:instance-id", ns: self.class.registered_ns)
226 &.content
227 end
228
229 def available?
230 !!query.at_xpath("./ns:available", ns: self.class.registered_ns)
231 end
232
233 def custom?
234 !!query.at_xpath("./ns:custom", ns: self.class.registered_ns)
235 end
236
237 def query
238 at_xpath("./ns:domain-info", ns: self.class.registered_ns)
239 end
240 end
241
242 class CustomerInstance
243 def self.for(customer, domain, launched)
244 new(
245 instance_id: launched.instance_id,
246 bootstrap_token: launched.bootstrap_token || "",
247 customer_id: customer.customer_id,
248 domain: domain
249 )
250 end
251
252 value_semantics do
253 instance_id String
254 bootstrap_token String
255 customer_id String
256 domain String
257 end
258
259 def bootstrap_uri
260 "https://#{domain}/invites_bootstrap?token=#{bootstrap_token}"
261 end
262
263 def fetch_invite
264 url = bootstrap_uri
265 EM::HttpRequest.new(
266 url, tls: { verify_peer: true }
267 ).ahead(redirects: 5).then { |res|
268 LinkHeaderParser.parse(
269 Array(res.response_header["LINK"]), base: url
270 ).group_by_relation_type[:alternate]&.find do |header|
271 URI.parse(header.target_uri).scheme == "xmpp"
272 end&.target_uri
273 }.catch { nil }
274 end
275 end
276
277 class Repo
278 def initialize(db: LazyObject.new { DB })
279 @db = db
280 end
281
282 def find_by_customer(customer)
283 promise = @db.query_defer(<<~SQL, [customer.customer_id])
284 SELECT instance_id, bootstrap_token, customer_id, domain
285 FROM snikket_instances
286 WHERE customer_id=$1
287 SQL
288 promise.then do |rows|
289 rows.map { |row| CustomerInstance.new(**row.transform_keys(&:to_sym)) }
290 end
291 end
292
293 def del(instance)
294 return EMPromise.resolve(nil) unless instance
295
296 params = [instance.instance_id, instance.customer_id, instance.domain]
297
298 IQ_MANAGER.write(Delete.new(
299 nil, CONFIG[:snikket_hosting_api], instance_id: instance.instance_id
300 )).then do
301 @db.exec_defer(<<~SQL, params)
302 DELETE FROM snikket_instances
303 WHERE instance_id=$1 AND customer_id=$2 AND domain=$3
304 SQL
305 end
306 end
307
308 def put(instance)
309 params = [
310 instance.instance_id, instance.bootstrap_token,
311 instance.customer_id, instance.domain
312 ]
313 @db.exec_defer(<<~SQL, params)
314 INSERT INTO snikket_instances
315 (instance_id, bootstrap_token, customer_id, domain)
316 VALUES
317 ($1, $2, $3, $4)
318 ON CONFLICT (instance_id)
319 DO UPDATE SET bootstrap_token=$2
320 SQL
321 end
322 end
323end