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