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