snikket.rb

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