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