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