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