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(type=nil, to=nil, id=nil, domain: nil)
 14			stanza = super(type || :set, to, id)
 15			node = Nokogiri::XML::Node.new("launch", stanza.document)
 16			node.default_namespace = registered_ns
 17			stanza << node
 18			stanza.domain = domain if domain
 19			stanza
 20		end
 21
 22		def domain=(domain)
 23			query.at_xpath("./ns:domain", ns: self.class.registered_ns)&.remove
 24			node = Nokogiri::XML::Node.new("domain", document)
 25			node.default_namespace = self.class.registered_ns
 26			node.content = domain
 27			query << node
 28		end
 29
 30		def query
 31			at_xpath("./ns:launch", ns: self.class.registered_ns)
 32		end
 33	end
 34
 35	class Launched < Blather::Stanza::Iq
 36		register :snikket_launched, "launched", "xmpp:snikket.org/hosting/v1"
 37
 38		def instance_id
 39			query
 40				.at_xpath("./ns:instance-id", ns: self.class.registered_ns)
 41				&.content
 42		end
 43
 44		def bootstrap_token
 45			query
 46				.at_xpath("./ns:bootstrap/ns:token", ns: self.class.registered_ns)
 47				&.content
 48		end
 49
 50		def status
 51			query
 52				.at_xpath("./ns:status", ns: self.class.registered_ns)
 53				&.content&.to_sym
 54		end
 55
 56		def records
 57			query
 58				.xpath("./ns:records/ns:record", ns: self.class.registered_ns)
 59				&.map(&Record.method(:new))
 60		end
 61
 62		def query
 63			at_xpath("./ns:launched", ns: self.class.registered_ns)
 64		end
 65	end
 66
 67	class Record
 68		NS = "xmpp:snikket.org/hosting/v1"
 69
 70		def initialize(element)
 71			@element = element
 72		end
 73
 74		def name
 75			@element
 76				.at_xpath("./ns:name", ns: NS)
 77				&.content
 78		end
 79
 80		def type
 81			@element
 82				.at_xpath("./ns:type", ns: NS)
 83				&.content
 84		end
 85
 86		def status
 87			@element
 88				.at_xpath("./ns:status", ns: NS)
 89				&.content&.to_sym
 90		end
 91
 92		def expected
 93			@element
 94				.xpath("./ns:expected/ns:value", ns: NS)
 95				&.map(&:content)
 96		end
 97
 98		def found
 99			@element
100				.xpath("./ns:found/ns:value", ns: NS)
101				&.map(&:content)
102		end
103	end
104
105	class Instance < Blather::Stanza::Iq
106		register :snikket_instance, "instance", "xmpp:snikket.org/hosting/v1"
107
108		def self.new(type=nil, to=nil, id=nil, instance_id: nil)
109			stanza = super(type || :get, to, id)
110			node = Nokogiri::XML::Node.new("instance", stanza.document)
111			node.default_namespace = registered_ns
112			stanza << node
113			stanza.instance_id = instance_id if instance_id
114			stanza
115		end
116
117		def inherit(*)
118			query.remove
119			super
120		end
121
122		def instance_id=(instance_id)
123			query.at_xpath("./ns:instance-id", ns: self.class.registered_ns)&.remove
124			node = Nokogiri::XML::Node.new("instance-id", document)
125			node.default_namespace = self.class.registered_ns
126			node.content = instance_id
127			query << node
128		end
129
130		def instance_id
131			query
132				.at_xpath("./ns:instance-id", ns: self.class.registered_ns)
133				&.content
134		end
135
136		def update_needed?
137			!!query.at_xpath("./ns:update-needed", ns: self.class.registered_ns)
138		end
139
140		def operation
141			query.at_xpath("./ns:operation", ns: self.class.registered_ns)
142		end
143
144		def status
145			query
146				.at_xpath("./ns:status", ns: self.class.registered_ns)
147				&.content&.to_sym
148		end
149
150		def query
151			at_xpath("./ns:instance", ns: self.class.registered_ns)
152		end
153	end
154
155	class Delete < Blather::Stanza::Iq
156		register nil, "delete", "xmpp:snikket.org/hosting/v1"
157
158		def self.new(type=nil, to=nil, id=nil, instance_id: nil)
159			stanza = super(type || :set, to, id)
160			node = Nokogiri::XML::Node.new("delete", stanza.document)
161			node.default_namespace = registered_ns
162			stanza << node
163			stanza.instance_id = instance_id if instance_id
164			stanza
165		end
166
167		def instance_id=(instance_id)
168			query.at_xpath("./ns:instance-id", ns: self.class.registered_ns)&.remove
169			node = Nokogiri::XML::Node.new("instance-id", document)
170			node.default_namespace = self.class.registered_ns
171			node.content = instance_id
172			query << node
173		end
174
175		def query
176			at_xpath("./ns:delete", ns: self.class.registered_ns)
177		end
178	end
179
180	class CustomerInstance
181		def self.for(customer, domain, launched)
182			new(
183				instance_id: launched.instance_id,
184				bootstrap_token: launched.bootstrap_token || "",
185				customer_id: customer.customer_id,
186				domain: domain
187			)
188		end
189
190		value_semantics do
191			instance_id     String
192			bootstrap_token String
193			customer_id     String
194			domain          String
195		end
196
197		def bootstrap_uri
198			"https://#{domain}/invites_bootstrap?token=#{bootstrap_token}"
199		end
200
201		def fetch_invite
202			url = bootstrap_uri
203			EM::HttpRequest.new(
204				url, tls: { verify_peer: true }
205			).ahead(redirects: 5).then { |res|
206				LinkHeaderParser.parse(
207					Array(res.response_header["LINK"]), base: url
208				).group_by_relation_type[:alternate]&.find do |header|
209					URI.parse(header.target_uri).scheme == "xmpp"
210				end&.target_uri
211			}.catch { nil }
212		end
213	end
214
215	class Repo
216		def initialize(db: LazyObject.new { DB })
217			@db = db
218		end
219
220		def find_by_customer(customer)
221			promise = @db.query_defer(<<~SQL, [customer.customer_id])
222				SELECT instance_id, bootstrap_token, customer_id, domain
223				FROM snikket_instances
224				WHERE customer_id=$1
225			SQL
226			promise.then do |rows|
227				rows.map { |row| CustomerInstance.new(**row.transform_keys(&:to_sym)) }
228			end
229		end
230
231		def del(instance)
232			return EMPromise.resolve(nil) unless instance
233
234			params = [instance.instance_id, instance.customer_id, instance.domain]
235
236			IQ_MANAGER.write(Delete.new(
237				nil, CONFIG[:snikket_hosting_api], instance_id: instance.instance_id
238			)).then do
239				@db.exec_defer(<<~SQL, params)
240					DELETE FROM snikket_instances
241					WHERE instance_id=$1 AND customer_id=$2 AND domain=$3
242				SQL
243			end
244		end
245
246		def put(instance)
247			params = [
248				instance.instance_id, instance.bootstrap_token,
249				instance.customer_id, instance.domain
250			]
251			@db.exec_defer(<<~SQL, params)
252				INSERT INTO snikket_instances
253					(instance_id, bootstrap_token, customer_id, domain)
254				VALUES
255					($1, $2, $3, $4)
256				ON CONFLICT (instance_id)
257				DO UPDATE SET bootstrap_token=$2
258			SQL
259		end
260	end
261end