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 CustomerInstance
202 def self.for(customer, domain, launched)
203 new(
204 instance_id: launched.instance_id,
205 bootstrap_token: launched.bootstrap_token || "",
206 customer_id: customer.customer_id,
207 domain: domain
208 )
209 end
210
211 value_semantics do
212 instance_id String
213 bootstrap_token String
214 customer_id String
215 domain String
216 end
217
218 def bootstrap_uri
219 "https://#{domain}/invites_bootstrap?token=#{bootstrap_token}"
220 end
221
222 def fetch_invite
223 url = bootstrap_uri
224 EM::HttpRequest.new(
225 url, tls: { verify_peer: true }
226 ).ahead(redirects: 5).then { |res|
227 LinkHeaderParser.parse(
228 Array(res.response_header["LINK"]), base: url
229 ).group_by_relation_type[:alternate]&.find do |header|
230 URI.parse(header.target_uri).scheme == "xmpp"
231 end&.target_uri
232 }.catch { nil }
233 end
234 end
235
236 class Repo
237 def initialize(db: LazyObject.new { DB })
238 @db = db
239 end
240
241 def find_by_customer(customer)
242 promise = @db.query_defer(<<~SQL, [customer.customer_id])
243 SELECT instance_id, bootstrap_token, customer_id, domain
244 FROM snikket_instances
245 WHERE customer_id=$1
246 SQL
247 promise.then do |rows|
248 rows.map { |row| CustomerInstance.new(**row.transform_keys(&:to_sym)) }
249 end
250 end
251
252 def del(instance)
253 return EMPromise.resolve(nil) unless instance
254
255 params = [instance.instance_id, instance.customer_id, instance.domain]
256
257 IQ_MANAGER.write(Delete.new(
258 nil, CONFIG[:snikket_hosting_api], instance_id: instance.instance_id
259 )).then do
260 @db.exec_defer(<<~SQL, params)
261 DELETE FROM snikket_instances
262 WHERE instance_id=$1 AND customer_id=$2 AND domain=$3
263 SQL
264 end
265 end
266
267 def put(instance)
268 params = [
269 instance.instance_id, instance.bootstrap_token,
270 instance.customer_id, instance.domain
271 ]
272 @db.exec_defer(<<~SQL, params)
273 INSERT INTO snikket_instances
274 (instance_id, bootstrap_token, customer_id, domain)
275 VALUES
276 ($1, $2, $3, $4)
277 ON CONFLICT (instance_id)
278 DO UPDATE SET bootstrap_token=$2
279 SQL
280 end
281 end
282end