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"
  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